mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-11-03 09:03:29 +00:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d9b67e444a | |||
| 47b91f76e7 | |||
| 9c6b55a8bc | |||
| f00b475f59 | |||
| f8743dcae0 | |||
| bd94382edb | |||
| 8abd3a8848 |
+1
-1
Submodule .claude/commands updated: d937abd228...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: af17a42883...70adb72c87
+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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+3
-28
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+16
-4
@@ -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}");
|
||||
@@ -162,7 +169,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
|
||||
{
|
||||
sw.Stop();
|
||||
var cause = TransportErrorCodes.DescribeSendFailure(ex);
|
||||
_logger?.Warning($"BackendTelemetryUploader: ingest NETWORK fail after {sw.ElapsedMilliseconds}ms (corr={correlationId}, items={items.Count}): {cause}");
|
||||
_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: {cause}");
|
||||
}
|
||||
@@ -175,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)
|
||||
{
|
||||
@@ -189,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)
|
||||
{
|
||||
@@ -205,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -5,6 +5,7 @@ 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";
|
||||
@@ -369,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={frozenText.countTitle}
|
||||
>
|
||||
{incompleteCount} {frozenText.countLabel}
|
||||
</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" />}
|
||||
|
||||
@@ -9,6 +9,7 @@ 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";
|
||||
|
||||
@@ -158,100 +159,94 @@ export default function InstallProgress({ 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="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={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>
|
||||
{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" />}
|
||||
@@ -538,7 +533,7 @@ function InstallItemRow({ item, agentSilent }: { item: InstallItem; agentSilent:
|
||||
);
|
||||
})()}
|
||||
{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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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