mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-11-03 00:53:30 +00:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d9b67e444a | |||
| 47b91f76e7 | |||
| 9c6b55a8bc | |||
| f00b475f59 | |||
| f8743dcae0 | |||
| bd94382edb | |||
| 8abd3a8848 | |||
| 43b3008266 | |||
| 2304436b8c | |||
| ad875d4021 | |||
| fd6b68231e | |||
| 88494a312f | |||
| 6901f6f682 | |||
| 82c0b20be1 | |||
| 4fa439cc3c | |||
| b631c63c53 | |||
| e9aaf3db53 | |||
| a37930e87a | |||
| 6bc5d47afe | |||
| 0ebbf87e5b |
+1
-1
Submodule .claude/commands updated: c5604145b3...45a18760a0
@@ -105,7 +105,7 @@ jobs:
|
||||
# the local install so the gate and the pre-commit run agree on the finding set.
|
||||
# Informational findings (template expansion of our own step outputs in pwsh) are
|
||||
# visible locally but do not fail the gate.
|
||||
- uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4
|
||||
- uses: zizmorcore/zizmor-action@53290badfa369cb0bf7ad16342cb62c2fa47ac7b # v0.8.0
|
||||
with:
|
||||
version: 1.30.1
|
||||
min-severity: low
|
||||
|
||||
@@ -247,7 +247,7 @@ jobs:
|
||||
Get-ChildItem $outDir | ForEach-Object { Write-Host "$($_.Name): $([math]::Round($_.Length / 1MB, 2)) MB" }
|
||||
|
||||
- name: Upload unsigned build output
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: agent-unsigned
|
||||
path: ${{ runner.temp }}/agent-unsigned
|
||||
@@ -293,7 +293,7 @@ jobs:
|
||||
run: ./.github/scripts/Get-Endpoints.ps1 -ExportToGitHubEnv
|
||||
|
||||
- name: Download unsigned build output
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: agent-unsigned
|
||||
path: ${{ runner.temp }}/agent-unsigned
|
||||
@@ -790,13 +790,13 @@ jobs:
|
||||
staging/agent/AutopilotMonitor.SummaryDialog.exe
|
||||
|
||||
- name: Upload build artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: AutopilotMonitor-Agent-v${{ needs.reserve.outputs.version }}
|
||||
path: ${{ steps.artifact.outputs.versioned_zip_release }}
|
||||
|
||||
- name: Upload version metadata
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: version-metadata
|
||||
path: ${{ env.MANIFEST_NAME }}
|
||||
|
||||
@@ -127,7 +127,7 @@ jobs:
|
||||
Write-Host "$env:RUNNER_SCRIPT : Valid, timestamped, signer $($sig.SignerCertificate.Subject.Split(',')[0])"
|
||||
|
||||
- name: Upload signed loader
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: bootstrap-loader-signed
|
||||
path: ${{ env.RUNNER_SCRIPT }}
|
||||
@@ -148,7 +148,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Download signed loader
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: bootstrap-loader-signed
|
||||
path: ${{ runner.temp }}/loader
|
||||
@@ -187,7 +187,7 @@ jobs:
|
||||
Write-Host "Built $env:STABLE_MSI ($([math]::Round((Get-Item $env:STABLE_MSI).Length / 1KB)) KB)"
|
||||
|
||||
- name: Upload unsigned MSI
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: bootstrap-msi-unsigned
|
||||
path: ${{ env.STABLE_MSI }}
|
||||
@@ -215,7 +215,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Download unsigned MSI
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: bootstrap-msi-unsigned
|
||||
path: ${{ runner.temp }}/msi
|
||||
@@ -310,7 +310,7 @@ jobs:
|
||||
"version=$version" >> $env:GITHUB_OUTPUT
|
||||
|
||||
- name: Download unsigned MSI
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: bootstrap-msi-unsigned
|
||||
path: ${{ runner.temp }}/msi
|
||||
@@ -442,7 +442,7 @@ jobs:
|
||||
subject-path: ${{ env.STABLE_MSI }}
|
||||
|
||||
- name: Upload build artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: AutopilotMonitor-Bootstrap-v${{ steps.version.outputs.version }}
|
||||
path: ${{ env.STABLE_MSI }}
|
||||
|
||||
@@ -134,7 +134,7 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
@@ -176,7 +176,7 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
@@ -222,7 +222,7 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
@@ -245,7 +245,7 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
@@ -276,7 +276,7 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
|
||||
@@ -112,7 +112,7 @@ jobs:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
|
||||
uses: github/codeql-action/init@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.build-mode }}
|
||||
@@ -123,6 +123,6 @@ jobs:
|
||||
run: dotnet build AutopilotMonitor.sln --configuration Release
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
|
||||
uses: github/codeql-action/analyze@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
|
||||
with:
|
||||
category: /language:${{ matrix.language }}
|
||||
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
@@ -66,7 +66,7 @@ jobs:
|
||||
# `git push` needs.
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
|
||||
@@ -99,7 +99,7 @@ jobs:
|
||||
run: tar -czf "$RUNNER_TEMP/backend-publish.tgz" -C publish .
|
||||
|
||||
- name: Upload publish output
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: backend-publish
|
||||
path: ${{ runner.temp }}/backend-publish.tgz
|
||||
@@ -128,7 +128,7 @@ jobs:
|
||||
run: ./.github/scripts/Get-Endpoints.ps1 -ExportToGitHubEnv
|
||||
|
||||
- name: Download publish output
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: backend-publish
|
||||
path: ${{ runner.temp }}/backend-artifact
|
||||
|
||||
@@ -74,7 +74,7 @@ jobs:
|
||||
id: docs
|
||||
run: echo "sha=$(git -C whats-new-docs rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
@@ -149,7 +149,7 @@ jobs:
|
||||
sha256sum "$archive"
|
||||
|
||||
- name: Upload build artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: portal-${{ github.sha }}
|
||||
path: ${{ steps.package.outputs.archive }}
|
||||
@@ -170,7 +170,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Download build artifact
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: portal-${{ github.sha }}
|
||||
path: ${{ runner.temp }}/portal
|
||||
@@ -215,7 +215,7 @@ jobs:
|
||||
run: ./.github/scripts/Get-Endpoints.ps1 -ExportToGitHubEnv
|
||||
|
||||
- name: Download build artifact
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: portal-${{ github.sha }}
|
||||
path: ${{ runner.temp }}/portal
|
||||
|
||||
@@ -150,7 +150,7 @@ jobs:
|
||||
# when someone wants to look at the file.
|
||||
- name: Upload signed scripts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: signed-scripts
|
||||
path: ${{ runner.temp }}/signed-scripts
|
||||
|
||||
+1
-1
Submodule internal updated: 2609b9f3e5...70adb72c87
+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));
|
||||
}
|
||||
}
|
||||
}
|
||||
+162
@@ -0,0 +1,162 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Agent.V2.Core.Transport;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
{
|
||||
/// <summary>The watchers only listen on the real keys; settings are played in, none is changed.</summary>
|
||||
public sealed class ProxySettingsHostTests : IDisposable
|
||||
{
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
private readonly FakeSignalIngressSink _ingress = new FakeSignalIngressSink();
|
||||
private readonly VirtualClock _clock = new VirtualClock(new DateTime(2026, 11, 2, 6, 53, 43, DateTimeKind.Utc));
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly ProxyObserver _observer;
|
||||
private ProxySettings _settings = new ProxySettings(true, null, null, null, null);
|
||||
private string _route = "proxy.example.test:8080";
|
||||
|
||||
public ProxySettingsHostTests()
|
||||
{
|
||||
_logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
|
||||
_observer = new ProxyObserver(() => _settings, () => _route, _logger, _clock, TimeSpan.FromSeconds(30));
|
||||
_observer.CaptureBaseline();
|
||||
}
|
||||
|
||||
private ProxySettingsHost NewHost() => new ProxySettingsHost(_observer, _ingress, _clock, _logger);
|
||||
|
||||
private void SwitchToNoProxy()
|
||||
{
|
||||
_settings = new ProxySettings(false, null, null, null, null);
|
||||
_route = "direct";
|
||||
}
|
||||
|
||||
private List<FakeSignalIngressSink.PostedSignal> ProxyEvents() =>
|
||||
_ingress.Posted
|
||||
.Where(p => p.Kind == DecisionSignalKind.InformationalEvent
|
||||
&& p.Payload != null
|
||||
&& p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var eventType)
|
||||
&& eventType == Constants.EventTypes.ProxyConfiguration)
|
||||
.ToList();
|
||||
|
||||
private string Log()
|
||||
{
|
||||
var text = string.Empty;
|
||||
foreach (var file in Directory.EnumerateFiles(_tmp.Path, "*.log", SearchOption.AllDirectories))
|
||||
{
|
||||
using var stream = new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
|
||||
using var reader = new StreamReader(stream);
|
||||
text += reader.ReadToEnd();
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
public void Dispose() => _tmp.Dispose();
|
||||
|
||||
[Fact]
|
||||
public void A_change_goes_on_the_timeline_as_a_warning_with_what_moved()
|
||||
{
|
||||
using var host = NewHost();
|
||||
host.Start();
|
||||
SwitchToNoProxy();
|
||||
|
||||
_observer.CheckForChange();
|
||||
|
||||
var signal = Assert.Single(ProxyEvents());
|
||||
Assert.Equal(nameof(EventSeverity.Warning), signal.Payload![SignalPayloadKeys.Severity]);
|
||||
Assert.Equal("Proxy settings changed: IE settings of this account: auto-detect -> no proxy", signal.Payload[SignalPayloadKeys.Message]);
|
||||
|
||||
var data = Assert.IsAssignableFrom<IReadOnlyDictionary<string, object>>(signal.TypedPayload);
|
||||
Assert.Equal("changed", data["trigger"]);
|
||||
Assert.Equal("IE settings of this account: auto-detect -> no proxy", data["change"]);
|
||||
Assert.Equal("direct", data["route"]);
|
||||
Assert.Equal("Direct", data["proxyType"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_change_between_the_baseline_and_the_start_is_noticed_at_the_start()
|
||||
{
|
||||
SwitchToNoProxy();
|
||||
|
||||
using var host = NewHost();
|
||||
host.Start();
|
||||
|
||||
Assert.Single(ProxyEvents());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void No_change_means_no_event()
|
||||
{
|
||||
using var host = NewHost();
|
||||
host.Start();
|
||||
|
||||
_observer.CheckForChange();
|
||||
|
||||
Assert.Empty(ProxyEvents());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_stopped_ingress_does_not_reach_the_upload_path()
|
||||
{
|
||||
using var host = NewHost();
|
||||
host.Start();
|
||||
_ingress.ThrowOnPost = new InvalidOperationException("SignalIngress has been stopped.");
|
||||
SwitchToNoProxy();
|
||||
|
||||
_observer.OnSendFailed(TimeSpan.Zero);
|
||||
|
||||
Assert.Contains("uploads are failing", Log());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Stopping_while_uploads_fail_says_so_once()
|
||||
{
|
||||
var host = NewHost();
|
||||
host.Start();
|
||||
_observer.OnSendFailed(TimeSpan.Zero);
|
||||
|
||||
host.Stop();
|
||||
host.Stop();
|
||||
host.Dispose();
|
||||
|
||||
var log = Log();
|
||||
var first = log.IndexOf("stopping with uploads still failing", StringComparison.Ordinal);
|
||||
Assert.True(first >= 0);
|
||||
Assert.Equal(-1, log.IndexOf("stopping with uploads still failing", first + 1, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_change_after_the_stop_no_longer_posts()
|
||||
{
|
||||
var host = NewHost();
|
||||
host.Start();
|
||||
host.Stop();
|
||||
SwitchToNoProxy();
|
||||
|
||||
_observer.CheckForChange();
|
||||
|
||||
Assert.Empty(ProxyEvents());
|
||||
host.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_host_that_never_started_stays_silent_on_stop()
|
||||
{
|
||||
var host = NewHost();
|
||||
_observer.OnSendFailed(TimeSpan.Zero);
|
||||
|
||||
host.Dispose();
|
||||
|
||||
Assert.DoesNotContain("stopping with uploads still failing", Log());
|
||||
}
|
||||
}
|
||||
}
|
||||
+130
@@ -6,6 +6,7 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Agent.V2.Core.Transport;
|
||||
using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry;
|
||||
using Xunit;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
@@ -35,6 +36,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
|
||||
requiresImmediateFlush: false,
|
||||
enqueuedAtUtc: At);
|
||||
|
||||
private static ProxyObserver NewProxyObserver(AgentLogger logger) =>
|
||||
new ProxyObserver(
|
||||
() => new ProxySettings(false, null, null, null, null),
|
||||
() => "direct",
|
||||
logger,
|
||||
new VirtualClock(At),
|
||||
TimeSpan.FromSeconds(30));
|
||||
|
||||
private static string ReadLogs(string path)
|
||||
{
|
||||
// AgentLogger writes to a dated file; scan the whole dir to stay robust to format.
|
||||
@@ -235,5 +244,126 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
|
||||
Assert.DoesNotContain("flushing", logContent);
|
||||
Assert.DoesNotContain("ingest", logContent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Network_failure_names_the_route_and_opens_a_series()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info);
|
||||
|
||||
var handler = new RecordingHttpMessageHandler();
|
||||
handler.QueueThrow(new HttpRequestException("An error occurred while sending the request."));
|
||||
using var http = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(30) };
|
||||
|
||||
var sut = new BackendTelemetryUploader(
|
||||
httpClient: http, baseUrl: "https://backend.test", tenantId: "T1", logger: logger, proxy: NewProxyObserver(logger));
|
||||
|
||||
await sut.UploadBatchAsync(new[] { NewEventItem(1) }, CancellationToken.None);
|
||||
|
||||
var logContent = ReadLogs(tmp.Path);
|
||||
Assert.Contains("ingest NETWORK fail after", logContent);
|
||||
Assert.Contains("items=1, route=direct): ", logContent);
|
||||
Assert.Contains("ProxyObserver: uploads are failing since", logContent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Timeout_names_the_route_and_opens_a_series()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info);
|
||||
|
||||
var handler = new RecordingHttpMessageHandler();
|
||||
handler.QueueThrow(new TaskCanceledException("A task was canceled."));
|
||||
using var http = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(30) };
|
||||
|
||||
var sut = new BackendTelemetryUploader(
|
||||
httpClient: http, baseUrl: "https://backend.test", tenantId: "T1", logger: logger, proxy: NewProxyObserver(logger));
|
||||
|
||||
await sut.UploadBatchAsync(new[] { NewEventItem(1) }, CancellationToken.None);
|
||||
|
||||
var logContent = ReadLogs(tmp.Path);
|
||||
Assert.Contains("ingest TIMEOUT after", logContent);
|
||||
Assert.Contains("items=1, route=direct): ", logContent);
|
||||
Assert.Contains("ProxyObserver: uploads are failing since", logContent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Interception_names_the_route_and_opens_a_series()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info);
|
||||
|
||||
var handler = new RecordingHttpMessageHandler();
|
||||
handler.QueueForeign(HttpStatusCode.Forbidden, InterceptionFixtures.CertificateRequiredPage);
|
||||
using var http = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(30) };
|
||||
|
||||
var sut = new BackendTelemetryUploader(
|
||||
httpClient: http, baseUrl: "https://backend.test", tenantId: "T1", logger: logger, proxy: NewProxyObserver(logger));
|
||||
|
||||
var result = await sut.UploadBatchAsync(new[] { NewEventItem(1) }, CancellationToken.None);
|
||||
Assert.True(result.IsIntercepted);
|
||||
|
||||
var logContent = ReadLogs(tmp.Path);
|
||||
Assert.Contains(", route=direct).", logContent);
|
||||
Assert.Contains("ProxyObserver: uploads are failing since", logContent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_successful_upload_ends_the_series()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info);
|
||||
|
||||
var handler = new RecordingHttpMessageHandler();
|
||||
handler.QueueThrow(new HttpRequestException("An error occurred while sending the request."));
|
||||
handler.QueueStatus(HttpStatusCode.OK);
|
||||
using var http = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(30) };
|
||||
|
||||
var sut = new BackendTelemetryUploader(
|
||||
httpClient: http, baseUrl: "https://backend.test", tenantId: "T1", logger: logger, proxy: NewProxyObserver(logger));
|
||||
|
||||
await sut.UploadBatchAsync(new[] { NewEventItem(1) }, CancellationToken.None);
|
||||
await sut.UploadBatchAsync(new[] { NewEventItem(1) }, CancellationToken.None);
|
||||
|
||||
Assert.Contains("ProxyObserver: uploads work again after 1 failed attempt(s)", ReadLogs(tmp.Path));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_answer_from_the_backend_that_is_not_a_success_leaves_the_series_alone()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info);
|
||||
|
||||
var handler = new RecordingHttpMessageHandler();
|
||||
handler.QueueStatus(HttpStatusCode.InternalServerError);
|
||||
using var http = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(30) };
|
||||
|
||||
var sut = new BackendTelemetryUploader(
|
||||
httpClient: http, baseUrl: "https://backend.test", tenantId: "T1", logger: logger, proxy: NewProxyObserver(logger));
|
||||
|
||||
await sut.UploadBatchAsync(new[] { NewEventItem(1) }, CancellationToken.None);
|
||||
|
||||
Assert.DoesNotContain("ProxyObserver: uploads", ReadLogs(tmp.Path));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Without_an_observer_the_failure_line_stays_as_it_was()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info);
|
||||
|
||||
var handler = new RecordingHttpMessageHandler();
|
||||
handler.QueueThrow(new HttpRequestException("An error occurred while sending the request."));
|
||||
using var http = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(30) };
|
||||
|
||||
var sut = new BackendTelemetryUploader(
|
||||
httpClient: http, baseUrl: "https://backend.test", tenantId: "T1", logger: logger);
|
||||
|
||||
await sut.UploadBatchAsync(new[] { NewEventItem(1) }, CancellationToken.None);
|
||||
|
||||
var logContent = ReadLogs(tmp.Path);
|
||||
Assert.Contains("items=1): ", logContent);
|
||||
Assert.DoesNotContain("route=", logContent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+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()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Agent.V2.Core.Transport;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
|
||||
{
|
||||
/// <summary>Settings and route are played in: no test changes a real proxy setting.</summary>
|
||||
public sealed class ProxyObserverTests : IDisposable
|
||||
{
|
||||
private static readonly ProxySettings AutoDetect = new ProxySettings(true, null, null, null, null);
|
||||
private static readonly ProxySettings NoProxy = new ProxySettings(false, null, null, null, null);
|
||||
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
private readonly VirtualClock _clock = new VirtualClock(new DateTime(2026, 11, 2, 6, 29, 13, DateTimeKind.Utc));
|
||||
private ProxySettings _settings = AutoDetect;
|
||||
private string _route = "proxy.example.test:8080";
|
||||
|
||||
private ProxyObserver NewObserver(Func<string>? readRoute = null, TimeSpan? routeBound = null, Func<ProxySettings>? readSettings = null) =>
|
||||
new ProxyObserver(
|
||||
readSettings ?? (() => _settings),
|
||||
readRoute ?? (() => _route),
|
||||
new AgentLogger(_tmp.Path, AgentLogLevel.Info),
|
||||
_clock,
|
||||
routeBound ?? TimeSpan.FromSeconds(30));
|
||||
|
||||
private ProxyObserver StartedObserver()
|
||||
{
|
||||
var observer = NewObserver();
|
||||
observer.CaptureBaseline();
|
||||
return observer;
|
||||
}
|
||||
|
||||
private void SwitchToNoProxy()
|
||||
{
|
||||
_settings = NoProxy;
|
||||
_route = "direct";
|
||||
}
|
||||
|
||||
private List<string> LogLines()
|
||||
{
|
||||
var lines = new List<string>();
|
||||
foreach (var file in Directory.EnumerateFiles(_tmp.Path, "*.log", SearchOption.AllDirectories))
|
||||
{
|
||||
using var stream = new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
|
||||
using var reader = new StreamReader(stream);
|
||||
lines.AddRange(reader.ReadToEnd().Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries));
|
||||
}
|
||||
return lines.Where(l => l.Contains("ProxyObserver:")).ToList();
|
||||
}
|
||||
|
||||
public void Dispose() => _tmp.Dispose();
|
||||
|
||||
[Fact]
|
||||
public void The_baseline_names_settings_and_route_at_info()
|
||||
{
|
||||
StartedObserver();
|
||||
|
||||
var line = Assert.Single(LogLines());
|
||||
Assert.Contains("[INFO]", line);
|
||||
Assert.Contains("start - IE settings of this account: auto-detect; WinHTTP: direct; route to backend: proxy.example.test:8080", line);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_rebuild_of_the_clients_does_not_repeat_the_baseline()
|
||||
{
|
||||
var logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
|
||||
foreach (var _ in new[] { 1, 2 })
|
||||
{
|
||||
new ProxyObserver(() => _settings, () => _route, logger, _clock, TimeSpan.FromSeconds(30)).CaptureBaseline();
|
||||
}
|
||||
|
||||
Assert.Single(LogLines());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_change_warns_once_with_both_sides()
|
||||
{
|
||||
var observer = StartedObserver();
|
||||
SwitchToNoProxy();
|
||||
|
||||
observer.CheckForChange();
|
||||
observer.CheckForChange();
|
||||
|
||||
var line = Assert.Single(LogLines().Skip(1));
|
||||
Assert.Contains("[WARN]", line);
|
||||
Assert.Contains("proxy settings changed - IE settings of this account: auto-detect -> no proxy; route to backend: proxy.example.test:8080 -> direct.", line);
|
||||
Assert.Contains("bitsadmin /util /getieproxy localsystem", line);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rewriting_the_same_content_says_nothing()
|
||||
{
|
||||
var observer = StartedObserver();
|
||||
_settings = new ProxySettings(true, null, null, null, null);
|
||||
|
||||
observer.CheckForChange();
|
||||
|
||||
Assert.Single(LogLines());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_winhttp_change_says_uploads_do_not_use_it()
|
||||
{
|
||||
var observer = StartedObserver();
|
||||
_settings = new ProxySettings(true, null, null, null, "p:8080");
|
||||
|
||||
observer.CheckForChange();
|
||||
|
||||
var line = LogLines().Last();
|
||||
Assert.Contains("[WARN]", line);
|
||||
Assert.Contains("WinHTTP: direct -> proxy p:8080", line);
|
||||
Assert.Contains("do not use WinHTTP", line);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_winhttp_change_is_not_blamed_for_failing_uploads()
|
||||
{
|
||||
var observer = StartedObserver();
|
||||
_settings = new ProxySettings(true, null, null, null, "p:8080");
|
||||
observer.CheckForChange();
|
||||
|
||||
observer.OnSendFailed(TimeSpan.Zero);
|
||||
|
||||
Assert.Contains("Proxy settings of this account unchanged since start: auto-detect.", LogLines().Last());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_change_reaches_the_subscriber_with_settings_and_route()
|
||||
{
|
||||
var observer = StartedObserver();
|
||||
ProxySettings? seen = null;
|
||||
string? seenWhat = null;
|
||||
string? seenRoute = null;
|
||||
observer.SettingsChanged += (settings, what, route) =>
|
||||
{
|
||||
seen = settings;
|
||||
seenWhat = what;
|
||||
seenRoute = route;
|
||||
};
|
||||
SwitchToNoProxy();
|
||||
|
||||
observer.CheckForChange();
|
||||
|
||||
Assert.Equal(NoProxy, seen);
|
||||
Assert.Equal("IE settings of this account: auto-detect -> no proxy", seenWhat);
|
||||
Assert.Equal("direct", seenRoute);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_subscriber_that_throws_does_not_reach_the_caller()
|
||||
{
|
||||
var observer = StartedObserver();
|
||||
observer.SettingsChanged += (_, _, _) => throw new InvalidOperationException("SignalIngress has been stopped.");
|
||||
SwitchToNoProxy();
|
||||
|
||||
observer.CheckForChange();
|
||||
|
||||
Assert.Contains(LogLines(), l => l.Contains("proxy settings changed"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_first_failure_ties_the_series_to_the_change()
|
||||
{
|
||||
var observer = StartedObserver();
|
||||
_clock.Advance(TimeSpan.FromMinutes(23));
|
||||
observer.OnSendSucceeded();
|
||||
_clock.Advance(TimeSpan.FromSeconds(90));
|
||||
SwitchToNoProxy();
|
||||
observer.CheckForChange();
|
||||
_clock.Advance(TimeSpan.FromSeconds(62));
|
||||
|
||||
observer.OnSendFailed(TimeSpan.FromSeconds(21));
|
||||
|
||||
var line = LogLines().Last();
|
||||
Assert.Contains("[WARN]", line);
|
||||
Assert.Contains("uploads are failing since 06:54:24, 41 s after the proxy settings changed at 06:53:43 (IE settings of this account: auto-detect -> no proxy).", line);
|
||||
Assert.Contains("Route to backend: direct. Last successful upload 06:52:13.", line);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_failure_notices_a_change_the_watcher_did_not_report()
|
||||
{
|
||||
var observer = StartedObserver();
|
||||
SwitchToNoProxy();
|
||||
|
||||
observer.OnSendFailed(TimeSpan.Zero);
|
||||
|
||||
var lines = LogLines();
|
||||
Assert.Contains(lines, l => l.Contains("proxy settings changed - IE settings of this account: auto-detect -> no proxy"));
|
||||
Assert.Contains("after the proxy settings changed", lines.Last());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_series_writes_its_finding_once()
|
||||
{
|
||||
var observer = StartedObserver();
|
||||
|
||||
observer.OnSendFailed(TimeSpan.Zero);
|
||||
observer.OnSendFailed(TimeSpan.Zero);
|
||||
observer.OnSendFailed(TimeSpan.Zero);
|
||||
|
||||
Assert.Single(LogLines(), l => l.Contains("uploads are failing"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_failure_without_a_change_says_the_settings_are_unchanged()
|
||||
{
|
||||
var observer = StartedObserver();
|
||||
|
||||
observer.OnSendFailed(TimeSpan.Zero);
|
||||
|
||||
var line = LogLines().Last();
|
||||
Assert.Contains("Proxy settings of this account unchanged since start: auto-detect.", line);
|
||||
Assert.Contains("Route to backend: proxy.example.test:8080. No successful upload yet.", line);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_change_before_the_last_success_is_not_blamed()
|
||||
{
|
||||
var observer = StartedObserver();
|
||||
SwitchToNoProxy();
|
||||
observer.CheckForChange();
|
||||
_clock.Advance(TimeSpan.FromMinutes(1));
|
||||
observer.OnSendSucceeded();
|
||||
_clock.Advance(TimeSpan.FromMinutes(1));
|
||||
|
||||
observer.OnSendFailed(TimeSpan.Zero);
|
||||
|
||||
Assert.Contains("Proxy settings of this account unchanged since the last successful upload: no proxy.", LogLines().Last());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Recovery_is_noted_and_a_new_series_gets_its_own_finding()
|
||||
{
|
||||
var observer = StartedObserver();
|
||||
observer.OnSendFailed(TimeSpan.Zero);
|
||||
observer.OnSendFailed(TimeSpan.Zero);
|
||||
_clock.Advance(TimeSpan.FromMinutes(9));
|
||||
|
||||
observer.OnSendSucceeded();
|
||||
|
||||
var line = LogLines().Last();
|
||||
Assert.Contains("[INFO]", line);
|
||||
Assert.Contains("uploads work again after 2 failed attempt(s) over 9 min; route to backend: proxy.example.test:8080.", line);
|
||||
|
||||
observer.OnSendFailed(TimeSpan.Zero);
|
||||
Assert.Equal(2, LogLines().Count(l => l.Contains("uploads are failing")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Recovery_reports_settings_that_came_back()
|
||||
{
|
||||
var observer = StartedObserver();
|
||||
SwitchToNoProxy();
|
||||
observer.OnSendFailed(TimeSpan.Zero);
|
||||
_settings = AutoDetect;
|
||||
_route = "proxy.example.test:8080";
|
||||
|
||||
observer.OnSendSucceeded();
|
||||
|
||||
Assert.Contains(LogLines(), l => l.Contains("no proxy -> auto-detect"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_success_without_a_series_stays_silent()
|
||||
{
|
||||
var observer = StartedObserver();
|
||||
|
||||
observer.OnSendSucceeded();
|
||||
|
||||
Assert.Single(LogLines());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Stopping_while_failing_sums_it_up()
|
||||
{
|
||||
var observer = StartedObserver();
|
||||
observer.OnSendSucceeded();
|
||||
_clock.Advance(TimeSpan.FromMinutes(1));
|
||||
SwitchToNoProxy();
|
||||
observer.OnSendFailed(TimeSpan.Zero);
|
||||
observer.OnSendFailed(TimeSpan.Zero);
|
||||
|
||||
observer.LogOpenFailures();
|
||||
|
||||
var line = LogLines().Last();
|
||||
Assert.Contains("[WARN]", line);
|
||||
Assert.Contains("stopping with uploads still failing: 2 failed attempt(s) since 06:30:13, last success 06:29:13, route to backend: direct.", line);
|
||||
Assert.Contains("Proxy settings changed at 06:30:13 (IE settings of this account: auto-detect -> no proxy).", line);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Stopping_without_a_series_stays_silent()
|
||||
{
|
||||
var observer = StartedObserver();
|
||||
observer.OnSendFailed(TimeSpan.Zero);
|
||||
observer.OnSendSucceeded();
|
||||
var before = LogLines().Count;
|
||||
|
||||
observer.LogOpenFailures();
|
||||
|
||||
Assert.Equal(before, LogLines().Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_settings_read_that_throws_is_not_a_change_and_never_reaches_the_upload()
|
||||
{
|
||||
var throwing = false;
|
||||
var observer = NewObserver(readSettings: () => throwing ? throw new InvalidOperationException("boom") : _settings);
|
||||
observer.CaptureBaseline();
|
||||
throwing = true;
|
||||
|
||||
observer.CheckForChange();
|
||||
observer.OnSendFailed(TimeSpan.Zero);
|
||||
|
||||
Assert.DoesNotContain(LogLines(), l => l.Contains("proxy settings changed"));
|
||||
Assert.Contains("Proxy settings of this account unchanged since start: auto-detect.", LogLines().Last());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_unreadable_state_is_not_a_change()
|
||||
{
|
||||
var observer = StartedObserver();
|
||||
_settings = ProxySettings.Unreadable(5);
|
||||
|
||||
observer.CheckForChange();
|
||||
|
||||
Assert.Single(LogLines());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_route_read_that_throws_reads_as_unknown()
|
||||
{
|
||||
var observer = NewObserver(readRoute: () => throw new InvalidOperationException("boom"));
|
||||
|
||||
Assert.Equal("unknown", observer.RouteText());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_hanging_route_read_gives_up_and_does_not_pile_up()
|
||||
{
|
||||
using var release = new ManualResetEventSlim(false);
|
||||
var reads = 0;
|
||||
var observer = NewObserver(
|
||||
readRoute: () =>
|
||||
{
|
||||
Interlocked.Increment(ref reads);
|
||||
release.Wait();
|
||||
return "direct";
|
||||
},
|
||||
routeBound: TimeSpan.FromMilliseconds(50));
|
||||
|
||||
Assert.Equal("unknown", observer.RouteText());
|
||||
Assert.Equal("unknown", observer.RouteText());
|
||||
Assert.Equal(1, Volatile.Read(ref reads));
|
||||
|
||||
release.Set();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Transport;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
|
||||
{
|
||||
public sealed class ProxySettingsTests
|
||||
{
|
||||
private static ProxySettings Settings(
|
||||
bool autoDetect = false, string? pac = null, string? proxy = null, string? bypass = null, string? winHttp = null) =>
|
||||
new ProxySettings(autoDetect, pac, proxy, bypass, winHttp);
|
||||
|
||||
[Fact]
|
||||
public void Each_kind_of_setting_describes_itself()
|
||||
{
|
||||
Assert.Equal("no proxy", Settings().Describe());
|
||||
Assert.Equal("auto-detect", Settings(autoDetect: true).Describe());
|
||||
Assert.Equal("PAC https://pac.example.test/proxy.pac", Settings(pac: "https://pac.example.test/proxy.pac").Describe());
|
||||
Assert.Equal("proxy proxy.example.test:8080", Settings(proxy: "proxy.example.test:8080").Describe());
|
||||
Assert.Equal("auto-detect, proxy p:8080", Settings(autoDetect: true, proxy: "p:8080").Describe());
|
||||
Assert.Equal("direct", Settings().DescribeWinHttp());
|
||||
Assert.Equal("proxy p:8080", Settings(winHttp: "p:8080").DescribeWinHttp());
|
||||
Assert.Equal("unreadable (Win32 error 5)", ProxySettings.Unreadable(5).Describe());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false, null, null, "Direct")]
|
||||
[InlineData(true, null, null, "AutoDetect")]
|
||||
[InlineData(true, "https://pac.example.test/p.pac", null, "PAC")]
|
||||
[InlineData(true, "https://pac.example.test/p.pac", "p:8080", "Proxy")]
|
||||
public void Type_keeps_the_values_the_download_rule_matches(bool autoDetect, string? pac, string? proxy, string expected)
|
||||
{
|
||||
Assert.Equal(expected, Settings(autoDetect, pac, proxy).ProxyType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_unreadable_state_has_its_own_type()
|
||||
{
|
||||
Assert.Equal("Unknown", ProxySettings.Unreadable(5).ProxyType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Equal_content_is_equal_and_any_field_makes_it_different()
|
||||
{
|
||||
Assert.Equal(Settings(autoDetect: true), Settings(autoDetect: true));
|
||||
Assert.NotEqual(Settings(autoDetect: true), Settings());
|
||||
Assert.NotEqual(Settings(pac: "https://a.test/p"), Settings(pac: "https://b.test/p"));
|
||||
Assert.NotEqual(Settings(proxy: "a:1"), Settings(proxy: "b:1"));
|
||||
Assert.NotEqual(Settings(proxy: "a:1", bypass: "x"), Settings(proxy: "a:1", bypass: "y"));
|
||||
Assert.NotEqual(Settings(winHttp: "a:1"), Settings());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_empty_string_counts_as_not_set()
|
||||
{
|
||||
Assert.Equal(Settings(), Settings(pac: "", proxy: "", bypass: "", winHttp: ""));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("user:secret@p:8080")]
|
||||
[InlineData("http://user:secret@p:8080")]
|
||||
[InlineData("http=user:secret@p:8080;https=user:secret@q:8443")]
|
||||
[InlineData("user:se=cret@p:8080")]
|
||||
[InlineData("user:se;cret@p:8080")]
|
||||
[InlineData("http=user:se;cret@p:8080")]
|
||||
[InlineData("user:se://cret@p:8080")]
|
||||
public void Credentials_never_reach_logs_or_events(string configured)
|
||||
{
|
||||
var settings = Settings(proxy: configured, winHttp: configured);
|
||||
var data = settings.ToEventData();
|
||||
|
||||
foreach (var text in new[] { settings.DescribeAll(), (string)data["proxyServer"], (string)data["winHttpProxy"] })
|
||||
{
|
||||
Assert.DoesNotContain("user", text);
|
||||
Assert.DoesNotContain("cret", text);
|
||||
}
|
||||
|
||||
Assert.Equal(ProxySettings.CredentialsHidden, settings.Proxy);
|
||||
Assert.Equal(ProxySettings.CredentialsHidden, settings.WinHttpProxy);
|
||||
Assert.True((bool)data["proxyEnabled"]);
|
||||
Assert.True((bool)data["winHttpProxyConfigured"]);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("p:8080")]
|
||||
[InlineData("http://p:8080")]
|
||||
[InlineData("http=p:8080;https=q:8443")]
|
||||
public void A_value_without_credentials_stays_as_it_is(string configured)
|
||||
{
|
||||
Assert.Equal(configured, Settings(proxy: configured).Proxy);
|
||||
Assert.Equal(configured, Settings(winHttp: configured).WinHttpProxy);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("https://pac.example.test/p.pac?key=secret", "https://pac.example.test/p.pac")]
|
||||
[InlineData("http://user:secret@pac.example.test:8080/p.pac", "http://pac.example.test:8080/p.pac")]
|
||||
[InlineData("http://user:se=cr;et@pac.example.test/p.pac", "http://pac.example.test/p.pac")]
|
||||
[InlineData("not a url with secret", "(unparsable URL)")]
|
||||
public void A_pac_url_keeps_scheme_host_port_and_path(string configured, string expected)
|
||||
{
|
||||
Assert.Equal(expected, Settings(pac: configured).PacUrl);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_change_names_what_moved()
|
||||
{
|
||||
var before = Settings(autoDetect: true);
|
||||
|
||||
Assert.Equal("IE settings of this account: auto-detect -> no proxy", Settings().DescribeChangeFrom(before));
|
||||
Assert.Equal("WinHTTP: direct -> proxy p:1", Settings(autoDetect: true, winHttp: "p:1").DescribeChangeFrom(before));
|
||||
Assert.Equal(
|
||||
"IE settings of this account: auto-detect -> no proxy; WinHTTP: direct -> proxy p:1",
|
||||
Settings(winHttp: "p:1").DescribeChangeFrom(before));
|
||||
Assert.Equal(
|
||||
"IE settings of this account: proxy bypass list changed",
|
||||
Settings(proxy: "p:1", bypass: "b").DescribeChangeFrom(Settings(proxy: "p:1", bypass: "a")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Only_the_account_settings_count_as_a_change_for_uploads()
|
||||
{
|
||||
var before = Settings(autoDetect: true);
|
||||
|
||||
Assert.True(Settings().DiffersForUploadsFrom(before));
|
||||
Assert.False(Settings(autoDetect: true, winHttp: "p:1").DiffersForUploadsFrom(before));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_event_tells_auto_detect_from_direct()
|
||||
{
|
||||
var data = Settings(autoDetect: true).ToEventData();
|
||||
|
||||
Assert.Equal("AutoDetect", data["proxyType"]);
|
||||
Assert.True((bool)data["autoDetect"]);
|
||||
Assert.False((bool)data["proxyEnabled"]);
|
||||
Assert.Null(data["proxyServer"]);
|
||||
Assert.False((bool)data["winHttpProxyConfigured"]);
|
||||
Assert.Null(data["winHttpProxy"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_event_carries_a_real_winhttp_proxy()
|
||||
{
|
||||
var data = Settings(winHttp: "p:8080").ToEventData();
|
||||
|
||||
Assert.True((bool)data["winHttpProxyConfigured"]);
|
||||
Assert.Equal("p:8080", data["winHttpProxy"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_real_settings_of_this_account_can_be_read()
|
||||
{
|
||||
var settings = ProxySettingsReader.Read();
|
||||
|
||||
Assert.False(settings.IsUnreadable, settings.Describe());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_real_route_can_be_read()
|
||||
{
|
||||
var read = Task.Run(() => ProxySettingsReader.ReadRoute(new Uri("https://backend.example.invalid/")));
|
||||
|
||||
Assert.False(string.IsNullOrEmpty(await read));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
|
||||
{
|
||||
internal static class WinHttpNativeMethods
|
||||
{
|
||||
public const int ERROR_FILE_NOT_FOUND = 2;
|
||||
public const uint WINHTTP_ACCESS_TYPE_NAMED_PROXY = 3;
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct WINHTTP_CURRENT_USER_IE_PROXY_CONFIG
|
||||
{
|
||||
[MarshalAs(UnmanagedType.Bool)]
|
||||
public bool fAutoDetect;
|
||||
public IntPtr lpszAutoConfigUrl;
|
||||
public IntPtr lpszProxy;
|
||||
public IntPtr lpszProxyBypass;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct WINHTTP_PROXY_INFO
|
||||
{
|
||||
public uint dwAccessType;
|
||||
public IntPtr lpszProxy;
|
||||
public IntPtr lpszProxyBypass;
|
||||
}
|
||||
|
||||
[DllImport("winhttp.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
public static extern bool WinHttpGetIEProxyConfigForCurrentUser(ref WINHTTP_CURRENT_USER_IE_PROXY_CONFIG config);
|
||||
|
||||
[DllImport("winhttp.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
public static extern bool WinHttpGetDefaultProxyConfiguration(ref WINHTTP_PROXY_INFO info);
|
||||
|
||||
// WinHTTP allocates the strings of both structs with GlobalAlloc; the caller frees them.
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
public static extern IntPtr GlobalFree(IntPtr hMem);
|
||||
}
|
||||
}
|
||||
+4
-29
@@ -11,6 +11,7 @@ using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Security;
|
||||
using AutopilotMonitor.Agent.V2.Core.Transport;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using AutopilotMonitor.Shared;
|
||||
@@ -113,34 +114,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
|
||||
{
|
||||
try
|
||||
{
|
||||
var data = new Dictionary<string, object>();
|
||||
|
||||
using (var key = Registry.CurrentUser.OpenSubKey(@"Software\Microsoft\Windows\CurrentVersion\Internet Settings"))
|
||||
{
|
||||
if (key != null)
|
||||
{
|
||||
data["proxyEnabled"] = key.GetValue("ProxyEnable")?.ToString() == "1";
|
||||
data["proxyServer"] = key.GetValue("ProxyServer")?.ToString();
|
||||
data["proxyOverride"] = key.GetValue("ProxyOverride")?.ToString();
|
||||
data["autoConfigUrl"] = key.GetValue("AutoConfigURL")?.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
// Also check WinHTTP proxy
|
||||
using (var key = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Windows\CurrentVersion\Internet Settings\Connections"))
|
||||
{
|
||||
data["winHttpProxyConfigured"] = key?.GetValue("WinHttpSettings") != null;
|
||||
}
|
||||
|
||||
var proxyType = "Direct";
|
||||
if (data.ContainsKey("proxyEnabled") && (bool)data["proxyEnabled"])
|
||||
proxyType = "Proxy";
|
||||
else if (data.ContainsKey("autoConfigUrl") && data["autoConfigUrl"] != null)
|
||||
proxyType = "PAC";
|
||||
|
||||
data["proxyType"] = proxyType;
|
||||
|
||||
EmitDeviceInfoEvent(Constants.EventTypes.ProxyConfiguration, $"Proxy configuration: {proxyType}", data);
|
||||
var settings = ProxySettingsReader.Read();
|
||||
EmitDeviceInfoEvent(Constants.EventTypes.ProxyConfiguration, $"Proxy configuration: {settings.ProxyType}", settings.ToEventData());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -1004,7 +979,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);
|
||||
}
|
||||
|
||||
@@ -96,6 +96,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
/// </summary>
|
||||
private readonly DateTime? _previousRunLastAliveUtc;
|
||||
|
||||
private readonly Transport.ProxyObserver? _proxyObserver;
|
||||
|
||||
public DefaultComponentFactory(
|
||||
AgentConfiguration agentConfig,
|
||||
AgentConfigResponse remoteConfig,
|
||||
@@ -105,7 +107,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
Persistence.StartupEventGate? startupEventGate = null,
|
||||
DateTime? previousBootUtc = null,
|
||||
string? previousExitType = null,
|
||||
DateTime? previousRunLastAliveUtc = null)
|
||||
DateTime? previousRunLastAliveUtc = null,
|
||||
Transport.ProxyObserver? proxyObserver = null)
|
||||
{
|
||||
_agentConfig = agentConfig ?? throw new ArgumentNullException(nameof(agentConfig));
|
||||
_remoteConfig = remoteConfig ?? throw new ArgumentNullException(nameof(remoteConfig));
|
||||
@@ -116,6 +119,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
_previousBootUtc = previousBootUtc;
|
||||
_previousExitType = previousExitType;
|
||||
_previousRunLastAliveUtc = previousRunLastAliveUtc;
|
||||
_proxyObserver = proxyObserver;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -666,6 +670,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
clock: clock,
|
||||
logger: logger));
|
||||
|
||||
// The exact time of a proxy change for the agent log and the timeline. Always on: when
|
||||
// this matters, nothing else from the device reaches the backend.
|
||||
if (_proxyObserver != null)
|
||||
{
|
||||
hosts.Add(new ProxySettingsHost(
|
||||
observer: _proxyObserver,
|
||||
ingress: ingress,
|
||||
clock: clock,
|
||||
logger: logger));
|
||||
}
|
||||
|
||||
// Live AC/battery watcher — WMI Win32_PowerManagementEvent push (no polling), emits
|
||||
// power_state_change on AC↔battery transitions and 50/30/15 % downward threshold
|
||||
// crossings. Deliberately NOT under PeriodicCollectorLifecycleHost: it is push-based
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Transport;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
{
|
||||
/// <summary>
|
||||
/// Tells <see cref="ProxyObserver"/> the moment proxy settings change, so the log has the exact
|
||||
/// time. A watcher that fails costs only that time: the observer compares again on every failed
|
||||
/// upload.
|
||||
/// </summary>
|
||||
internal sealed class ProxySettingsHost : ICollectorHost
|
||||
{
|
||||
private const string Source = "ProxySettingsHost";
|
||||
private const int DebounceMs = 1000;
|
||||
|
||||
// The whole subtree: "Connections" does not exist yet in an account nothing has written to.
|
||||
// HKCU of a SYSTEM process is HKU\S-1-5-18.
|
||||
private const string AccountSettingsKey = @"Software\Microsoft\Windows\CurrentVersion\Internet Settings";
|
||||
// Holds WinHttpSettings, which netsh winhttp writes.
|
||||
private const string MachineConnectionsKey = @"SOFTWARE\Microsoft\Windows\CurrentVersion\Internet Settings\Connections";
|
||||
|
||||
private const RegistryNativeMethods.RegChangeNotifyFilter Filter =
|
||||
RegistryNativeMethods.RegChangeNotifyFilter.Name |
|
||||
RegistryNativeMethods.RegChangeNotifyFilter.LastSet;
|
||||
|
||||
private readonly ProxyObserver _observer;
|
||||
private readonly InformationalEventPost _post;
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly List<RegistryWatcher> _watchers = new List<RegistryWatcher>();
|
||||
private readonly Timer _debounce;
|
||||
private int _started;
|
||||
private int _stopped;
|
||||
|
||||
public string Name => "ProxySettingsWatcher";
|
||||
|
||||
public ProxySettingsHost(
|
||||
ProxyObserver observer,
|
||||
ISignalIngressSink ingress,
|
||||
IClock clock,
|
||||
AgentLogger logger)
|
||||
{
|
||||
if (ingress == null) throw new ArgumentNullException(nameof(ingress));
|
||||
if (clock == null) throw new ArgumentNullException(nameof(clock));
|
||||
_observer = observer ?? throw new ArgumentNullException(nameof(observer));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_post = new InformationalEventPost(ingress, clock);
|
||||
_debounce = new Timer(_ => OnDebounceElapsed(), null, Timeout.Infinite, Timeout.Infinite);
|
||||
|
||||
AddWatcher(RegistryHive.CurrentUser, AccountSettingsKey, watchSubtree: true, "account settings");
|
||||
AddWatcher(RegistryHive.LocalMachine, MachineConnectionsKey, watchSubtree: false, "WinHTTP settings");
|
||||
}
|
||||
|
||||
private void AddWatcher(RegistryHive hive, string subKey, bool watchSubtree, string label)
|
||||
{
|
||||
var watcher = new RegistryWatcher(hive, subKey, watchSubtree, RegistryView.Registry64, Filter);
|
||||
watcher.Changed += (_, _) => _debounce.Change(DebounceMs, Timeout.Infinite);
|
||||
watcher.Error += (_, ex) => _logger.Warning($"ProxySettingsHost: {label} watcher error - {ex.GetType().Name}: {ex.Message}");
|
||||
_watchers.Add(watcher);
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _started, 1) == 1) return;
|
||||
|
||||
_observer.SettingsChanged += OnSettingsChanged;
|
||||
foreach (var watcher in _watchers)
|
||||
{
|
||||
try { watcher.Start(); }
|
||||
catch (Exception ex) { _logger.Warning($"ProxySettingsHost: a watcher failed to start: {ex.Message}"); }
|
||||
}
|
||||
|
||||
// Whatever moved between the baseline and the start of the watchers.
|
||||
OnDebounceElapsed();
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _stopped, 1) == 1) return;
|
||||
|
||||
DisposeWatchers();
|
||||
_observer.SettingsChanged -= OnSettingsChanged;
|
||||
if (Volatile.Read(ref _started) == 1) _observer.LogOpenFailures();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Stop();
|
||||
_debounce.Dispose();
|
||||
}
|
||||
|
||||
private void DisposeWatchers()
|
||||
{
|
||||
foreach (var watcher in _watchers)
|
||||
{
|
||||
try { watcher.Dispose(); }
|
||||
catch (Exception ex) { _logger.Debug($"ProxySettingsHost: disposing a watcher threw: {ex.Message}"); }
|
||||
}
|
||||
_watchers.Clear();
|
||||
}
|
||||
|
||||
private void OnDebounceElapsed()
|
||||
{
|
||||
_logger.Trace("ProxySettingsHost: checking the proxy settings.");
|
||||
try { _observer.CheckForChange(); }
|
||||
catch (Exception ex) { _logger.Warning($"ProxySettingsHost: checking the proxy settings failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Waits in the spool if uploads are down, so the timeline explains the gap once they work
|
||||
/// again. The portal shows the newest event of this type.
|
||||
/// </summary>
|
||||
private void OnSettingsChanged(ProxySettings settings, string what, string route)
|
||||
{
|
||||
var data = settings.ToEventData();
|
||||
data["trigger"] = "changed";
|
||||
data["change"] = what;
|
||||
data["route"] = route;
|
||||
|
||||
_post.Emit(
|
||||
eventType: Constants.EventTypes.ProxyConfiguration,
|
||||
source: Source,
|
||||
message: $"Proxy settings changed: {what}",
|
||||
severity: EventSeverity.Warning,
|
||||
typedPayload: data);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Transport
|
||||
{
|
||||
/// <summary>
|
||||
/// Makes the agent log say which way uploads go and what happened to it. Something on the device
|
||||
/// can change the proxy settings of the agent's account; .NET follows at once, and from then on
|
||||
/// no channel may reach the backend, so the log on the device is the only witness.
|
||||
/// <para>
|
||||
/// Compares with what held at the start instead of judging a value: "no proxy" is the right
|
||||
/// state on one network and the broken one on the next. Reads on failures and on registry
|
||||
/// changes, never by polling. Nothing here throws into the upload path.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class ProxyObserver
|
||||
{
|
||||
internal const string RouteUnknown = "unknown";
|
||||
|
||||
private static readonly TimeSpan DefaultRouteBound = TimeSpan.FromSeconds(3);
|
||||
|
||||
private readonly Func<ProxySettings> _readSettings;
|
||||
private readonly Func<string> _readRoute;
|
||||
private readonly IClock _clock;
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly TimeSpan _routeBound;
|
||||
private readonly object _sync = new object();
|
||||
|
||||
private ProxySettings? _current;
|
||||
private string _lastRoute = RouteUnknown;
|
||||
private Task<string>? _routeRead;
|
||||
private DateTime? _uploadChangeAtUtc;
|
||||
private string? _uploadChange;
|
||||
private DateTime? _failingSinceUtc;
|
||||
private int _failedAttempts;
|
||||
private DateTime? _lastSuccessUtc;
|
||||
|
||||
/// <summary>Settings now, what changed, route now. Raised outside the lock, on the caller's thread.</summary>
|
||||
internal event Action<ProxySettings, string, string>? SettingsChanged;
|
||||
|
||||
public ProxyObserver(string baseUrl, AgentLogger logger, IClock clock)
|
||||
: this(
|
||||
ProxySettingsReader.Read,
|
||||
RouteReaderFor(baseUrl),
|
||||
logger,
|
||||
clock,
|
||||
DefaultRouteBound)
|
||||
{
|
||||
}
|
||||
|
||||
internal ProxyObserver(
|
||||
Func<ProxySettings> readSettings,
|
||||
Func<string> readRoute,
|
||||
AgentLogger logger,
|
||||
IClock clock,
|
||||
TimeSpan routeBound)
|
||||
{
|
||||
_readSettings = readSettings ?? throw new ArgumentNullException(nameof(readSettings));
|
||||
_readRoute = readRoute ?? throw new ArgumentNullException(nameof(readRoute));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
|
||||
_routeBound = routeBound;
|
||||
}
|
||||
|
||||
private static Func<string> RouteReaderFor(string baseUrl)
|
||||
{
|
||||
if (!Uri.TryCreate(baseUrl, UriKind.Absolute, out var target)) return () => RouteUnknown;
|
||||
return () => ProxySettingsReader.ReadRoute(target);
|
||||
}
|
||||
|
||||
/// <summary>Logs the state to compare against. Runs before the first request of the agent.</summary>
|
||||
public void CaptureBaseline()
|
||||
{
|
||||
var settings = ReadSettingsSafe();
|
||||
var route = ReadRouteBounded();
|
||||
lock (_sync)
|
||||
{
|
||||
if (_current == null && !settings.IsUnreadable) _current = settings;
|
||||
_lastRoute = route;
|
||||
}
|
||||
// Same text on a rebuild of the clients after an endpoint migration: the repeat goes to Trace.
|
||||
_logger.Info($"ProxyObserver: start - {settings.DescribeAll()}; route to backend: {route}", repeatKey: "ProxyObserver.baseline");
|
||||
}
|
||||
|
||||
/// <summary>Re-reads the settings and reports a change of content once, whoever notices it first.</summary>
|
||||
public void CheckForChange()
|
||||
{
|
||||
var settings = ReadSettingsSafe();
|
||||
if (settings.IsUnreadable) return;
|
||||
|
||||
ProxySettings previous;
|
||||
var affectsUploads = false;
|
||||
lock (_sync)
|
||||
{
|
||||
if (_current == null)
|
||||
{
|
||||
_current = settings;
|
||||
return;
|
||||
}
|
||||
if (_current.Equals(settings)) return;
|
||||
|
||||
previous = _current;
|
||||
_current = settings;
|
||||
if (settings.DiffersForUploadsFrom(previous))
|
||||
{
|
||||
affectsUploads = true;
|
||||
_uploadChangeAtUtc = _clock.UtcNow;
|
||||
_uploadChange = settings.DescribeUploadChangeFrom(previous);
|
||||
}
|
||||
}
|
||||
|
||||
var route = ReadRouteBounded();
|
||||
string routeBefore;
|
||||
lock (_sync)
|
||||
{
|
||||
routeBefore = _lastRoute;
|
||||
_lastRoute = route;
|
||||
}
|
||||
|
||||
var what = settings.DescribeChangeFrom(previous);
|
||||
_logger.Warning(affectsUploads
|
||||
? $"ProxyObserver: proxy settings changed - {what}; route to backend: {(routeBefore == route ? route : $"{routeBefore} -> {route}")}. " +
|
||||
"The agent's uploads follow these settings. Check with: bitsadmin /util /getieproxy localsystem"
|
||||
: $"ProxyObserver: proxy settings changed - {what}. The agent's uploads do not use WinHTTP, services that rely on its default do.");
|
||||
|
||||
try
|
||||
{
|
||||
SettingsChanged?.Invoke(settings, what, route);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Debug($"ProxyObserver: change handler threw: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The next hop to the backend: <c>direct</c>, <c>host:port</c> or <c>unknown</c>.</summary>
|
||||
public string RouteText() => ReadRouteBounded();
|
||||
|
||||
/// <summary>
|
||||
/// An upload did not reach the backend. The first of a series says what the reader needs to
|
||||
/// know: when it began and whether the proxy settings moved before that.
|
||||
/// </summary>
|
||||
public void OnSendFailed(TimeSpan attemptDuration)
|
||||
{
|
||||
// Also catches a change the registry watcher did not report.
|
||||
CheckForChange();
|
||||
|
||||
DateTime since;
|
||||
DateTime? lastSuccess;
|
||||
DateTime? changedAt;
|
||||
string? change;
|
||||
string? current;
|
||||
lock (_sync)
|
||||
{
|
||||
_failedAttempts++;
|
||||
if (_failingSinceUtc != null) return;
|
||||
|
||||
since = _clock.UtcNow - attemptDuration;
|
||||
_failingSinceUtc = since;
|
||||
lastSuccess = _lastSuccessUtc;
|
||||
changedAt = _uploadChangeAtUtc;
|
||||
change = _uploadChange;
|
||||
current = _current?.Describe();
|
||||
}
|
||||
|
||||
var route = ReadRouteBounded();
|
||||
var lastUpload = lastSuccess == null ? "No successful upload yet." : $"Last successful upload {Time(lastSuccess.Value)}.";
|
||||
|
||||
if (changedAt != null && (lastSuccess == null || changedAt > lastSuccess))
|
||||
{
|
||||
_logger.Warning(
|
||||
$"ProxyObserver: uploads are failing since {Time(since)}, {Span(since - changedAt.Value)} after the proxy settings changed at {Time(changedAt.Value)} ({change}). " +
|
||||
$"Route to backend: {route}. {lastUpload}");
|
||||
}
|
||||
else
|
||||
{
|
||||
var unchangedSince = changedAt == null ? "start" : "the last successful upload";
|
||||
_logger.Warning(
|
||||
$"ProxyObserver: uploads are failing since {Time(since)}. Proxy settings of this account unchanged since {unchangedSince}: {current ?? RouteUnknown}. " +
|
||||
$"Route to backend: {route}. {lastUpload}");
|
||||
}
|
||||
}
|
||||
|
||||
public void OnSendSucceeded()
|
||||
{
|
||||
DateTime since;
|
||||
int attempts;
|
||||
lock (_sync)
|
||||
{
|
||||
_lastSuccessUtc = _clock.UtcNow;
|
||||
if (_failingSinceUtc == null) return;
|
||||
|
||||
since = _failingSinceUtc.Value;
|
||||
attempts = _failedAttempts;
|
||||
_failingSinceUtc = null;
|
||||
_failedAttempts = 0;
|
||||
}
|
||||
|
||||
CheckForChange();
|
||||
_logger.Info($"ProxyObserver: uploads work again after {attempts} failed attempt(s) over {Span(_clock.UtcNow - since)}; route to backend: {ReadRouteBounded()}.");
|
||||
}
|
||||
|
||||
/// <summary>The end of the log is read first, so a run that ends while failing says so there.</summary>
|
||||
public void LogOpenFailures()
|
||||
{
|
||||
DateTime since;
|
||||
int attempts;
|
||||
DateTime? lastSuccess;
|
||||
DateTime? changedAt;
|
||||
string? change;
|
||||
string? current;
|
||||
lock (_sync)
|
||||
{
|
||||
if (_failingSinceUtc == null) return;
|
||||
|
||||
since = _failingSinceUtc.Value;
|
||||
attempts = _failedAttempts;
|
||||
lastSuccess = _lastSuccessUtc;
|
||||
changedAt = _uploadChangeAtUtc;
|
||||
change = _uploadChange;
|
||||
current = _current?.Describe();
|
||||
}
|
||||
|
||||
var lastUpload = lastSuccess == null ? "no successful upload" : $"last success {Time(lastSuccess.Value)}";
|
||||
var settings = changedAt == null
|
||||
? $"Proxy settings of this account unchanged since start: {current ?? RouteUnknown}."
|
||||
: $"Proxy settings changed at {Time(changedAt.Value)} ({change}).";
|
||||
_logger.Warning(
|
||||
$"ProxyObserver: stopping with uploads still failing: {attempts} failed attempt(s) since {Time(since)}, {lastUpload}, route to backend: {ReadRouteBounded()}. {settings}");
|
||||
}
|
||||
|
||||
private ProxySettings ReadSettingsSafe()
|
||||
{
|
||||
try
|
||||
{
|
||||
var settings = _readSettings();
|
||||
if (settings.IsUnreadable) _logger.Debug($"ProxyObserver: proxy settings {settings.Describe()}", repeatKey: "ProxyObserver.unreadable");
|
||||
return settings;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Debug($"ProxyObserver: reading the proxy settings threw: {ex.Message}", repeatKey: "ProxyObserver.readThrew");
|
||||
return ProxySettings.Unreadable(-1);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Auto-detect can hang and must hold up neither the start nor an upload. A read still under
|
||||
/// way is joined, so a hanging one does not pile up pool threads.
|
||||
/// </summary>
|
||||
private string ReadRouteBounded()
|
||||
{
|
||||
Task<string> read;
|
||||
lock (_sync)
|
||||
{
|
||||
if (_routeRead == null || _routeRead.IsCompleted) _routeRead = Task.Run(ReadRouteSafe);
|
||||
read = _routeRead;
|
||||
}
|
||||
return read.Wait(_routeBound) ? read.Result : RouteUnknown;
|
||||
}
|
||||
|
||||
private string ReadRouteSafe()
|
||||
{
|
||||
try
|
||||
{
|
||||
return _readRoute();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Debug($"ProxyObserver: reading the route threw: {ex.Message}", repeatKey: "ProxyObserver.routeThrew");
|
||||
return RouteUnknown;
|
||||
}
|
||||
}
|
||||
|
||||
private static string Time(DateTime utc) => utc.ToString("HH:mm:ss", CultureInfo.InvariantCulture);
|
||||
|
||||
private static string Span(TimeSpan span)
|
||||
{
|
||||
var seconds = Math.Max(0, (long)span.TotalSeconds);
|
||||
return seconds < 120 ? $"{seconds} s" : $"{seconds / 60} min";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Transport
|
||||
{
|
||||
/// <summary>
|
||||
/// The proxy settings Windows holds for the account this process runs as (what
|
||||
/// <c>bitsadmin /util /getieproxy localsystem</c> shows for SYSTEM and what .NET uses), plus the
|
||||
/// WinHTTP proxy (<c>netsh winhttp show proxy</c>). Compared by content, because WinINET rewrites
|
||||
/// its settings blob without changing anything. The values end up in logs and events, so
|
||||
/// credentials and PAC query strings are kept out on the way in.
|
||||
/// </summary>
|
||||
internal sealed class ProxySettings : IEquatable<ProxySettings>
|
||||
{
|
||||
internal const string CredentialsHidden = "(value with credentials, not shown)";
|
||||
|
||||
public bool AutoDetect { get; }
|
||||
public string PacUrl { get; }
|
||||
public string Proxy { get; }
|
||||
public string Bypass { get; }
|
||||
public string WinHttpProxy { get; }
|
||||
|
||||
/// <summary>Win32 error of a failed read; null when the settings were read.</summary>
|
||||
public int? ReadError { get; }
|
||||
|
||||
public bool IsUnreadable => ReadError != null;
|
||||
|
||||
public ProxySettings(bool autoDetect, string pacUrl, string proxy, string bypass, string winHttpProxy)
|
||||
{
|
||||
AutoDetect = autoDetect;
|
||||
PacUrl = SanitizeUrl(pacUrl);
|
||||
Proxy = HideCredentials(proxy);
|
||||
Bypass = EmptyToNull(bypass);
|
||||
WinHttpProxy = HideCredentials(winHttpProxy);
|
||||
}
|
||||
|
||||
private ProxySettings(int readError)
|
||||
{
|
||||
ReadError = readError;
|
||||
}
|
||||
|
||||
public static ProxySettings Unreadable(int win32Error) => new ProxySettings(win32Error);
|
||||
|
||||
/// <summary>Kept in step with ANALYZE-CORR-003, which matches <c>Proxy|PAC</c>.</summary>
|
||||
public string ProxyType =>
|
||||
IsUnreadable ? "Unknown"
|
||||
: Proxy != null ? "Proxy"
|
||||
: PacUrl != null ? "PAC"
|
||||
: AutoDetect ? "AutoDetect"
|
||||
: "Direct";
|
||||
|
||||
public string Describe()
|
||||
{
|
||||
if (IsUnreadable) return $"unreadable (Win32 error {ReadError})";
|
||||
|
||||
var parts = new List<string>(3);
|
||||
if (AutoDetect) parts.Add("auto-detect");
|
||||
if (PacUrl != null) parts.Add($"PAC {PacUrl}");
|
||||
if (Proxy != null) parts.Add($"proxy {Proxy}");
|
||||
return parts.Count == 0 ? "no proxy" : string.Join(", ", parts);
|
||||
}
|
||||
|
||||
public string DescribeWinHttp() => WinHttpProxy == null ? "direct" : $"proxy {WinHttpProxy}";
|
||||
|
||||
public string DescribeAll() => $"IE settings of this account: {Describe()}; WinHTTP: {DescribeWinHttp()}";
|
||||
|
||||
/// <summary>The account's own settings, which the agent's uploads follow; WinHTTP is apart.</summary>
|
||||
public bool DiffersForUploadsFrom(ProxySettings other) =>
|
||||
AutoDetect != other.AutoDetect
|
||||
|| PacUrl != other.PacUrl
|
||||
|| Proxy != other.Proxy
|
||||
|| Bypass != other.Bypass;
|
||||
|
||||
public string DescribeUploadChangeFrom(ProxySettings previous) =>
|
||||
Describe() == previous.Describe()
|
||||
? "IE settings of this account: proxy bypass list changed"
|
||||
: $"IE settings of this account: {previous.Describe()} -> {Describe()}";
|
||||
|
||||
public string DescribeChangeFrom(ProxySettings previous)
|
||||
{
|
||||
var parts = new List<string>(2);
|
||||
if (DiffersForUploadsFrom(previous)) parts.Add(DescribeUploadChangeFrom(previous));
|
||||
if (WinHttpProxy != previous.WinHttpProxy) parts.Add($"WinHTTP: {previous.DescribeWinHttp()} -> {DescribeWinHttp()}");
|
||||
return string.Join("; ", parts);
|
||||
}
|
||||
|
||||
/// <summary>Payload of the <c>proxy_configuration</c> event.</summary>
|
||||
public Dictionary<string, object> ToEventData() => new Dictionary<string, object>
|
||||
{
|
||||
{ "proxyType", ProxyType },
|
||||
{ "autoDetect", AutoDetect },
|
||||
{ "proxyEnabled", Proxy != null },
|
||||
{ "proxyServer", Proxy },
|
||||
{ "proxyOverride", Bypass },
|
||||
{ "autoConfigUrl", PacUrl },
|
||||
{ "winHttpProxyConfigured", WinHttpProxy != null },
|
||||
{ "winHttpProxy", WinHttpProxy },
|
||||
};
|
||||
|
||||
public bool Equals(ProxySettings other) =>
|
||||
other != null
|
||||
&& ReadError == other.ReadError
|
||||
&& !DiffersForUploadsFrom(other)
|
||||
&& WinHttpProxy == other.WinHttpProxy;
|
||||
|
||||
public override bool Equals(object obj) => Equals(obj as ProxySettings);
|
||||
|
||||
public override int GetHashCode() => (AutoDetect, PacUrl, Proxy, Bypass, WinHttpProxy, ReadError).GetHashCode();
|
||||
|
||||
private static string EmptyToNull(string value) => string.IsNullOrEmpty(value) ? null : value;
|
||||
|
||||
/// <summary>Scheme, host, port and path. A query string can carry a key; an unparsable URL is not repeated.</summary>
|
||||
private static string SanitizeUrl(string url)
|
||||
{
|
||||
if (string.IsNullOrEmpty(url)) return null;
|
||||
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) || string.IsNullOrEmpty(uri.Host)) return "(unparsable URL)";
|
||||
var port = uri.IsDefaultPort ? string.Empty : $":{uri.Port}";
|
||||
return $"{uri.Scheme}://{uri.Host}{port}{uri.AbsolutePath}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A proxy string has no defined form for credentials and a password can hold <c>=</c>,
|
||||
/// <c>;</c> or <c>://</c>, so a value with an <c>@</c> is replaced as a whole, not taken apart.
|
||||
/// </summary>
|
||||
private static string HideCredentials(string proxy)
|
||||
{
|
||||
if (string.IsNullOrEmpty(proxy)) return null;
|
||||
return proxy.IndexOf('@') < 0 ? proxy : CredentialsHidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Runtime.InteropServices;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Transport
|
||||
{
|
||||
internal static class ProxySettingsReader
|
||||
{
|
||||
public const string RouteDirect = "direct";
|
||||
|
||||
/// <summary>Reads the registry through WinHTTP, no network. Does not throw on a failed read.</summary>
|
||||
public static ProxySettings Read()
|
||||
{
|
||||
var ie = new WinHttpNativeMethods.WINHTTP_CURRENT_USER_IE_PROXY_CONFIG();
|
||||
var winHttp = new WinHttpNativeMethods.WINHTTP_PROXY_INFO();
|
||||
try
|
||||
{
|
||||
if (!WinHttpNativeMethods.WinHttpGetIEProxyConfigForCurrentUser(ref ie))
|
||||
{
|
||||
var error = Marshal.GetLastWin32Error();
|
||||
// The account has never had settings written, so nothing configures a proxy.
|
||||
if (error != WinHttpNativeMethods.ERROR_FILE_NOT_FOUND) return ProxySettings.Unreadable(error);
|
||||
}
|
||||
|
||||
if (!WinHttpNativeMethods.WinHttpGetDefaultProxyConfiguration(ref winHttp))
|
||||
{
|
||||
return ProxySettings.Unreadable(Marshal.GetLastWin32Error());
|
||||
}
|
||||
|
||||
return new ProxySettings(
|
||||
autoDetect: ie.fAutoDetect,
|
||||
pacUrl: Marshal.PtrToStringUni(ie.lpszAutoConfigUrl),
|
||||
proxy: Marshal.PtrToStringUni(ie.lpszProxy),
|
||||
bypass: Marshal.PtrToStringUni(ie.lpszProxyBypass),
|
||||
winHttpProxy: winHttp.dwAccessType == WinHttpNativeMethods.WINHTTP_ACCESS_TYPE_NAMED_PROXY
|
||||
? Marshal.PtrToStringUni(winHttp.lpszProxy)
|
||||
: null);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Free(ie.lpszAutoConfigUrl);
|
||||
Free(ie.lpszProxy);
|
||||
Free(ie.lpszProxyBypass);
|
||||
Free(winHttp.lpszProxy);
|
||||
Free(winHttp.lpszProxyBypass);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The next hop .NET chooses for <paramref name="target"/>: the same decision an
|
||||
/// <c>HttpClientHandler</c> with default settings takes, PAC and auto-detect evaluated.
|
||||
/// Auto-detect can take seconds on a network without WPAD.
|
||||
/// </summary>
|
||||
public static string ReadRoute(Uri target)
|
||||
{
|
||||
var hop = WebRequest.DefaultWebProxy?.GetProxy(target);
|
||||
if (hop == null || string.Equals(hop.Host, target.Host, StringComparison.OrdinalIgnoreCase)) return RouteDirect;
|
||||
return hop.IsDefaultPort ? hop.Host : $"{hop.Host}:{hop.Port}";
|
||||
}
|
||||
|
||||
private static void Free(IntPtr memory)
|
||||
{
|
||||
if (memory != IntPtr.Zero) WinHttpNativeMethods.GlobalFree(memory);
|
||||
}
|
||||
}
|
||||
}
|
||||
+18
-5
@@ -52,6 +52,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
|
||||
private readonly Logging.AgentLogger? _logger;
|
||||
private readonly ServerCertificateObserver? _serverCertificates;
|
||||
private readonly NetworkMetrics? _networkMetrics;
|
||||
private readonly ProxyObserver? _proxy;
|
||||
|
||||
/// <param name="serverCertificates">
|
||||
/// Observer installed on the <paramref name="httpClient"/>'s handler; its issuer becomes
|
||||
@@ -60,6 +61,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
|
||||
/// <param name="networkMetrics">
|
||||
/// Counts 2xx answers without the correlation echo (<c>agent_metrics_snapshot</c>). Null = not counted.
|
||||
/// </param>
|
||||
/// <param name="proxy">
|
||||
/// Names the route in the failure lines and hears how each upload ended. Null = neither.
|
||||
/// </param>
|
||||
public BackendTelemetryUploader(
|
||||
HttpClient httpClient,
|
||||
string baseUrl,
|
||||
@@ -72,7 +76,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
|
||||
AuthFailureTracker? authFailureTracker = null,
|
||||
Logging.AgentLogger? logger = null,
|
||||
ServerCertificateObserver? serverCertificates = null,
|
||||
NetworkMetrics? networkMetrics = null)
|
||||
NetworkMetrics? networkMetrics = null,
|
||||
ProxyObserver? proxy = null)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
if (string.IsNullOrEmpty(baseUrl)) throw new ArgumentException("BaseUrl is mandatory.", nameof(baseUrl));
|
||||
@@ -89,6 +94,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
|
||||
_logger = logger;
|
||||
_serverCertificates = serverCertificates;
|
||||
_networkMetrics = networkMetrics;
|
||||
_proxy = proxy;
|
||||
}
|
||||
|
||||
public async Task<UploadResult> UploadBatchAsync(
|
||||
@@ -153,7 +159,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
|
||||
catch (TaskCanceledException ex)
|
||||
{
|
||||
sw.Stop();
|
||||
_logger?.Warning($"BackendTelemetryUploader: ingest TIMEOUT after {sw.ElapsedMilliseconds}ms (corr={correlationId}, items={items.Count}): {ex.Message}");
|
||||
_logger?.Warning($"BackendTelemetryUploader: ingest TIMEOUT after {sw.ElapsedMilliseconds}ms (corr={correlationId}, items={items.Count}{RouteSuffix()}): {ex.Message}");
|
||||
_proxy?.OnSendFailed(sw.Elapsed);
|
||||
// HttpClient timeout (not caller cancellation) surfaces as TaskCanceledException
|
||||
// with cancellationToken.IsCancellationRequested == false.
|
||||
return UploadResult.Transient($"timeout: {ex.Message}");
|
||||
@@ -161,9 +168,11 @@ 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}{RouteSuffix()}): {cause}");
|
||||
_proxy?.OnSendFailed(sw.Elapsed);
|
||||
// DNS failure, socket error, etc. — network-layer is retryable.
|
||||
return UploadResult.Transient($"network: {ex.Message}");
|
||||
return UploadResult.Transient($"network: {cause}");
|
||||
}
|
||||
|
||||
using (response)
|
||||
@@ -174,6 +183,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
|
||||
if (result.Success)
|
||||
{
|
||||
_logger?.Verbose($"BackendTelemetryUploader: ingest OK (corr={correlationId}, items={items.Count}, firstId={items[0].TelemetryItemId}, lastId={items[items.Count - 1].TelemetryItemId}, bytes={compressedBody.Length}, durationMs={sw.ElapsedMilliseconds}, status={(int)response.StatusCode}).");
|
||||
_proxy?.OnSendSucceeded();
|
||||
}
|
||||
else if (result.IsTransient)
|
||||
{
|
||||
@@ -188,7 +198,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
|
||||
$"BackendTelemetryUploader: ingest INTERCEPTED — the answer did not come from the backend " +
|
||||
$"(corr={correlationId}, items={items.Count}, durationMs={sw.ElapsedMilliseconds}, status={interception.StatusCode}, " +
|
||||
$"reason={interception.ForeignReason}, pageTitle=\"{interception.PageTitle ?? "(none)"}\", " +
|
||||
$"serverCertIssuer=\"{interception.ServerCertIssuer ?? "(unknown)"}\").");
|
||||
$"serverCertIssuer=\"{interception.ServerCertIssuer ?? "(unknown)"}\"{RouteSuffix()}).");
|
||||
_proxy?.OnSendFailed(sw.Elapsed);
|
||||
}
|
||||
else if (result.IsSessionGone)
|
||||
{
|
||||
@@ -204,6 +215,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
|
||||
}
|
||||
}
|
||||
|
||||
private string RouteSuffix() => _proxy == null ? string.Empty : $", route={_proxy.RouteText()}";
|
||||
|
||||
private async Task<UploadResult> MapResponseAsync(HttpResponseMessage response, string correlationId)
|
||||
{
|
||||
var statusCode = (int)response.StatusCode;
|
||||
|
||||
@@ -99,7 +99,8 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
// Measured, not guessed: the reducer snapshot is rewritten on every decision step,
|
||||
// so its mtime is the last moment the previous run is known to have been alive —
|
||||
// and unlike LastBootUtc it exists for every exit type, exception_crash included.
|
||||
previousRunLastAliveUtc: DefaultComponentFactory.ReadPreviousRunLastAliveUtc(stateSubdir));
|
||||
previousRunLastAliveUtc: DefaultComponentFactory.ReadPreviousRunLastAliveUtc(stateSubdir),
|
||||
proxyObserver: auth.ProxyObserver);
|
||||
|
||||
var whiteGloveSealingPatternIds = (System.Collections.Generic.IReadOnlyCollection<string>)remoteConfig.WhiteGloveSealingPatternIds
|
||||
?? Array.Empty<string>();
|
||||
|
||||
@@ -52,6 +52,10 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
// outbound call (legacy BackendApiClient calls + dominant /api/agent/telemetry POSTs).
|
||||
var networkMetrics = new NetworkMetrics();
|
||||
|
||||
// Before the first request, so a registration that fails has the route in the log too.
|
||||
var proxyObserver = new ProxyObserver(agentConfig.ApiBaseUrl, logger, SystemClock.Instance);
|
||||
proxyObserver.CaptureBaseline();
|
||||
|
||||
var hardware = HardwareInfo.GetHardwareInfo(logger);
|
||||
|
||||
// Resolve the MDM client cert when cert-mode is on. Cert-missing is non-fatal here:
|
||||
@@ -150,6 +154,7 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
emergencyReporter: emergencyReporter,
|
||||
authFailureTracker: authFailureTracker,
|
||||
serverCertificates: serverCertificates,
|
||||
proxyObserver: proxyObserver,
|
||||
clientCertificateThumbprint: clientCertificate?.Thumbprint);
|
||||
}
|
||||
|
||||
@@ -201,7 +206,8 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
authFailureTracker: auth.AuthFailureTracker,
|
||||
logger: logger, // Plan §5 Fix 5 — upload-cadence logging
|
||||
serverCertificates: auth.ServerCertificates,
|
||||
networkMetrics: auth.NetworkMetrics);
|
||||
networkMetrics: auth.NetworkMetrics,
|
||||
proxy: auth.ProxyObserver);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -291,6 +297,9 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
/// </summary>
|
||||
public ServerCertificateObserver ServerCertificates { get; }
|
||||
|
||||
/// <summary>Shared by the telemetry uploader built in Phase 5 and the collector host.</summary>
|
||||
public ProxyObserver ProxyObserver { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Thumbprint of the client certificate this run uses; null without one. Kept beside a
|
||||
/// failed run's record so the next successful start can tell whether the certificate changed.
|
||||
@@ -308,6 +317,7 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
EmergencyReporter emergencyReporter,
|
||||
AuthFailureTracker authFailureTracker,
|
||||
ServerCertificateObserver serverCertificates,
|
||||
ProxyObserver proxyObserver,
|
||||
string clientCertificateThumbprint = null)
|
||||
{
|
||||
BackendApiClient = backendApiClient;
|
||||
@@ -320,6 +330,7 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
EmergencyReporter = emergencyReporter;
|
||||
AuthFailureTracker = authFailureTracker;
|
||||
ServerCertificates = serverCertificates;
|
||||
ProxyObserver = proxyObserver;
|
||||
ClientCertificateThumbprint = clientCertificateThumbprint;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -606,7 +606,9 @@ function EventRow({ event, showScriptOutput, prevEvent, clockDeltas }: { event:
|
||||
<div id={`event-${event.eventId}`} className="bg-gray-50 rounded-lg p-3 hover:bg-gray-100 transition-colors">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* flex-wrap: on a phone the event type, one long word, drops below the time and badges
|
||||
instead of pushing the whole page sideways. */}
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<span className="text-xs text-gray-500 font-mono">
|
||||
{(recordedAt ?? new Date(event.timestamp)).toLocaleTimeString()}
|
||||
{recordedAt && (
|
||||
@@ -632,9 +634,9 @@ function EventRow({ event, showScriptOutput, prevEvent, clockDeltas }: { event:
|
||||
⧖ Clamped
|
||||
</span>
|
||||
)}
|
||||
<span className="text-sm font-medium text-gray-900">{event.eventType}</span>
|
||||
<span className="text-sm font-medium text-gray-900 min-w-0 break-words">{event.eventType}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-gray-600" title={event.message || undefined}>{shortenBuildHashInMessage(event.message)}</p>
|
||||
<p className="mt-1 text-sm text-gray-600 break-words" title={event.message || undefined}>{shortenBuildHashInMessage(event.message)}</p>
|
||||
{laterCorrection && (
|
||||
<p className="mt-0.5 text-xs text-gray-500">
|
||||
{laterCorrection === "foreign"
|
||||
|
||||
@@ -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"}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -5,9 +5,11 @@ import { partitionHistoricReplayEvents, type ReplayInputEvent } from "@/lib/hist
|
||||
import TruncatedLabel from "@/components/TruncatedLabel";
|
||||
import PendingAppRow from "@/components/PendingAppRow";
|
||||
import PhaseDivider from "@/components/PhaseDivider";
|
||||
import ProgressPanelHeader from "@/components/ProgressPanelHeader";
|
||||
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 +67,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 +159,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 +280,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 +352,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;
|
||||
@@ -365,104 +370,98 @@ export default function DownloadProgress({ events, summaryStats, observedUntilMs
|
||||
|
||||
return (
|
||||
<div className="bg-white shadow rounded-lg p-6 mb-6">
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="flex items-center justify-between w-full text-left"
|
||||
>
|
||||
{/* flex-wrap + gap (not space-x): on narrow screens the pills wrap as whole units
|
||||
below the title instead of overflowing the viewport; whitespace-nowrap inherits
|
||||
so no pill breaks internally. */}
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 min-w-0 whitespace-nowrap">
|
||||
<ProgressPanelHeader
|
||||
title="Download Progress"
|
||||
expanded={expanded}
|
||||
onToggle={() => setExpanded(!expanded)}
|
||||
icon={
|
||||
<svg className="w-5 h-5 text-blue-500 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
<h2 className="text-lg font-semibold text-gray-900">Download Progress</h2>
|
||||
{totalDownloadable != null && downloadedCount != null ? (
|
||||
}
|
||||
>
|
||||
{totalDownloadable != null && downloadedCount != null ? (
|
||||
<span
|
||||
className="text-xs text-gray-400"
|
||||
title="Install assignments only — skipped apps and uninstall downloads are not counted here."
|
||||
>
|
||||
({downloadedCount} of {totalDownloadable} downloaded)
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-gray-400">({downloads.length} {downloads.length === 1 ? 'download' : 'downloads'})</span>
|
||||
)}
|
||||
{totalDuration != null && (
|
||||
<span className="text-xs text-gray-400">
|
||||
— Total: {formatDuration(Math.floor(totalDuration / 1000))}
|
||||
</span>
|
||||
)}
|
||||
{totalP2P != null && (
|
||||
<span
|
||||
className="text-xs text-gray-400"
|
||||
title={`${formatBytes(totalP2P.peers)} from peers of ${formatBytes(totalP2P.total)} total`}
|
||||
>
|
||||
— P2P: {totalP2P.percent}%
|
||||
</span>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs">
|
||||
{activeCount > 0 && (
|
||||
<span className="px-2 py-0.5 rounded-full bg-blue-100 text-blue-700 font-medium">
|
||||
{activeCount} active
|
||||
</span>
|
||||
)}
|
||||
{completedCount > 0 && (
|
||||
<span className="px-2 py-0.5 rounded-full bg-green-100 text-green-700 font-medium">
|
||||
{completedCount} completed
|
||||
</span>
|
||||
)}
|
||||
{incompleteCount > 0 && (
|
||||
// Slate like the session-level Incomplete badge: unknown outcome, not a failure.
|
||||
<span
|
||||
className="text-xs text-gray-400"
|
||||
title="Install assignments only — skipped apps and uninstall downloads are not counted here."
|
||||
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={frozenText.countTitle}
|
||||
>
|
||||
({downloadedCount} of {totalDownloadable} downloaded)
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-gray-400">({downloads.length} {downloads.length === 1 ? 'download' : 'downloads'})</span>
|
||||
)}
|
||||
{totalDuration != null && (
|
||||
<span className="text-xs text-gray-400">
|
||||
— Total: {formatDuration(Math.floor(totalDuration / 1000))}
|
||||
{incompleteCount} {frozenText.countLabel}
|
||||
</span>
|
||||
)}
|
||||
{totalP2P != null && (
|
||||
{uninstallCount > 0 && (
|
||||
// Muted gray on purpose (dark-mode covered by the global gray overrides) —
|
||||
// it's context, not a state the user needs to act on.
|
||||
<span
|
||||
className="text-xs text-gray-400"
|
||||
title={`${formatBytes(totalP2P.peers)} from peers of ${formatBytes(totalP2P.total)} total`}
|
||||
className="px-2 py-0.5 rounded-full bg-gray-100 text-gray-600 font-medium"
|
||||
title="Package content downloaded for uninstall assignments — Win32 uninstalls re-download the full package before removing the app."
|
||||
>
|
||||
— P2P: {totalP2P.percent}%
|
||||
{uninstallCount} uninstall
|
||||
</span>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs">
|
||||
{activeCount > 0 && (
|
||||
<span className="px-2 py-0.5 rounded-full bg-blue-100 text-blue-700 font-medium">
|
||||
{activeCount} active
|
||||
</span>
|
||||
)}
|
||||
{completedCount > 0 && (
|
||||
<span className="px-2 py-0.5 rounded-full bg-green-100 text-green-700 font-medium">
|
||||
{completedCount} completed
|
||||
</span>
|
||||
)}
|
||||
{incompleteCount > 0 && (
|
||||
// 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."
|
||||
>
|
||||
{incompleteCount} incomplete
|
||||
</span>
|
||||
)}
|
||||
{uninstallCount > 0 && (
|
||||
// Muted gray on purpose (dark-mode covered by the global gray overrides) —
|
||||
// it's context, not a state the user needs to act on.
|
||||
<span
|
||||
className="px-2 py-0.5 rounded-full bg-gray-100 text-gray-600 font-medium"
|
||||
title="Package content downloaded for uninstall assignments — Win32 uninstalls re-download the full package before removing the app."
|
||||
>
|
||||
{uninstallCount} uninstall
|
||||
</span>
|
||||
)}
|
||||
{skippedCount > 0 && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setShowSkipped(!showSkipped); }}
|
||||
className={`px-2 py-0.5 rounded-full font-medium transition-colors ${showSkipped ? "bg-gray-200 text-gray-700" : "bg-gray-100 text-gray-400"}`}
|
||||
title={showSkipped ? "Hide skipped apps" : "Show skipped apps"}
|
||||
>
|
||||
{skippedCount} skipped {showSkipped ? "▾" : "▸"}
|
||||
</button>
|
||||
)}
|
||||
{noDownloadCount > 0 && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setShowNoDownload(!showNoDownload); }}
|
||||
className={`px-2 py-0.5 rounded-full font-medium transition-colors ${showNoDownload ? "bg-gray-200 text-gray-700" : "bg-gray-100 text-gray-400"}`}
|
||||
title="Assignments the management extension reported without downloading any package content — typically Store/WinGet uninstalls and skips."
|
||||
>
|
||||
{noDownloadCount} no download {showNoDownload ? "▾" : "▸"}
|
||||
</button>
|
||||
)}
|
||||
{pendingNames.length > 0 && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setShowPending(!showPending); }}
|
||||
className={`px-2 py-0.5 rounded-full font-medium transition-colors ${showPending ? "bg-gray-200 text-gray-700" : "bg-gray-100 text-gray-400"}`}
|
||||
title="Assigned to this device but not picked up by the management extension yet — no download activity has been observed."
|
||||
>
|
||||
{pendingNames.length} pending {showPending ? "▾" : "▸"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{skippedCount > 0 && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setShowSkipped(!showSkipped); }}
|
||||
className={`px-2 py-0.5 rounded-full font-medium transition-colors ${showSkipped ? "bg-gray-200 text-gray-700" : "bg-gray-100 text-gray-400"}`}
|
||||
title={showSkipped ? "Hide skipped apps" : "Show skipped apps"}
|
||||
>
|
||||
{skippedCount} skipped {showSkipped ? "▾" : "▸"}
|
||||
</button>
|
||||
)}
|
||||
{noDownloadCount > 0 && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setShowNoDownload(!showNoDownload); }}
|
||||
className={`px-2 py-0.5 rounded-full font-medium transition-colors ${showNoDownload ? "bg-gray-200 text-gray-700" : "bg-gray-100 text-gray-400"}`}
|
||||
title="Assignments the management extension reported without downloading any package content — typically Store/WinGet uninstalls and skips."
|
||||
>
|
||||
{noDownloadCount} no download {showNoDownload ? "▾" : "▸"}
|
||||
</button>
|
||||
)}
|
||||
{pendingNames.length > 0 && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setShowPending(!showPending); }}
|
||||
className={`px-2 py-0.5 rounded-full font-medium transition-colors ${showPending ? "bg-gray-200 text-gray-700" : "bg-gray-100 text-gray-400"}`}
|
||||
title="Assigned to this device but not picked up by the management extension yet — no download activity has been observed."
|
||||
>
|
||||
{pendingNames.length} pending {showPending ? "▾" : "▸"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<svg className={`w-5 h-5 text-gray-400 flex-shrink-0 transition-transform duration-200 ${expanded ? 'rotate-90' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</ProgressPanelHeader>
|
||||
|
||||
{expanded && <div className="space-y-3 mt-4">
|
||||
{userPhaseBoundary && userSplit > 0 && <PhaseDivider boundary={userPhaseBoundary} side="before" />}
|
||||
@@ -474,7 +473,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 +485,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 +546,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,10 +4,12 @@ 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";
|
||||
import PhaseDivider from "@/components/PhaseDivider";
|
||||
import ProgressPanelHeader from "@/components/ProgressPanelHeader";
|
||||
import AssignmentPill from "@/components/AssignmentPill";
|
||||
import { userPhaseSplitIndex, type UserPhaseBoundary } from "@/lib/userPhaseBoundary";
|
||||
|
||||
@@ -25,9 +27,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 +51,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,110 +155,105 @@ 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">
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="flex items-center justify-between w-full text-left"
|
||||
>
|
||||
{/* flex-wrap + gap (not space-x): on narrow screens the pills wrap as whole units
|
||||
below the title instead of overflowing the viewport; whitespace-nowrap inherits
|
||||
so no pill breaks internally. */}
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 min-w-0 whitespace-nowrap">
|
||||
<ProgressPanelHeader
|
||||
title="Install Progress"
|
||||
expanded={expanded}
|
||||
onToggle={() => setExpanded(!expanded)}
|
||||
icon={
|
||||
<svg className="w-5 h-5 text-indigo-500 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
|
||||
</svg>
|
||||
<h2 className="text-lg font-semibold text-gray-900">Install Progress</h2>
|
||||
{totalFromSummary != null && installedFromSummary != null && (
|
||||
<span className="text-xs text-gray-400">
|
||||
({installedFromSummary} of {totalFromSummary} installed)
|
||||
}
|
||||
>
|
||||
{totalFromSummary != null && installedFromSummary != null && (
|
||||
<span className="text-xs text-gray-400">
|
||||
({installedFromSummary} of {totalFromSummary} installed)
|
||||
</span>
|
||||
)}
|
||||
{totalDuration != null && (
|
||||
<span className="text-xs text-gray-400">
|
||||
— Total: {formatDuration(totalDuration)}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs">
|
||||
{activeCount > 0 && (
|
||||
<span className="px-2 py-0.5 rounded-full bg-indigo-100 text-indigo-700 font-medium">
|
||||
{activeCount} active
|
||||
</span>
|
||||
)}
|
||||
{totalDuration != null && (
|
||||
<span className="text-xs text-gray-400">
|
||||
— Total: {formatDuration(totalDuration)}
|
||||
{completedCount > 0 && (
|
||||
<span className="px-2 py-0.5 rounded-full bg-green-100 text-green-700 font-medium">
|
||||
{completedCount} completed
|
||||
</span>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs">
|
||||
{activeCount > 0 && (
|
||||
<span className="px-2 py-0.5 rounded-full bg-indigo-100 text-indigo-700 font-medium">
|
||||
{activeCount} active
|
||||
</span>
|
||||
)}
|
||||
{completedCount > 0 && (
|
||||
<span className="px-2 py-0.5 rounded-full bg-green-100 text-green-700 font-medium">
|
||||
{completedCount} completed
|
||||
</span>
|
||||
)}
|
||||
{uninstalledCount > 0 && (
|
||||
// Deliberately muted (gray family, dark-mode covered by the global overrides) —
|
||||
// a saturated pill here pulls the eye to what is a successful non-event.
|
||||
<span
|
||||
className="px-2 py-0.5 rounded-full bg-gray-100 text-gray-600 font-medium"
|
||||
title="Uninstall assignments enforced during this enrollment — the app was removed, not installed."
|
||||
>
|
||||
{uninstalledCount} uninstalled
|
||||
</span>
|
||||
)}
|
||||
{failedCount > 0 && (
|
||||
<span className="px-2 py-0.5 rounded-full bg-red-100 text-red-700 font-medium">
|
||||
{failedCount} failed
|
||||
</span>
|
||||
)}
|
||||
{likelyStuckCount > 0 && (
|
||||
<span
|
||||
className="px-2 py-0.5 rounded-full bg-orange-100 text-orange-700 font-medium"
|
||||
title="ESP timed out while these apps were still installing — final status couldn't be confirmed."
|
||||
>
|
||||
{likelyStuckCount} likely stuck
|
||||
</span>
|
||||
)}
|
||||
{incompleteCount > 0 && (
|
||||
// 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."
|
||||
>
|
||||
{incompleteCount} incomplete
|
||||
</span>
|
||||
)}
|
||||
{postponedCount > 0 && (
|
||||
<span className="px-2 py-0.5 rounded-full bg-amber-100 text-amber-700 font-medium">
|
||||
{postponedCount} postponed
|
||||
</span>
|
||||
)}
|
||||
{skippedCount > 0 && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setShowSkipped(!showSkipped); }}
|
||||
className={`px-2 py-0.5 rounded-full font-medium transition-colors ${showSkipped ? "bg-gray-200 text-gray-700" : "bg-gray-100 text-gray-400"}`}
|
||||
title={showSkipped ? "Hide skipped apps" : "Show skipped apps"}
|
||||
>
|
||||
{skippedCount} skipped {showSkipped ? "▾" : "▸"}
|
||||
</button>
|
||||
)}
|
||||
{pendingNames.length > 0 && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setShowPending(!showPending); }}
|
||||
className={`px-2 py-0.5 rounded-full font-medium transition-colors ${showPending ? "bg-gray-200 text-gray-700" : "bg-gray-100 text-gray-400"}`}
|
||||
title="Assigned to this device but not picked up by the management extension yet — no install activity has been observed."
|
||||
>
|
||||
{pendingNames.length} pending {showPending ? "▾" : "▸"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{uninstalledCount > 0 && (
|
||||
// Deliberately muted (gray family, dark-mode covered by the global overrides) —
|
||||
// a saturated pill here pulls the eye to what is a successful non-event.
|
||||
<span
|
||||
className="px-2 py-0.5 rounded-full bg-gray-100 text-gray-600 font-medium"
|
||||
title="Uninstall assignments enforced during this enrollment — the app was removed, not installed."
|
||||
>
|
||||
{uninstalledCount} uninstalled
|
||||
</span>
|
||||
)}
|
||||
{failedCount > 0 && (
|
||||
<span className="px-2 py-0.5 rounded-full bg-red-100 text-red-700 font-medium">
|
||||
{failedCount} failed
|
||||
</span>
|
||||
)}
|
||||
{likelyStuckCount > 0 && (
|
||||
<span
|
||||
className="px-2 py-0.5 rounded-full bg-orange-100 text-orange-700 font-medium"
|
||||
title="ESP timed out while these apps were still installing — final status couldn't be confirmed."
|
||||
>
|
||||
{likelyStuckCount} likely stuck
|
||||
</span>
|
||||
)}
|
||||
{incompleteCount > 0 && (
|
||||
// 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={frozenText.countTitle}
|
||||
>
|
||||
{incompleteCount} {frozenText.countLabel}
|
||||
</span>
|
||||
)}
|
||||
{postponedCount > 0 && (
|
||||
<span className="px-2 py-0.5 rounded-full bg-amber-100 text-amber-700 font-medium">
|
||||
{postponedCount} postponed
|
||||
</span>
|
||||
)}
|
||||
{skippedCount > 0 && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setShowSkipped(!showSkipped); }}
|
||||
className={`px-2 py-0.5 rounded-full font-medium transition-colors ${showSkipped ? "bg-gray-200 text-gray-700" : "bg-gray-100 text-gray-400"}`}
|
||||
title={showSkipped ? "Hide skipped apps" : "Show skipped apps"}
|
||||
>
|
||||
{skippedCount} skipped {showSkipped ? "▾" : "▸"}
|
||||
</button>
|
||||
)}
|
||||
{pendingNames.length > 0 && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setShowPending(!showPending); }}
|
||||
className={`px-2 py-0.5 rounded-full font-medium transition-colors ${showPending ? "bg-gray-200 text-gray-700" : "bg-gray-100 text-gray-400"}`}
|
||||
title="Assigned to this device but not picked up by the management extension yet — no install activity has been observed."
|
||||
>
|
||||
{pendingNames.length} pending {showPending ? "▾" : "▸"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<svg className={`w-5 h-5 text-gray-400 flex-shrink-0 transition-transform duration-200 ${expanded ? 'rotate-90' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</ProgressPanelHeader>
|
||||
|
||||
{expanded && <div className="space-y-3 mt-4">
|
||||
{userPhaseBoundary && userSplit > 0 && <PhaseDivider boundary={userPhaseBoundary} side="before" />}
|
||||
{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 +272,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 +296,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 +380,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 +428,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" && (
|
||||
@@ -506,7 +533,7 @@ function InstallItemRow({ item }: { item: InstallItem }) {
|
||||
);
|
||||
})()}
|
||||
{item.isError && item.errorDetail && !item.isLikelyStuck && (
|
||||
<div className="text-xs text-red-600">{item.errorDetail}</div>
|
||||
<div className="text-xs text-red-600 break-words">{item.errorDetail}</div>
|
||||
)}
|
||||
{item.isLikelyStuck && (
|
||||
<div className="text-xs text-orange-700">
|
||||
@@ -516,6 +543,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">
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface ProgressPanelHeaderProps {
|
||||
title: string;
|
||||
icon: ReactNode;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
// Summary text and pills after the title. May hold buttons of their own (the skipped and
|
||||
// pending toggles), which call stopPropagation so they do not also collapse the panel.
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapsible head of the Download and Install panels. A plain div takes the mouse click so the
|
||||
* whole head stays a click target, and only the chevron is a real button: the pills hold buttons,
|
||||
* and a button inside a button is invalid HTML that hides the inner ones from screen readers.
|
||||
*/
|
||||
export default function ProgressPanelHeader({ title, icon, expanded, onToggle, children }: ProgressPanelHeaderProps) {
|
||||
return (
|
||||
<div onClick={onToggle} className="flex items-center justify-between w-full text-left cursor-pointer select-none">
|
||||
{/* flex-wrap + gap (not space-x): on narrow screens the pills wrap as whole units
|
||||
below the title instead of overflowing the viewport; whitespace-nowrap inherits
|
||||
so no pill breaks internally. */}
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 min-w-0 whitespace-nowrap">
|
||||
{icon}
|
||||
<h2 className="text-lg font-semibold text-gray-900">{title}</h2>
|
||||
{children}
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onToggle(); }}
|
||||
aria-expanded={expanded}
|
||||
aria-label={title}
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
<svg className={`w-5 h-5 text-gray-400 flex-shrink-0 transition-transform duration-200 ${expanded ? 'rotate-90' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import ProgressPanelHeader from "../ProgressPanelHeader";
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
function render(expanded: boolean) {
|
||||
return renderToStaticMarkup(
|
||||
createElement(
|
||||
ProgressPanelHeader,
|
||||
{ title: "Install Progress", icon: createElement("svg"), expanded, onToggle: noop },
|
||||
createElement("button", null, "3 skipped"),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Walks the markup and reports whether a button ever opens while another one is still open.
|
||||
function hasNestedButton(html: string): boolean {
|
||||
let depth = 0;
|
||||
for (const [tag] of html.matchAll(/<\/?button\b/g)) {
|
||||
depth += tag === "<button" ? 1 : -1;
|
||||
if (depth > 1) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
describe("ProgressPanelHeader", () => {
|
||||
it("keeps the pill buttons and the panel toggle as siblings, never nested", () => {
|
||||
const html = render(true);
|
||||
expect(html.match(/<button\b/g)?.length).toBe(2);
|
||||
expect(hasNestedButton(html)).toBe(false);
|
||||
});
|
||||
|
||||
it("names the toggle after the panel and reports its state", () => {
|
||||
expect(render(true)).toContain('aria-expanded="true" aria-label="Install Progress"');
|
||||
expect(render(false)).toContain('aria-expanded="false" aria-label="Install Progress"');
|
||||
});
|
||||
|
||||
it("detects a nested button", () => {
|
||||
expect(hasNestedButton("<button><span><button></button></span></button>")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -95,36 +95,41 @@ export function MySubmissionsList({ kind, getAccessToken, refreshKey, overrideTe
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<button
|
||||
{/* The div takes the mouse click so the whole head stays a click target; the title and the
|
||||
filter are sibling buttons, because a button inside a button hides the inner one from
|
||||
the keyboard and from screen readers. */}
|
||||
<div
|
||||
onClick={() => setCollapsed((c) => !c)}
|
||||
className="w-full flex items-center justify-between px-4 py-3 text-left"
|
||||
className="w-full flex items-center justify-between px-4 py-3 text-left cursor-pointer select-none"
|
||||
>
|
||||
<span className="flex items-center gap-2 text-sm font-medium text-gray-900">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setCollapsed((c) => !c); }}
|
||||
aria-expanded={!collapsed}
|
||||
className="flex items-center gap-2 text-sm font-medium text-gray-900 text-left"
|
||||
>
|
||||
<svg className={`w-4 h-4 text-gray-400 transition-transform ${collapsed ? "" : "rotate-90"}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
Community submissions
|
||||
<span className="text-xs font-normal text-gray-500">({items.length}{pending > 0 ? `, ${pending} pending` : ""})</span>
|
||||
</span>
|
||||
</button>
|
||||
{!collapsed && hidden > 0 && (
|
||||
<span
|
||||
role="button"
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setShowAll(true); }}
|
||||
className="text-xs text-indigo-600 hover:text-indigo-800"
|
||||
>
|
||||
Show all ({hidden} more)
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
{!collapsed && showAll && items.length > pending && (
|
||||
<span
|
||||
role="button"
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setShowAll(false); }}
|
||||
className="text-xs text-indigo-600 hover:text-indigo-800"
|
||||
>
|
||||
Pending only
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{!collapsed && visible.length === 0 && (
|
||||
<p className="border-t border-gray-100 px-4 py-2 text-xs text-gray-500">Nothing pending.</p>
|
||||
)}
|
||||
|
||||
@@ -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.`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
"1d7bffc15ccf7436": 1,
|
||||
"29bbb90f9b9e306a": 1,
|
||||
"2a985532f2e52066": 1,
|
||||
"33829a769f2eacb9": 1,
|
||||
"376b94382a0f2fc3": 1,
|
||||
"391740d0bd84d73b": 1,
|
||||
"3a83e25903a9f78b": 1,
|
||||
|
||||
Reference in New Issue
Block a user