Compare commits

..

20 Commits

Author SHA1 Message Date
Oliver Kieselbach d9b67e444a bump pointer: internal (D-400)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 18:17:48 +01:00
Oliver Kieselbach 47b91f76e7 Agent: a proxy value with credentials is replaced as a whole
Taking it apart left part of a password containing =, ; or :// in the log and the event.
A proxy string has no defined form for credentials, so there is nothing safe to parse.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 18:16:59 +01:00
Oliver Kieselbach 9c6b55a8bc bump pointer: internal (D-400), .claude/commands
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 18:05:27 +01:00
Oliver Kieselbach f00b475f59 Agent: the log names the upload route and when the proxy settings changed
The agent log had no proxy line at all, and proxy_configuration could not tell auto-detect
from direct.

ProxyObserver logs the settings and the route .NET chooses before the first request, every
change with its time, the route in each failed upload, one finding per failure series that
ties it to a change or says nothing changed, and a summary when the run stops while failing.
It compares with the start instead of judging a value.

proxy_configuration uses the same reader. It knows auto-detect and the real WinHTTP proxy,
drops credentials and PAC query strings, and is sent again on a change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 18:04:37 +01:00
Oliver Kieselbach f8743dcae0 bump pointer: internal (B-3gb done)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 17:24:21 +01:00
Oliver Kieselbach bd94382edb Web: panel heads without nested buttons, session page fits a phone
The Download and Install heads were one button holding the skipped, pending
and no-download toggles: invalid HTML that hid them from screen readers. The
head is now a div that takes the mouse click, so the whole area still toggles,
with the chevron as the real button (ProgressPanelHeader). The tab order
changes: the panel toggle now follows the pills.

Community submissions had the same flaw with span role=button, which the
keyboard could not reach at all. Title, Show all and Pending only are sibling
buttons now.

At 390 px the session page ran up to 630 px wide. The event type, the event
message and the install error detail hold long words without a break point;
the event row wraps now and those texts break inside a word. Desktop rows stay
on one line. The clone between the two panel heads is gone from the baseline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 17:24:21 +01:00
dependabot[bot] 8abd3a8848 chore(deps): bump the actions group with 6 updates (#195)
Bumps the actions group with 6 updates:

| Package | From | To |
| --- | --- | --- |
| [zizmorcore/zizmor-action](https://github.com/zizmorcore/zizmor-action) | `0.6.4` | `0.8.0` |
| [actions/upload-artifact](https://github.com/actions/upload-artifact) | `7.0.1` | `7.0.2` |
| [actions/download-artifact](https://github.com/actions/download-artifact) | `8.0.1` | `8.0.2` |
| [actions/setup-node](https://github.com/actions/setup-node) | `7.0.0` | `7.1.0` |
| [github/codeql-action/init](https://github.com/github/codeql-action) | `4.38.2` | `4.39.1` |
| [github/codeql-action/analyze](https://github.com/github/codeql-action) | `4.38.2` | `4.39.1` |


Updates `zizmorcore/zizmor-action` from 0.6.4 to 0.8.0
- [Release notes](https://github.com/zizmorcore/zizmor-action/releases)
- [Commits](https://github.com/zizmorcore/zizmor-action/compare/cc914d7f3750a2d13d75c7f184a1060aa0e9d482...53290badfa369cb0bf7ad16342cb62c2fa47ac7b)

Updates `actions/upload-artifact` from 7.0.1 to 7.0.2
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/043fb46d1a93c77aae656e7c1c64a875d1fc6a0a...cf430e030ddbb5b0abf93d22962f4752f3646cd9)

Updates `actions/download-artifact` from 8.0.1 to 8.0.2
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c...9000827ccba6bdab643e8b6fd33ac0654aef8333)

Updates `actions/setup-node` from 7.0.0 to 7.1.0
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/820762786026740c76f36085b0efc47a31fe5020...949feb2413d6458794dcd2491c4babbbce0c15c1)

Updates `github/codeql-action/init` from 4.38.2 to 4.39.1
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2...9b3254085494b55a0f7c4c2b3d2d52cc712ce370)

Updates `github/codeql-action/analyze` from 4.38.2 to 4.39.1
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2...9b3254085494b55a0f7c4c2b3d2d52cc712ce370)

---
updated-dependencies:
- dependency-name: zizmorcore/zizmor-action
  dependency-version: 0.8.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: actions/upload-artifact
  dependency-version: 7.0.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
- dependency-name: actions/download-artifact
  dependency-version: 8.0.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
- dependency-name: actions/setup-node
  dependency-version: 7.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: github/codeql-action/init
  dependency-version: 4.39.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: github/codeql-action/analyze
  dependency-version: 4.39.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-02 16:46:27 +01:00
Oliver Kieselbach 43b3008266 Merge origin/main: the phone layout of Install Progress and the No data row
Both changed InstallProgress; the row keeps the new detection badge and the frozen text.

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

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

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

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

OnAllAppsCompleted had no subscriber and is gone.

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 15:31:53 +01:00
Oliver Kieselbach 6901f6f682 Install Progress on phones: the green detection pill moves into Details, red and amber shorten (#194) 2026-10-31 08:50:27 +01:00
Oliver Kieselbach 82c0b20be1 bump pointer: internal (D-397), .claude/commands
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 12:27:53 +01:00
Oliver Kieselbach 4fa439cc3c Settings: Save only; the Reset button is gone from every card
Reset sat next to Save and discarded a card's edits with one click and no
confirmation. The bar is now SaveBar; the Reset handlers go with it, as does the
Reset button on the Global Admin settings card. A failed save shows its reason
until the next Save.

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 10:58:59 +01:00
Oliver Kieselbach 0ebbf87e5b bump pointer: internal (D-394 live: agent 2.0.1495; count the next session B-ztz)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 01:49:49 +01:00
82 changed files with 3685 additions and 1131 deletions
+1 -1
View File
@@ -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
+4 -4
View File
@@ -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 }}
+6 -6
View File
@@ -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 }}
+5 -5
View File
@@ -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
+2 -2
View File
@@ -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 }}
+2 -2
View File
@@ -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'
+2 -2
View File
@@ -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
+4 -4
View File
@@ -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
+1 -1
View File
@@ -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
@@ -0,0 +1,277 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
{
/// <summary>
/// IME rolls a log with File.Move at 3 MB. The tracker used to take the archive for a new file and
/// read it from 0: every app line played again, finished apps fell back to Installing and the
/// subgraph line of an older app marked the running app as skipped (session 190fed00).
/// </summary>
public sealed partial class ImeLogTrackerOverwriteRewindTests
{
private const string FirstStamp = "20260910-193000";
private const string SecondStamp = "20260910-193500";
private static string Folder(Harness h) => Path.GetDirectoryName(h.LogPath)!;
private static string ArchivePath(Harness h, string stamp = FirstStamp)
=> Path.Combine(Folder(h), $"{Path.GetFileNameWithoutExtension(h.LogName)}-{stamp}.log");
private static string Roll(Harness h, string stamp = FirstStamp)
{
var archive = ArchivePath(h, stamp);
File.Move(h.LogPath, archive);
return archive;
}
private static List<ImeLogPattern> AppPatterns() => new List<ImeLogPattern>
{
new ImeLogPattern
{
PatternId = "T-POLICIES", Category = "always", Enabled = true,
Pattern = @"^policies discovered = (?<policies>\[.*\])", Action = "policiesDiscovered",
Parameters = new Dictionary<string, string>(),
},
new ImeLogPattern
{
PatternId = "T-SUBGRAPH", Category = "always", Enabled = true,
Pattern = @"^subgraph (?<id>[\w-]+)", Action = "cancelStuckAndSetCurrent",
Parameters = new Dictionary<string, string>(),
},
new ImeLogPattern
{
PatternId = "T-INSTALLING", Category = "always", Enabled = true,
Pattern = @"^installing (?<id>[\w-]+)", Action = "updateStateInstalling",
Parameters = new Dictionary<string, string>(),
},
new ImeLogPattern
{
PatternId = "T-INSTALLED", Category = "always", Enabled = true,
Pattern = @"^installed (?<id>[\w-]+)", Action = "updateStateInstalled",
Parameters = new Dictionary<string, string>(),
},
};
private const string AppPoliciesLine = @"policies discovered = [{""Id"":""app-1"",""Name"":""App One""},{""Id"":""app-2"",""Name"":""App Two""}]";
private static string OneFinishedOneRunning() =>
Entry(AppPoliciesLine) + Entry("subgraph app-1") + Entry("installing app-1") + Entry("installed app-1")
+ Entry("subgraph app-2") + Entry("installing app-2");
[Theory]
[InlineData("AppWorkload.log")]
[InlineData("AppActionProcessor.log")]
[InlineData("HealthScripts.log")]
[InlineData("AgentExecutor.log")]
[InlineData("IntuneManagementExtension.log")]
public async Task Rolled_over_log_is_not_read_again(string logName)
{
using var h = new Harness(logName, MarkerPatterns());
h.Append(Entry("marker 1") + Entry("marker 2") + Entry("marker 3"));
await h.Pass();
Roll(h);
h.Append(Entry("marker 4"));
h.Now = h.Now.AddMilliseconds(100);
await h.Pass();
Assert.Equal(new[] { 1, 2, 3, 4 }, h.Matched());
h.Tracker.RequestOverwriteCheckForTest();
h.Now = h.Now.AddSeconds(2);
await h.Pass();
Assert.Equal(new[] { 1, 2, 3, 4 }, h.Matched());
Assert.Equal(0, h.Health.OverwriteRewinds);
}
[Fact]
public async Task Bytes_written_after_the_last_read_come_from_the_archive_once_and_first()
{
using var h = new Harness("AppWorkload.log", MarkerPatterns());
h.Append(Entry("marker 1") + Entry("marker 2"));
await h.Pass();
h.Append(Entry("marker 3"));
Roll(h);
h.Append(Entry("marker 4"));
h.Now = h.Now.AddMilliseconds(100);
await h.Pass();
Assert.Equal(new[] { 1, 2, 3, 4 }, h.Matched());
}
[Fact]
public async Task Line_held_back_at_the_end_of_the_file_is_finished_from_the_archive()
{
using var h = new Harness("AppWorkload.log", MarkerPatterns());
var second = Entry("marker 2");
h.Append(Entry("marker 1") + second.Substring(0, 10));
await h.Pass();
Assert.Equal(new[] { 1 }, h.Matched());
h.Append(second.Substring(10));
Roll(h);
h.Append(Entry("marker 3"));
h.Now = h.Now.AddMilliseconds(100);
await h.Pass();
Assert.Equal(new[] { 1, 2, 3 }, h.Matched());
}
[Fact]
public async Task Rollover_leaves_finished_and_running_apps_alone()
{
using var h = new Harness("AppWorkload.log", AppPatterns());
var transitions = new List<string>();
h.Tracker.OnAppStateChanged = (pkg, from, to) => transitions.Add($"{pkg.Id}:{from}->{to}");
h.Append(OneFinishedOneRunning());
await h.Pass();
var before = transitions.ToList();
Roll(h);
h.Append(Entry("marker unrelated"));
h.Now = h.Now.AddMilliseconds(100);
await h.Pass();
Assert.Equal(before, transitions);
var states = h.Tracker.GetAllKnownPackageStates().ToDictionary(p => p.Id, p => p.InstallationState);
Assert.Equal(AppInstallationState.Installed, states["app-1"]);
Assert.Equal(AppInstallationState.Installing, states["app-2"]);
}
[Fact]
public async Task Without_a_readable_identity_the_archive_is_read_again_as_before()
{
// The fallback, and the proof that the tests above catch the replay: this is what they saw before the fix.
using var h = new Harness("AppWorkload.log", AppPatterns());
h.Tracker.FileIdentityProvider = _ => null!;
var transitions = new List<string>();
h.Tracker.OnAppStateChanged = (pkg, from, to) => transitions.Add($"{pkg.Id}:{from}->{to}");
h.Append(OneFinishedOneRunning());
await h.Pass();
Roll(h);
h.Append(Entry("marker unrelated"));
h.Now = h.Now.AddMilliseconds(100);
await h.Pass();
Assert.Contains("app-2:Installing->Skipped", transitions);
Assert.Contains("app-1:Installed->Installing", transitions);
}
[Fact]
public async Task Live_file_that_sorts_before_its_archive_hands_over_its_bookmark()
{
// LogMaxHistory <= 1: the archive is _X.log, which sorts behind X.log. The pass reaches the
// shrunken live file first, and the next rollover replaces the archive under the same name.
using var h = new Harness("IntuneManagementExtension.log", MarkerPatterns());
var archive = Path.Combine(Folder(h), "_IntuneManagementExtension.log");
h.Append(Entry("marker 1") + Entry("marker 2") + Entry("marker 3") + Entry("marker 4"));
await h.Pass();
File.Move(h.LogPath, archive);
h.Append(Entry("marker 5"));
h.Now = h.Now.AddMilliseconds(100);
await h.Pass();
Assert.Equal(new[] { 1, 2, 3, 4, 5 }, h.Matched());
h.Append(Entry("marker 6"));
File.Delete(archive);
File.Move(h.LogPath, archive);
h.Append(Entry("marker 8"));
h.Now = h.Now.AddMilliseconds(100);
await h.Pass();
h.Now = h.Now.AddMilliseconds(100);
await h.Pass();
Assert.Equal(new[] { 1, 2, 3, 4, 5, 6, 8 }, h.Matched());
}
[Fact]
public async Task Rollover_during_a_restart_with_a_longer_new_file_loses_and_repeats_nothing()
{
using var h = new Harness("AppWorkload.log", MarkerPatterns());
h.Append(Entry("marker 1") + Entry("marker 2"));
await h.Pass();
h.Restart();
h.Append(Entry("marker 3"));
Roll(h);
h.Append(Entry("marker 4") + Entry("marker 5") + Entry("marker 6") + Entry("marker 7"));
await h.Pass();
Assert.Equal(new[] { 1, 2, 3, 4, 5, 6, 7 }, h.Matched());
}
[Fact]
public async Task Two_rollovers_during_a_restart_with_the_bookmarked_file_deleted_lose_nothing()
{
using var h = new Harness("AppWorkload.log", MarkerPatterns());
h.Append(Entry("marker 1") + Entry("marker 2"));
await h.Pass();
h.Restart();
var first = Roll(h, FirstStamp);
h.Append(Entry("marker 3"));
Roll(h, SecondStamp);
h.Append(Entry("marker 4") + Entry("marker 5") + Entry("marker 6"));
File.Delete(first);
await h.Pass();
Assert.Equal(new[] { 1, 2, 3, 4, 5, 6 }, h.Matched());
}
[Fact]
public async Task Archives_found_at_the_first_start_are_read_from_the_beginning_in_order()
{
using var h = new Harness("AppWorkload.log", MarkerPatterns());
File.WriteAllText(ArchivePath(h, FirstStamp), Entry("marker 1") + Entry("marker 2"));
File.WriteAllText(ArchivePath(h, SecondStamp), Entry("marker 3"));
h.Append(Entry("marker 4"));
await h.Pass();
Assert.Equal(new[] { 1, 2, 3, 4 }, h.Matched());
}
[Fact]
public async Task Platform_script_in_flight_keeps_its_end_block_across_a_rollover()
{
// Reading the archive again used to restore the marker of the run in flight by accident.
using var h = new Harness("AgentExecutor.log", ScriptPatterns());
h.Append(Entry("ExecutorLog AgentExecutor gets invoked") + Entry(PlatformStartLine) + Entry("[Executor] created powershell with process id 12708"));
await h.Pass();
Roll(h);
h.Append(Entry("Powershell exit code is 0") + Entry("write output done. output = Hello from E, error = ") + Entry("Agent executor completed."));
h.Now = h.Now.AddMilliseconds(100);
await h.Pass();
h.Tracker.ProcessLogMessageForTest(ResultLine);
var script = Assert.Single(h.Completed);
Assert.Equal(PlatformId, script.PolicyId);
Assert.Equal(0, script.ExitCode);
Assert.Equal("Hello from E", script.Stdout);
}
[Fact]
public void Identity_of_a_real_file_survives_a_rename_and_differs_from_the_next_file()
{
using var h = new Harness("AppWorkload.log", MarkerPatterns());
h.Append(Entry("marker 1"));
var before = ImeLogFileIdentity.TryRead(h.LogPath);
Assert.NotNull(before);
var archive = Roll(h);
h.Append(Entry("marker 2"));
Assert.Equal(before, ImeLogFileIdentity.TryRead(archive));
Assert.NotEqual(before, ImeLogFileIdentity.TryRead(h.LogPath));
Assert.Null(ImeLogFileIdentity.TryRead(archive + ".missing"));
}
}
}
@@ -0,0 +1,187 @@
using System;
using System.Collections.Generic;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
{
/// <summary>
/// A bookmark belongs to a file, not to a name: IME rolls a log by renaming it, so the identity
/// (volume plus file index) tells what happened to the bytes behind the bookmark.
/// </summary>
public sealed class LogFilePositionTrackerTests
{
private const string Live = @"C:\Logs\AppWorkload.log";
private const string Archive = @"C:\Logs\AppWorkload-20260910-193000.log";
private const string Underscore = @"C:\Logs\_AppWorkload.log";
private static readonly DateTime Checked = new DateTime(2026, 9, 10, 19, 30, 0, DateTimeKind.Utc);
private static Dictionary<string, string> Ids(params (string Path, string Id)[] entries)
{
var ids = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var entry in entries) ids[entry.Path] = entry.Id;
return ids;
}
private static LogFilePositionTracker TrackerWithBookmark(string path, string id, long position)
{
var tracker = new LogFilePositionTracker();
tracker.Reconcile(Ids((path, id)));
tracker.MarkChecked(path, Checked);
tracker.SetPosition(path, position);
return tracker;
}
[Fact]
public void Renamed_file_keeps_its_bookmark_and_the_old_path_starts_over()
{
var tracker = TrackerWithBookmark(Live, "A", 3000);
var moves = tracker.Reconcile(Ids((Archive, "A"), (Live, "B")));
var move = Assert.Single(moves);
Assert.Equal(Live, move.From);
Assert.Equal(Archive, move.To);
Assert.Equal(3000, move.Position);
Assert.Equal(3000, tracker.GetPosition(Archive));
Assert.Equal("A", tracker.GetFileId(Archive));
Assert.Equal(0, tracker.GetPosition(Live));
Assert.Equal("B", tracker.GetFileId(Live));
}
[Fact]
public void Both_paths_keep_the_freshness_reference()
{
var tracker = TrackerWithBookmark(Live, "A", 3000);
tracker.Reconcile(Ids((Archive, "A"), (Live, "B")));
Assert.True(tracker.HasSeen(Archive));
Assert.Equal(Checked, tracker.GetLastCheckedUtc(Archive));
Assert.True(tracker.HasSeen(Live));
Assert.Equal(Checked, tracker.GetLastCheckedUtc(Live));
}
[Fact]
public void Renamed_file_whose_old_path_is_not_recreated_yet_keeps_its_bookmark()
{
var tracker = TrackerWithBookmark(Live, "A", 3000);
var moves = tracker.Reconcile(Ids((Archive, "A")));
Assert.Single(moves);
Assert.Equal(3000, tracker.GetPosition(Archive));
Assert.Equal(0, tracker.GetPosition(Live));
Assert.False(tracker.HasIdentity(Live));
}
[Fact]
public void Second_name_for_the_same_file_moves_nothing()
{
var tracker = TrackerWithBookmark(Live, "A", 3000);
var moves = tracker.Reconcile(Ids((Live, "A"), (Archive, "A")));
Assert.Empty(moves);
Assert.Equal(3000, tracker.GetPosition(Live));
Assert.False(tracker.HasSeen(Archive));
}
[Fact]
public void Unknown_file_stays_unseen_so_its_first_pass_is_not_taken_for_fresh_lines()
{
var tracker = TrackerWithBookmark(Live, "A", 3000);
var moves = tracker.Reconcile(Ids((Live, "A"), (Archive, "X")));
Assert.Empty(moves);
Assert.True(tracker.HasIdentity(Archive));
Assert.False(tracker.HasSeen(Archive));
Assert.Equal(0, tracker.GetPosition(Archive));
}
[Fact]
public void Path_holding_another_file_with_no_one_to_take_over_starts_at_zero()
{
var tracker = TrackerWithBookmark(Live, "A", 3000);
var moves = tracker.Reconcile(Ids((Live, "B")));
Assert.Empty(moves);
// GetSafePosition alone would keep 3000 here when the new file is longer: lost lines.
Assert.Equal(0, tracker.GetSafePosition(Live, 5000));
Assert.Equal("B", tracker.GetFileId(Live));
}
[Fact]
public void Unreadable_identity_changes_nothing()
{
var tracker = TrackerWithBookmark(Live, "A", 3000);
var moves = tracker.Reconcile(Ids((Live, null!), (Archive, null!)));
Assert.Empty(moves);
Assert.Equal(3000, tracker.GetPosition(Live));
Assert.Equal("A", tracker.GetFileId(Live));
Assert.True(tracker.HasIdentity(Archive));
Assert.Null(tracker.GetFileId(Archive));
}
[Fact]
public void Single_slot_archive_is_replaced_by_the_next_rollover()
{
// LogMaxHistory <= 1: IME deletes _X.log and renames X.log to it.
var tracker = TrackerWithBookmark(Underscore, "A", 9000);
tracker.Reconcile(Ids((Underscore, "A"), (Live, "B")));
tracker.SetPosition(Live, 3000);
var moves = tracker.Reconcile(Ids((Underscore, "B"), (Live, "C")));
var move = Assert.Single(moves);
Assert.Equal(Live, move.From);
Assert.Equal(Underscore, move.To);
Assert.Equal(3000, tracker.GetPosition(Underscore));
Assert.Equal(0, tracker.GetPosition(Live));
}
[Fact]
public void A_renamed_file_can_leave_the_path_it_moved_into()
{
var tracker = TrackerWithBookmark(Live, "A", 3000);
tracker.Reconcile(Ids((Live, "A"), (Underscore, "B")));
tracker.SetPosition(Underscore, 700);
var moves = tracker.Reconcile(Ids((Underscore, "A"), (Archive, "B"), (Live, "C")));
Assert.Equal(2, moves.Count);
Assert.Equal(3000, tracker.GetPosition(Underscore));
Assert.Equal(700, tracker.GetPosition(Archive));
Assert.Equal(0, tracker.GetPosition(Live));
}
[Fact]
public void Behind_bookmark_only_when_smaller_than_the_position()
{
var tracker = TrackerWithBookmark(Live, "A", 3000);
Assert.True(tracker.IsBehindBookmark(Live, 2999));
Assert.False(tracker.IsBehindBookmark(Live, 3000));
Assert.False(tracker.IsBehindBookmark(Archive, 0));
}
[Fact]
public void Restored_identity_is_used_to_find_a_rollover_during_the_downtime()
{
var tracker = new LogFilePositionTracker();
tracker.RestorePosition(Live, 3000, 3000);
tracker.RestoreFileId(Live, "A");
var moves = tracker.Reconcile(Ids((Archive, "A"), (Live, "B")));
Assert.Single(moves);
Assert.Equal(3000, tracker.GetPosition(Archive));
Assert.Equal(0, tracker.GetPosition(Live));
}
}
}
@@ -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());
}
}
}
@@ -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);
}
}
}
@@ -550,6 +550,24 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
Assert.Contains("network", result.ErrorReason);
}
[Fact]
public async Task Network_error_reason_names_the_cause_instead_of_the_generic_text()
{
using var rig = new Rig();
rig.Handler.QueueThrow(new HttpRequestException(
"An error occurred while sending the request.",
new WebException(
"Unable to connect to the remote server",
new System.Net.Sockets.SocketException((int)System.Net.Sockets.SocketError.TimedOut),
WebExceptionStatus.ConnectFailure,
null)));
var result = await rig.Sut.UploadBatchAsync(new[] { NewEventItem(1) }, CancellationToken.None);
Assert.True(result.IsTransient);
Assert.StartsWith("network: status=ConnectFailure, socket=TimedOut: ", result.ErrorReason);
}
[Fact]
public async Task HttpClient_timeout_maps_to_Transient()
{
@@ -0,0 +1,366 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
using AutopilotMonitor.Agent.V2.Core.Transport;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
{
/// <summary>Settings and route are played in: no test changes a real proxy setting.</summary>
public sealed class ProxyObserverTests : IDisposable
{
private static readonly ProxySettings AutoDetect = new ProxySettings(true, null, null, null, null);
private static readonly ProxySettings NoProxy = new ProxySettings(false, null, null, null, null);
private readonly TempDirectory _tmp = new TempDirectory();
private readonly VirtualClock _clock = new VirtualClock(new DateTime(2026, 11, 2, 6, 29, 13, DateTimeKind.Utc));
private ProxySettings _settings = AutoDetect;
private string _route = "proxy.example.test:8080";
private ProxyObserver NewObserver(Func<string>? readRoute = null, TimeSpan? routeBound = null, Func<ProxySettings>? readSettings = null) =>
new ProxyObserver(
readSettings ?? (() => _settings),
readRoute ?? (() => _route),
new AgentLogger(_tmp.Path, AgentLogLevel.Info),
_clock,
routeBound ?? TimeSpan.FromSeconds(30));
private ProxyObserver StartedObserver()
{
var observer = NewObserver();
observer.CaptureBaseline();
return observer;
}
private void SwitchToNoProxy()
{
_settings = NoProxy;
_route = "direct";
}
private List<string> LogLines()
{
var lines = new List<string>();
foreach (var file in Directory.EnumerateFiles(_tmp.Path, "*.log", SearchOption.AllDirectories))
{
using var stream = new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
using var reader = new StreamReader(stream);
lines.AddRange(reader.ReadToEnd().Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries));
}
return lines.Where(l => l.Contains("ProxyObserver:")).ToList();
}
public void Dispose() => _tmp.Dispose();
[Fact]
public void The_baseline_names_settings_and_route_at_info()
{
StartedObserver();
var line = Assert.Single(LogLines());
Assert.Contains("[INFO]", line);
Assert.Contains("start - IE settings of this account: auto-detect; WinHTTP: direct; route to backend: proxy.example.test:8080", line);
}
[Fact]
public void A_rebuild_of_the_clients_does_not_repeat_the_baseline()
{
var logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
foreach (var _ in new[] { 1, 2 })
{
new ProxyObserver(() => _settings, () => _route, logger, _clock, TimeSpan.FromSeconds(30)).CaptureBaseline();
}
Assert.Single(LogLines());
}
[Fact]
public void A_change_warns_once_with_both_sides()
{
var observer = StartedObserver();
SwitchToNoProxy();
observer.CheckForChange();
observer.CheckForChange();
var line = Assert.Single(LogLines().Skip(1));
Assert.Contains("[WARN]", line);
Assert.Contains("proxy settings changed - IE settings of this account: auto-detect -> no proxy; route to backend: proxy.example.test:8080 -> direct.", line);
Assert.Contains("bitsadmin /util /getieproxy localsystem", line);
}
[Fact]
public void Rewriting_the_same_content_says_nothing()
{
var observer = StartedObserver();
_settings = new ProxySettings(true, null, null, null, null);
observer.CheckForChange();
Assert.Single(LogLines());
}
[Fact]
public void A_winhttp_change_says_uploads_do_not_use_it()
{
var observer = StartedObserver();
_settings = new ProxySettings(true, null, null, null, "p:8080");
observer.CheckForChange();
var line = LogLines().Last();
Assert.Contains("[WARN]", line);
Assert.Contains("WinHTTP: direct -> proxy p:8080", line);
Assert.Contains("do not use WinHTTP", line);
}
[Fact]
public void A_winhttp_change_is_not_blamed_for_failing_uploads()
{
var observer = StartedObserver();
_settings = new ProxySettings(true, null, null, null, "p:8080");
observer.CheckForChange();
observer.OnSendFailed(TimeSpan.Zero);
Assert.Contains("Proxy settings of this account unchanged since start: auto-detect.", LogLines().Last());
}
[Fact]
public void A_change_reaches_the_subscriber_with_settings_and_route()
{
var observer = StartedObserver();
ProxySettings? seen = null;
string? seenWhat = null;
string? seenRoute = null;
observer.SettingsChanged += (settings, what, route) =>
{
seen = settings;
seenWhat = what;
seenRoute = route;
};
SwitchToNoProxy();
observer.CheckForChange();
Assert.Equal(NoProxy, seen);
Assert.Equal("IE settings of this account: auto-detect -> no proxy", seenWhat);
Assert.Equal("direct", seenRoute);
}
[Fact]
public void A_subscriber_that_throws_does_not_reach_the_caller()
{
var observer = StartedObserver();
observer.SettingsChanged += (_, _, _) => throw new InvalidOperationException("SignalIngress has been stopped.");
SwitchToNoProxy();
observer.CheckForChange();
Assert.Contains(LogLines(), l => l.Contains("proxy settings changed"));
}
[Fact]
public void The_first_failure_ties_the_series_to_the_change()
{
var observer = StartedObserver();
_clock.Advance(TimeSpan.FromMinutes(23));
observer.OnSendSucceeded();
_clock.Advance(TimeSpan.FromSeconds(90));
SwitchToNoProxy();
observer.CheckForChange();
_clock.Advance(TimeSpan.FromSeconds(62));
observer.OnSendFailed(TimeSpan.FromSeconds(21));
var line = LogLines().Last();
Assert.Contains("[WARN]", line);
Assert.Contains("uploads are failing since 06:54:24, 41 s after the proxy settings changed at 06:53:43 (IE settings of this account: auto-detect -> no proxy).", line);
Assert.Contains("Route to backend: direct. Last successful upload 06:52:13.", line);
}
[Fact]
public void A_failure_notices_a_change_the_watcher_did_not_report()
{
var observer = StartedObserver();
SwitchToNoProxy();
observer.OnSendFailed(TimeSpan.Zero);
var lines = LogLines();
Assert.Contains(lines, l => l.Contains("proxy settings changed - IE settings of this account: auto-detect -> no proxy"));
Assert.Contains("after the proxy settings changed", lines.Last());
}
[Fact]
public void A_series_writes_its_finding_once()
{
var observer = StartedObserver();
observer.OnSendFailed(TimeSpan.Zero);
observer.OnSendFailed(TimeSpan.Zero);
observer.OnSendFailed(TimeSpan.Zero);
Assert.Single(LogLines(), l => l.Contains("uploads are failing"));
}
[Fact]
public void A_failure_without_a_change_says_the_settings_are_unchanged()
{
var observer = StartedObserver();
observer.OnSendFailed(TimeSpan.Zero);
var line = LogLines().Last();
Assert.Contains("Proxy settings of this account unchanged since start: auto-detect.", line);
Assert.Contains("Route to backend: proxy.example.test:8080. No successful upload yet.", line);
}
[Fact]
public void A_change_before_the_last_success_is_not_blamed()
{
var observer = StartedObserver();
SwitchToNoProxy();
observer.CheckForChange();
_clock.Advance(TimeSpan.FromMinutes(1));
observer.OnSendSucceeded();
_clock.Advance(TimeSpan.FromMinutes(1));
observer.OnSendFailed(TimeSpan.Zero);
Assert.Contains("Proxy settings of this account unchanged since the last successful upload: no proxy.", LogLines().Last());
}
[Fact]
public void Recovery_is_noted_and_a_new_series_gets_its_own_finding()
{
var observer = StartedObserver();
observer.OnSendFailed(TimeSpan.Zero);
observer.OnSendFailed(TimeSpan.Zero);
_clock.Advance(TimeSpan.FromMinutes(9));
observer.OnSendSucceeded();
var line = LogLines().Last();
Assert.Contains("[INFO]", line);
Assert.Contains("uploads work again after 2 failed attempt(s) over 9 min; route to backend: proxy.example.test:8080.", line);
observer.OnSendFailed(TimeSpan.Zero);
Assert.Equal(2, LogLines().Count(l => l.Contains("uploads are failing")));
}
[Fact]
public void Recovery_reports_settings_that_came_back()
{
var observer = StartedObserver();
SwitchToNoProxy();
observer.OnSendFailed(TimeSpan.Zero);
_settings = AutoDetect;
_route = "proxy.example.test:8080";
observer.OnSendSucceeded();
Assert.Contains(LogLines(), l => l.Contains("no proxy -> auto-detect"));
}
[Fact]
public void A_success_without_a_series_stays_silent()
{
var observer = StartedObserver();
observer.OnSendSucceeded();
Assert.Single(LogLines());
}
[Fact]
public void Stopping_while_failing_sums_it_up()
{
var observer = StartedObserver();
observer.OnSendSucceeded();
_clock.Advance(TimeSpan.FromMinutes(1));
SwitchToNoProxy();
observer.OnSendFailed(TimeSpan.Zero);
observer.OnSendFailed(TimeSpan.Zero);
observer.LogOpenFailures();
var line = LogLines().Last();
Assert.Contains("[WARN]", line);
Assert.Contains("stopping with uploads still failing: 2 failed attempt(s) since 06:30:13, last success 06:29:13, route to backend: direct.", line);
Assert.Contains("Proxy settings changed at 06:30:13 (IE settings of this account: auto-detect -> no proxy).", line);
}
[Fact]
public void Stopping_without_a_series_stays_silent()
{
var observer = StartedObserver();
observer.OnSendFailed(TimeSpan.Zero);
observer.OnSendSucceeded();
var before = LogLines().Count;
observer.LogOpenFailures();
Assert.Equal(before, LogLines().Count);
}
[Fact]
public void A_settings_read_that_throws_is_not_a_change_and_never_reaches_the_upload()
{
var throwing = false;
var observer = NewObserver(readSettings: () => throwing ? throw new InvalidOperationException("boom") : _settings);
observer.CaptureBaseline();
throwing = true;
observer.CheckForChange();
observer.OnSendFailed(TimeSpan.Zero);
Assert.DoesNotContain(LogLines(), l => l.Contains("proxy settings changed"));
Assert.Contains("Proxy settings of this account unchanged since start: auto-detect.", LogLines().Last());
}
[Fact]
public void An_unreadable_state_is_not_a_change()
{
var observer = StartedObserver();
_settings = ProxySettings.Unreadable(5);
observer.CheckForChange();
Assert.Single(LogLines());
}
[Fact]
public void A_route_read_that_throws_reads_as_unknown()
{
var observer = NewObserver(readRoute: () => throw new InvalidOperationException("boom"));
Assert.Equal("unknown", observer.RouteText());
}
[Fact]
public void A_hanging_route_read_gives_up_and_does_not_pile_up()
{
using var release = new ManualResetEventSlim(false);
var reads = 0;
var observer = NewObserver(
readRoute: () =>
{
Interlocked.Increment(ref reads);
release.Wait();
return "direct";
},
routeBound: TimeSpan.FromMilliseconds(50));
Assert.Equal("unknown", observer.RouteText());
Assert.Equal("unknown", observer.RouteText());
Assert.Equal(1, Volatile.Read(ref reads));
release.Set();
}
}
}
@@ -0,0 +1,167 @@
using System;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Transport;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
{
public sealed class ProxySettingsTests
{
private static ProxySettings Settings(
bool autoDetect = false, string? pac = null, string? proxy = null, string? bypass = null, string? winHttp = null) =>
new ProxySettings(autoDetect, pac, proxy, bypass, winHttp);
[Fact]
public void Each_kind_of_setting_describes_itself()
{
Assert.Equal("no proxy", Settings().Describe());
Assert.Equal("auto-detect", Settings(autoDetect: true).Describe());
Assert.Equal("PAC https://pac.example.test/proxy.pac", Settings(pac: "https://pac.example.test/proxy.pac").Describe());
Assert.Equal("proxy proxy.example.test:8080", Settings(proxy: "proxy.example.test:8080").Describe());
Assert.Equal("auto-detect, proxy p:8080", Settings(autoDetect: true, proxy: "p:8080").Describe());
Assert.Equal("direct", Settings().DescribeWinHttp());
Assert.Equal("proxy p:8080", Settings(winHttp: "p:8080").DescribeWinHttp());
Assert.Equal("unreadable (Win32 error 5)", ProxySettings.Unreadable(5).Describe());
}
[Theory]
[InlineData(false, null, null, "Direct")]
[InlineData(true, null, null, "AutoDetect")]
[InlineData(true, "https://pac.example.test/p.pac", null, "PAC")]
[InlineData(true, "https://pac.example.test/p.pac", "p:8080", "Proxy")]
public void Type_keeps_the_values_the_download_rule_matches(bool autoDetect, string? pac, string? proxy, string expected)
{
Assert.Equal(expected, Settings(autoDetect, pac, proxy).ProxyType);
}
[Fact]
public void An_unreadable_state_has_its_own_type()
{
Assert.Equal("Unknown", ProxySettings.Unreadable(5).ProxyType);
}
[Fact]
public void Equal_content_is_equal_and_any_field_makes_it_different()
{
Assert.Equal(Settings(autoDetect: true), Settings(autoDetect: true));
Assert.NotEqual(Settings(autoDetect: true), Settings());
Assert.NotEqual(Settings(pac: "https://a.test/p"), Settings(pac: "https://b.test/p"));
Assert.NotEqual(Settings(proxy: "a:1"), Settings(proxy: "b:1"));
Assert.NotEqual(Settings(proxy: "a:1", bypass: "x"), Settings(proxy: "a:1", bypass: "y"));
Assert.NotEqual(Settings(winHttp: "a:1"), Settings());
}
[Fact]
public void An_empty_string_counts_as_not_set()
{
Assert.Equal(Settings(), Settings(pac: "", proxy: "", bypass: "", winHttp: ""));
}
[Theory]
[InlineData("user:secret@p:8080")]
[InlineData("http://user:secret@p:8080")]
[InlineData("http=user:secret@p:8080;https=user:secret@q:8443")]
[InlineData("user:se=cret@p:8080")]
[InlineData("user:se;cret@p:8080")]
[InlineData("http=user:se;cret@p:8080")]
[InlineData("user:se://cret@p:8080")]
public void Credentials_never_reach_logs_or_events(string configured)
{
var settings = Settings(proxy: configured, winHttp: configured);
var data = settings.ToEventData();
foreach (var text in new[] { settings.DescribeAll(), (string)data["proxyServer"], (string)data["winHttpProxy"] })
{
Assert.DoesNotContain("user", text);
Assert.DoesNotContain("cret", text);
}
Assert.Equal(ProxySettings.CredentialsHidden, settings.Proxy);
Assert.Equal(ProxySettings.CredentialsHidden, settings.WinHttpProxy);
Assert.True((bool)data["proxyEnabled"]);
Assert.True((bool)data["winHttpProxyConfigured"]);
}
[Theory]
[InlineData("p:8080")]
[InlineData("http://p:8080")]
[InlineData("http=p:8080;https=q:8443")]
public void A_value_without_credentials_stays_as_it_is(string configured)
{
Assert.Equal(configured, Settings(proxy: configured).Proxy);
Assert.Equal(configured, Settings(winHttp: configured).WinHttpProxy);
}
[Theory]
[InlineData("https://pac.example.test/p.pac?key=secret", "https://pac.example.test/p.pac")]
[InlineData("http://user:secret@pac.example.test:8080/p.pac", "http://pac.example.test:8080/p.pac")]
[InlineData("http://user:se=cr;et@pac.example.test/p.pac", "http://pac.example.test/p.pac")]
[InlineData("not a url with secret", "(unparsable URL)")]
public void A_pac_url_keeps_scheme_host_port_and_path(string configured, string expected)
{
Assert.Equal(expected, Settings(pac: configured).PacUrl);
}
[Fact]
public void A_change_names_what_moved()
{
var before = Settings(autoDetect: true);
Assert.Equal("IE settings of this account: auto-detect -> no proxy", Settings().DescribeChangeFrom(before));
Assert.Equal("WinHTTP: direct -> proxy p:1", Settings(autoDetect: true, winHttp: "p:1").DescribeChangeFrom(before));
Assert.Equal(
"IE settings of this account: auto-detect -> no proxy; WinHTTP: direct -> proxy p:1",
Settings(winHttp: "p:1").DescribeChangeFrom(before));
Assert.Equal(
"IE settings of this account: proxy bypass list changed",
Settings(proxy: "p:1", bypass: "b").DescribeChangeFrom(Settings(proxy: "p:1", bypass: "a")));
}
[Fact]
public void Only_the_account_settings_count_as_a_change_for_uploads()
{
var before = Settings(autoDetect: true);
Assert.True(Settings().DiffersForUploadsFrom(before));
Assert.False(Settings(autoDetect: true, winHttp: "p:1").DiffersForUploadsFrom(before));
}
[Fact]
public void The_event_tells_auto_detect_from_direct()
{
var data = Settings(autoDetect: true).ToEventData();
Assert.Equal("AutoDetect", data["proxyType"]);
Assert.True((bool)data["autoDetect"]);
Assert.False((bool)data["proxyEnabled"]);
Assert.Null(data["proxyServer"]);
Assert.False((bool)data["winHttpProxyConfigured"]);
Assert.Null(data["winHttpProxy"]);
}
[Fact]
public void The_event_carries_a_real_winhttp_proxy()
{
var data = Settings(winHttp: "p:8080").ToEventData();
Assert.True((bool)data["winHttpProxyConfigured"]);
Assert.Equal("p:8080", data["winHttpProxy"]);
}
[Fact]
public void The_real_settings_of_this_account_can_be_read()
{
var settings = ProxySettingsReader.Read();
Assert.False(settings.IsUnreadable, settings.Describe());
}
[Fact]
public async Task The_real_route_can_be_read()
{
var read = Task.Run(() => ProxySettingsReader.ReadRoute(new Uri("https://backend.example.invalid/")));
Assert.False(string.IsNullOrEmpty(await read));
}
}
}
@@ -2,7 +2,9 @@ using System;
using System.ComponentModel;
using System.Net;
using System.Net.Http;
using System.Net.Sockets;
using System.Security.Authentication;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
using Xunit;
@@ -69,5 +71,56 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
Assert.Equal("boom", TransportErrorCodes.MessageWithNativeCode(new InvalidOperationException("boom")));
Assert.Null(TransportErrorCodes.MessageWithNativeCode(null!));
}
// The .NET Framework shape of a failed connect or name lookup through HttpClient.
private static Exception SendFailureChain(WebExceptionStatus status, SocketError socketError) =>
new HttpRequestException(
"An error occurred while sending the request.",
new WebException("Unable to connect to the remote server", new SocketException((int)socketError), status, null));
[Theory]
[InlineData(WebExceptionStatus.ConnectFailure, SocketError.TimedOut, "status=ConnectFailure, socket=TimedOut: ")]
[InlineData(WebExceptionStatus.NameResolutionFailure, SocketError.HostNotFound, "status=NameResolutionFailure, socket=HostNotFound: ")]
public void Send_failure_names_status_and_socket_error(WebExceptionStatus status, SocketError socketError, string expectedPrefix)
{
var described = TransportErrorCodes.DescribeSendFailure(SendFailureChain(status, socketError));
Assert.StartsWith(expectedPrefix, described);
Assert.DoesNotContain("An error occurred while sending the request", described);
}
[Fact]
public void Send_failure_of_a_tls_handshake_names_status_and_native_code()
{
var described = TransportErrorCodes.DescribeSendFailure(TlsFailureChain(new Win32Exception(IllegalMessage)));
Assert.StartsWith("status=SecureChannelFailure, win32=0x80090326: ", described);
}
[Fact]
public void Send_failure_without_details_is_the_innermost_message()
{
Assert.Equal("inner", TransportErrorCodes.DescribeSendFailure(new HttpRequestException("outer", new InvalidOperationException("inner"))));
Assert.Equal("boom", TransportErrorCodes.DescribeSendFailure(new InvalidOperationException("boom")));
Assert.Null(TransportErrorCodes.DescribeSendFailure(null!));
}
[Fact]
public async Task Real_refused_connect_names_the_endpoint_it_tried()
{
// The endpoint in the message is what tells the backend from a proxy, and only a real
// connect produces it: a hand-built SocketException carries none.
var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
listener.Stop();
using var client = new HttpClient(new HttpClientHandler { UseProxy = false });
var failure = await Assert.ThrowsAsync<HttpRequestException>(() => client.GetAsync($"http://127.0.0.1:{port}/"));
var described = TransportErrorCodes.DescribeSendFailure(failure);
Assert.StartsWith("status=ConnectFailure, socket=ConnectionRefused: ", described);
Assert.Contains($"127.0.0.1:{port}", described);
}
}
}
@@ -0,0 +1,33 @@
using System;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
{
/// <summary>
/// Which file a path holds right now: volume serial plus file index. IME rolls a log with
/// <c>File.Move</c>, so the identity stays with the bytes when the name changes.
/// </summary>
internal static class ImeLogFileIdentity
{
/// <summary>
/// Null when the file cannot be opened. An attribute-only handle with full sharing, so it
/// can never make IME's rename of the log fail.
/// </summary>
public static string TryRead(string path)
{
using (var handle = FileNativeMethods.CreateFileW(
path,
FileNativeMethods.FILE_READ_ATTRIBUTES,
FileNativeMethods.FILE_SHARE_READ | FileNativeMethods.FILE_SHARE_WRITE | FileNativeMethods.FILE_SHARE_DELETE,
IntPtr.Zero,
FileNativeMethods.OPEN_EXISTING,
0,
IntPtr.Zero))
{
if (handle.IsInvalid) return null;
if (!FileNativeMethods.GetFileInformationByHandle(handle, out var info)) return null;
return $"{info.VolumeSerialNumber:X8}-{info.FileIndexHigh:X8}{info.FileIndexLow:X8}";
}
}
}
}
@@ -1709,7 +1709,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
{
_allAppsCompletedFired = true;
_logger.Info($"ImeLogTracker: all {_packageStates.CountAll} apps completed");
OnAllAppsCompleted?.Invoke();
}
}
}
@@ -51,23 +51,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
if (!Directory.Exists(_logFolder))
return;
// Get all matching log files, sorted by name (archived files come before current).
// M2: a SINGLE directory enumeration filtered in-memory against the patterns, instead
// of one Directory.GetFiles(pattern) per LogFilePattern every 100 ms poll (~90 folder
// enumerations/s → 1). The in-memory regex match is anchored, so it is also immune to
// the Win32 "*.ext" 8.3 search-pattern quirk.
var files = new List<string>();
try
{
foreach (var filePath in Directory.EnumerateFiles(_logFolder))
{
if (MatchesLogFilePattern(Path.GetFileName(filePath)))
files.Add(filePath);
}
}
catch (DirectoryNotFoundException) { }
files.Sort(StringComparer.OrdinalIgnoreCase);
var files = EnumerateLogFiles();
if (FileIdentitiesAreDue(files)) ReconcileFileIdentities(files);
// Σ(file length − bookmark) after this pass — the tracker's queue depth.
long backlogBytes = 0;
@@ -94,6 +79,66 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
}
}
/// <summary>
/// All matching log files, sorted by name (archived files come before current).
/// M2: a SINGLE directory enumeration filtered in-memory against the patterns, instead
/// of one Directory.GetFiles(pattern) per LogFilePattern every 100 ms poll (~90 folder
/// enumerations/s → 1). The in-memory regex match is anchored, so it is also immune to
/// the Win32 "*.ext" 8.3 search-pattern quirk.
/// </summary>
private List<string> EnumerateLogFiles()
{
var files = new List<string>();
try
{
foreach (var filePath in Directory.EnumerateFiles(_logFolder))
{
if (MatchesLogFilePattern(Path.GetFileName(filePath)))
files.Add(filePath);
}
}
catch (DirectoryNotFoundException) { }
files.Sort(StringComparer.OrdinalIgnoreCase);
return files;
}
/// <summary>
/// On the first pass (a restart can have rolled logs over) and when a file has no known
/// identity yet, which is how a rolled-over archive first shows up. Nothing is opened in
/// the regular poll.
/// </summary>
private bool FileIdentitiesAreDue(List<string> files)
{
if (!_fileIdentitiesReconciled) return true;
foreach (var filePath in files)
{
if (!_positionTracker.HasIdentity(filePath)) return true;
}
return false;
}
/// <summary>
/// Lets every bookmark follow its file (see <see cref="LogFilePositionTracker.Reconcile"/>).
/// Reading a rolled-over log again would replay every app and script line in it: finished
/// apps fall back to Installing, and the subgraph line of an older app marks the app that is
/// running right now as skipped.
/// </summary>
private void ReconcileFileIdentities(List<string> files)
{
_fileIdentitiesReconciled = true;
var idByPath = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var filePath in files) idByPath[filePath] = FileIdentityProvider(filePath);
foreach (var move in _positionTracker.Reconcile(idByPath))
{
MoveFileContext(move.From, move.To);
_logger.Info($"ImeLogTracker: {Path.GetFileName(move.From)} rolled over to {Path.GetFileName(move.To)} - continuing at offset {move.Position} instead of reading it again");
}
_stateDirty = true;
}
/// <summary>
/// One pass over one file: the bytes behind the bookmark, on a multi-writer file preceded
/// by the overwrite check that may move the bookmark back first (see
@@ -104,6 +149,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
var fileInfo = new FileInfo(filePath);
if (!fileInfo.Exists) return 0;
// IME renamed the file after this pass listed the folder, or its archive sorts behind it
// (_IntuneManagementExtension.log). Either way the archive needs the bookmark before the
// read below resets it.
if (_positionTracker.IsBehindBookmark(filePath, fileInfo.Length))
ReconcileFileIdentities(EnumerateLogFiles());
// Captured BEFORE MarkChecked below stamps this pass. Growth measured
// against a previously observed state is what makes this pass's lines valid
// "written now" anchors — for the per-file measurement AND per-line anchoring.
@@ -616,6 +616,35 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
private void InvalidateInvocationMarkersFrom(string fileName, long offset) => TakeInvocationMarkersFrom(fileName, offset, out _);
/// <summary>
/// A log was rolled over: what the tracker knows about its bytes goes with them. The markers keep
/// <see cref="_lastPlatformMarker"/> alive, so the run in flight still owns its lines in the fresh file;
/// the ledger stays behind and empties, an archive is not checked for overwrites.
/// </summary>
private void MoveFileContext(string fromPath, string toPath)
{
var from = Path.GetFileName(fromPath);
var to = Path.GetFileName(toPath);
List<InvocationMarker> markers;
if (_invocationMarkers.TryGetValue(from, out markers))
{
_invocationMarkers.Remove(from);
_invocationMarkers[to] = markers;
}
if (_platformMarkerFiles.Contains(from)) _platformMarkerFiles.Add(to);
FileLedger ledger;
if (_ledgers.TryGetValue(from, out ledger)) ledger.Clear();
HeldTail held;
if (_heldTails.TryGetValue(fromPath, out held))
{
_heldTails.Remove(fromPath);
_heldTails[toPath] = held;
}
}
/// <summary>
/// An entry that reads unchanged after a rewind keeps its markers: gives back the set-aside markers at exactly
/// <paramref name="entryOffset"/>. Set-aside markers in front of it belonged to entries that changed and stay
@@ -88,6 +88,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
// system clock, ImeLogHost rewires it to the agent IClock, tests to their VirtualClock.
internal Func<DateTime> UtcNowProvider { get; set; } = () => DateTime.UtcNow;
// Which file a path holds; null when unreadable. Tests swap it to cover that path.
internal Func<string, string> FileIdentityProvider { get; set; } = ImeLogFileIdentity.TryRead;
// False until the bookmarks were checked against the disk once in this process.
private bool _fileIdentitiesReconciled;
/// <summary>
/// CMTrace timestamps from IME are emitted in UTC, but DateTimeKind may arrive as
/// Unspecified depending on the parser path. Normalize via SpecifyKind=Utc rather than
@@ -494,7 +500,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
public Action OnImeStarted { get; set; }
public Action<AppPackageState, AppInstallationState, AppInstallationState> OnAppStateChanged { get; set; }
public Action<string> OnPoliciesDiscovered { get; set; }
public Action OnAllAppsCompleted { get; set; }
public Action OnUserSessionCompleted { get; set; }
public Action<string> OnImeSessionChange { get; set; }
// Args: errorCode (may be empty when IME logged none), raw failure line excerpt.
@@ -954,13 +959,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
{
if (!Directory.Exists(_logFolder)) return;
var inputs = new List<ImeLogEraPreScan.ScanInput>();
var files = new List<string>();
foreach (var filePath in Directory.EnumerateFiles(_logFolder))
{
if (MatchesLogFilePattern(Path.GetFileName(filePath)))
files.Add(filePath);
}
files.Sort(StringComparer.OrdinalIgnoreCase);
var files = EnumerateLogFiles();
// First: the read below resets the bookmark of a file that shrank, and the archive
// that file moved to during the downtime still needs that bookmark.
ReconcileFileIdentities(files);
foreach (var filePath in files)
{
var info = new FileInfo(filePath);
@@ -994,9 +996,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
LoadState();
// Era anchors for the backlog the first pass is about to read (2026-09-04). Runs
// before the poll loop so every replayed line already finds its era; read-only on
// the position tracker. Replay tooling anchors nothing — its logs did not come from
// this device's bootstrap.
// before the poll loop so every replayed line already finds its era. Replay tooling
// anchors nothing — its logs did not come from this device's bootstrap.
if (!SimulationMode) PreScanEras();
_cts = new CancellationTokenSource();
@@ -1180,6 +1181,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
{
var fullPath = Path.Combine(_logFolder, kvp.Key);
_positionTracker.RestorePosition(fullPath, kvp.Value.Position, kvp.Value.LastKnownSize);
_positionTracker.RestoreFileId(fullPath, kvp.Value.FileId);
}
}
@@ -1346,7 +1348,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
state.FilePositions[fileName] = new FilePositionData
{
Position = kvp.Value.Position,
LastKnownSize = kvp.Value.LastKnownSize
LastKnownSize = kvp.Value.LastKnownSize,
FileId = _positionTracker.GetFileId(kvp.Key)
};
}
@@ -250,5 +250,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
{
public long Position { get; set; }
public long LastKnownSize { get; set; }
/// <summary>Which file the position belongs to (see <see cref="LogFilePositionTracker.Reconcile"/>); null on older state files.</summary>
public string FileId { get; set; }
}
}
@@ -13,12 +13,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
private readonly Dictionary<string, FilePositionState> _positions
= new Dictionary<string, FilePositionState>(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Gets the safe read position for a file, detecting rollover/truncation.
/// If the current file size is smaller than the stored position, the file
/// has been rotated - resets to 0 to read from beginning.
/// Returns 0 if no position has been recorded yet.
/// </summary>
// Which file each path held when last checked (see Reconcile). Apart from _positions: a file
// whose identity is known but which no pass looked at yet must still count as unseen for
// HasSeen. A null value means the identity could not be read.
private readonly Dictionary<string, string> _fileIds
= new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Whether this file was already observed in an earlier pass.
/// <para>
@@ -31,6 +31,112 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
/// </summary>
public bool HasSeen(string filePath) => _positions.ContainsKey(filePath);
/// <summary>Whether <see cref="Reconcile"/> already looked at this path.</summary>
public bool HasIdentity(string filePath) => _fileIds.ContainsKey(filePath);
/// <summary>The identity recorded for this path, or null.</summary>
public string GetFileId(string filePath)
{
string id;
return _fileIds.TryGetValue(filePath, out id) ? id : null;
}
/// <summary>Restores a persisted identity. <see cref="Reconcile"/> checks it against the disk on the next pass.</summary>
public void RestoreFileId(string filePath, string fileId)
{
if (fileId != null) _fileIds[filePath] = fileId;
}
/// <summary>True when the file is smaller than its bookmark: it was rolled over or truncated.</summary>
public bool IsBehindBookmark(string filePath, long currentFileSize)
{
FilePositionState state;
return _positions.TryGetValue(filePath, out state) && currentFileSize < state.Position;
}
/// <summary>
/// Brings the bookmarks in line with the files on disk. A bookmark belongs to a file, not to
/// a name: IME rolls a log by renaming it, so a known file under a new path keeps its
/// position and only the bytes behind it are still unread. The old path starts at 0 with
/// whatever file it holds next, and keeps its freshness reference because that file was
/// written since the last look. Returns the moves.
/// <list type="bullet">
/// <item>A file that is still at its old path as well (a hard link) moves nothing.</item>
/// <item>A path that holds another file with no bookmark to take over starts at 0, even
/// when the new file is longer than the old position.</item>
/// <item>An unreadable identity (null) changes nothing, the path keeps its bookmark.</item>
/// </list>
/// </summary>
public List<BookmarkMove> Reconcile(IDictionary<string, string> idByPath)
{
var moves = new List<BookmarkMove>();
var claimed = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var entry in idByPath)
{
if (entry.Value == null || GetFileId(entry.Key) == entry.Value) continue;
var holder = FindBookmarkLeftBehind(entry.Value, entry.Key, idByPath);
if (holder != null && claimed.Add(holder))
moves.Add(new BookmarkMove(holder, entry.Key, _positions[holder].Position));
}
// In two steps: a path can be source and target at once (X.log becomes _X.log while
// _X.log's own file was replaced).
var movedStates = new List<FilePositionState>();
foreach (var move in moves)
{
var state = _positions[move.From];
movedStates.Add(state);
_positions[move.From] = new FilePositionState { LastCheckedUtc = state.LastCheckedUtc };
_fileIds.Remove(move.From);
}
for (var i = 0; i < moves.Count; i++)
{
_positions[moves[i].To] = movedStates[i];
_fileIds[moves[i].To] = idByPath[moves[i].To];
}
foreach (var entry in idByPath)
{
if (entry.Value == null)
{
if (!_fileIds.ContainsKey(entry.Key)) _fileIds[entry.Key] = null;
continue;
}
string known;
FilePositionState state;
if (_fileIds.TryGetValue(entry.Key, out known) && known != null && known != entry.Value
&& _positions.TryGetValue(entry.Key, out state))
{
state.Position = 0;
state.LastKnownSize = 0;
}
_fileIds[entry.Key] = entry.Value;
}
return moves;
}
private string FindBookmarkLeftBehind(string fileId, string newPath, IDictionary<string, string> idByPath)
{
foreach (var known in _fileIds)
{
if (known.Value != fileId || string.Equals(known.Key, newPath, StringComparison.OrdinalIgnoreCase)) continue;
if (!_positions.ContainsKey(known.Key)) continue;
string stillThere;
if (idByPath.TryGetValue(known.Key, out stillThere) && stillThere == fileId) continue;
return known.Key;
}
return null;
}
/// <summary>
/// Gets the safe read position for a file, detecting rollover/truncation.
/// If the current file size is smaller than the stored position, the file
/// has been rotated - resets to 0 to read from beginning.
/// Returns 0 if no position has been recorded yet.
/// </summary>
public long GetSafePosition(string filePath, long currentFileSize)
{
FilePositionState state;
@@ -144,6 +250,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
}
}
/// <summary>One bookmark that followed its file to a new path.</summary>
public sealed class BookmarkMove
{
public BookmarkMove(string from, string to, long position)
{
From = from;
To = to;
Position = position;
}
public string From { get; }
public string To { get; }
public long Position { get; }
}
public class FilePositionState
{
public long Position { get; set; }
@@ -9,6 +9,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
/// Win32 file-handle declarations for <c>PinnedSourceFolder</c>: open a file or directory
/// without following a reparse point at its final component, and ask the handle where it
/// really points (<c>GetFinalPathNameByHandle</c>) instead of trusting the path string.
/// <c>ImeLogFileIdentity</c> reads a file's identity through the same declarations.
/// </summary>
internal static class FileNativeMethods
{
@@ -18,6 +19,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
public const uint FILE_SHARE_READ = 0x1;
public const uint FILE_SHARE_WRITE = 0x2;
public const uint FILE_SHARE_DELETE = 0x4;
public const uint OPEN_EXISTING = 3;
@@ -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);
}
}
@@ -11,6 +11,7 @@ using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
using AutopilotMonitor.Agent.V2.Core.Orchestration;
using AutopilotMonitor.Agent.V2.Core.Security;
using AutopilotMonitor.Agent.V2.Core.Transport;
using AutopilotMonitor.DecisionCore.Engine;
using AutopilotMonitor.DecisionCore.Signals;
using AutopilotMonitor.Shared;
@@ -113,34 +114,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
{
try
{
var data = new Dictionary<string, object>();
using (var key = Registry.CurrentUser.OpenSubKey(@"Software\Microsoft\Windows\CurrentVersion\Internet Settings"))
{
if (key != null)
{
data["proxyEnabled"] = key.GetValue("ProxyEnable")?.ToString() == "1";
data["proxyServer"] = key.GetValue("ProxyServer")?.ToString();
data["proxyOverride"] = key.GetValue("ProxyOverride")?.ToString();
data["autoConfigUrl"] = key.GetValue("AutoConfigURL")?.ToString();
}
}
// Also check WinHTTP proxy
using (var key = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Windows\CurrentVersion\Internet Settings\Connections"))
{
data["winHttpProxyConfigured"] = key?.GetValue("WinHttpSettings") != null;
}
var proxyType = "Direct";
if (data.ContainsKey("proxyEnabled") && (bool)data["proxyEnabled"])
proxyType = "Proxy";
else if (data.ContainsKey("autoConfigUrl") && data["autoConfigUrl"] != null)
proxyType = "PAC";
data["proxyType"] = proxyType;
EmitDeviceInfoEvent(Constants.EventTypes.ProxyConfiguration, $"Proxy configuration: {proxyType}", data);
var settings = ProxySettingsReader.Read();
EmitDeviceInfoEvent(Constants.EventTypes.ProxyConfiguration, $"Proxy configuration: {settings.ProxyType}", settings.ToEventData());
}
catch (Exception ex)
{
@@ -1004,7 +979,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
var fingerprint = Persistence.StartupEventGate.ComputeFingerprint(data, excludedFields);
if (_startupGate.ShouldEmit(eventType, fingerprint)) return true;
_logger.Debug($"EnrollmentTracker: '{eventType}' suppressed — payload unchanged since last emission (restart dedup)");
_logger.Debug($"EnrollmentTracker: '{eventType}' suppressed — payload unchanged since last emission (restart dedup)", repeatKey: "DeviceInfo.unchanged:" + eventType);
return false;
}
@@ -160,6 +160,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
// Suppress events when network hasn't actually changed (DHCP renewals, OS chatter)
if (previousState != null && previousState.IsSameNetwork(newState))
{
// Every occurrence on purpose, no repeatKey: when it happened is the information
// (did anything move on the network when uploads stopped?).
_logger.Debug("NetworkChangeDetector: network address changed but same logical network — suppressing");
return;
}
@@ -194,7 +194,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
catch (Exception ex)
{
// Swallow — distress channel must never cascade failures into the caller
_logger?.Debug($"[DistressChannel] Failed to send report: {ex.Message}");
_logger?.Debug($"[DistressChannel] Failed to send report: {TransportErrorCodes.DescribeSendFailure(ex)}");
}
}
@@ -180,7 +180,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
catch (Exception ex)
{
// Swallow — emergency channel must never cascade failures into the caller
_logger?.Debug($"[EmergencyChannel] Failed to send report: {ex.Message}");
_logger?.Debug($"[EmergencyChannel] Failed to send report: {TransportErrorCodes.DescribeSendFailure(ex)}");
}
if (!delivered)
@@ -1,6 +1,8 @@
using System;
using System.ComponentModel;
using System.Globalization;
using System.Net;
using System.Net.Sockets;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
{
@@ -31,6 +33,38 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
return code == null ? ex.Message : $"{ex.Message} (win32 {code})";
}
/// <summary>
/// What a failed send hit, in one line: the <see cref="WebException.Status"/>, the socket error
/// or native code, and the innermost message. On .NET Framework a socket error's message names
/// the endpoint it tried, which tells a backend that does not answer from a proxy that does not.
/// </summary>
public static string DescribeSendFailure(Exception ex)
{
if (ex == null) return null;
WebExceptionStatus? status = null;
SocketError? socketError = null;
var innermost = ex;
for (var current = ex; current != null; current = current.InnerException)
{
innermost = current;
if (current is WebException web && status == null) status = web.Status;
if (current is SocketException socket) socketError = socket.SocketErrorCode;
}
var code = socketError.HasValue ? $"socket={socketError.Value}" : NativeCodePart(ex);
var details = status.HasValue
? (code == null ? $"status={status.Value}" : $"status={status.Value}, {code}")
: code;
return details == null ? innermost.Message : $"{details}: {innermost.Message}";
}
private static string NativeCodePart(Exception ex)
{
var code = InnermostNativeCode(ex);
return code == null ? null : $"win32={code}";
}
/// <summary>Eight-digit hex with <c>0x</c>: <c>-2146893018</c> → <c>0x80090326</c>.</summary>
public static string Hex(int value) => "0x" + value.ToString("X8", CultureInfo.InvariantCulture);
}
@@ -96,6 +96,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
/// </summary>
private readonly DateTime? _previousRunLastAliveUtc;
private readonly Transport.ProxyObserver? _proxyObserver;
public DefaultComponentFactory(
AgentConfiguration agentConfig,
AgentConfigResponse remoteConfig,
@@ -105,7 +107,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
Persistence.StartupEventGate? startupEventGate = null,
DateTime? previousBootUtc = null,
string? previousExitType = null,
DateTime? previousRunLastAliveUtc = null)
DateTime? previousRunLastAliveUtc = null,
Transport.ProxyObserver? proxyObserver = null)
{
_agentConfig = agentConfig ?? throw new ArgumentNullException(nameof(agentConfig));
_remoteConfig = remoteConfig ?? throw new ArgumentNullException(nameof(remoteConfig));
@@ -116,6 +119,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
_previousBootUtc = previousBootUtc;
_previousExitType = previousExitType;
_previousRunLastAliveUtc = previousRunLastAliveUtc;
_proxyObserver = proxyObserver;
}
/// <summary>
@@ -666,6 +670,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
clock: clock,
logger: logger));
// The exact time of a proxy change for the agent log and the timeline. Always on: when
// this matters, nothing else from the device reaches the backend.
if (_proxyObserver != null)
{
hosts.Add(new ProxySettingsHost(
observer: _proxyObserver,
ingress: ingress,
clock: clock,
logger: logger));
}
// Live AC/battery watcher — WMI Win32_PowerManagementEvent push (no polling), emits
// power_state_change on AC↔battery transitions and 50/30/15 % downward threshold
// crossings. Deliberately NOT under PeriodicCollectorLifecycleHost: it is push-based
@@ -0,0 +1,137 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Threading;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
using AutopilotMonitor.Agent.V2.Core.Transport;
using AutopilotMonitor.DecisionCore.Engine;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
using Microsoft.Win32;
namespace AutopilotMonitor.Agent.V2.Core.Orchestration
{
/// <summary>
/// Tells <see cref="ProxyObserver"/> the moment proxy settings change, so the log has the exact
/// time. A watcher that fails costs only that time: the observer compares again on every failed
/// upload.
/// </summary>
internal sealed class ProxySettingsHost : ICollectorHost
{
private const string Source = "ProxySettingsHost";
private const int DebounceMs = 1000;
// The whole subtree: "Connections" does not exist yet in an account nothing has written to.
// HKCU of a SYSTEM process is HKU\S-1-5-18.
private const string AccountSettingsKey = @"Software\Microsoft\Windows\CurrentVersion\Internet Settings";
// Holds WinHttpSettings, which netsh winhttp writes.
private const string MachineConnectionsKey = @"SOFTWARE\Microsoft\Windows\CurrentVersion\Internet Settings\Connections";
private const RegistryNativeMethods.RegChangeNotifyFilter Filter =
RegistryNativeMethods.RegChangeNotifyFilter.Name |
RegistryNativeMethods.RegChangeNotifyFilter.LastSet;
private readonly ProxyObserver _observer;
private readonly InformationalEventPost _post;
private readonly AgentLogger _logger;
private readonly List<RegistryWatcher> _watchers = new List<RegistryWatcher>();
private readonly Timer _debounce;
private int _started;
private int _stopped;
public string Name => "ProxySettingsWatcher";
public ProxySettingsHost(
ProxyObserver observer,
ISignalIngressSink ingress,
IClock clock,
AgentLogger logger)
{
if (ingress == null) throw new ArgumentNullException(nameof(ingress));
if (clock == null) throw new ArgumentNullException(nameof(clock));
_observer = observer ?? throw new ArgumentNullException(nameof(observer));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_post = new InformationalEventPost(ingress, clock);
_debounce = new Timer(_ => OnDebounceElapsed(), null, Timeout.Infinite, Timeout.Infinite);
AddWatcher(RegistryHive.CurrentUser, AccountSettingsKey, watchSubtree: true, "account settings");
AddWatcher(RegistryHive.LocalMachine, MachineConnectionsKey, watchSubtree: false, "WinHTTP settings");
}
private void AddWatcher(RegistryHive hive, string subKey, bool watchSubtree, string label)
{
var watcher = new RegistryWatcher(hive, subKey, watchSubtree, RegistryView.Registry64, Filter);
watcher.Changed += (_, _) => _debounce.Change(DebounceMs, Timeout.Infinite);
watcher.Error += (_, ex) => _logger.Warning($"ProxySettingsHost: {label} watcher error - {ex.GetType().Name}: {ex.Message}");
_watchers.Add(watcher);
}
public void Start()
{
if (Interlocked.Exchange(ref _started, 1) == 1) return;
_observer.SettingsChanged += OnSettingsChanged;
foreach (var watcher in _watchers)
{
try { watcher.Start(); }
catch (Exception ex) { _logger.Warning($"ProxySettingsHost: a watcher failed to start: {ex.Message}"); }
}
// Whatever moved between the baseline and the start of the watchers.
OnDebounceElapsed();
}
public void Stop()
{
if (Interlocked.Exchange(ref _stopped, 1) == 1) return;
DisposeWatchers();
_observer.SettingsChanged -= OnSettingsChanged;
if (Volatile.Read(ref _started) == 1) _observer.LogOpenFailures();
}
public void Dispose()
{
Stop();
_debounce.Dispose();
}
private void DisposeWatchers()
{
foreach (var watcher in _watchers)
{
try { watcher.Dispose(); }
catch (Exception ex) { _logger.Debug($"ProxySettingsHost: disposing a watcher threw: {ex.Message}"); }
}
_watchers.Clear();
}
private void OnDebounceElapsed()
{
_logger.Trace("ProxySettingsHost: checking the proxy settings.");
try { _observer.CheckForChange(); }
catch (Exception ex) { _logger.Warning($"ProxySettingsHost: checking the proxy settings failed: {ex.Message}"); }
}
/// <summary>
/// Waits in the spool if uploads are down, so the timeline explains the gap once they work
/// again. The portal shows the newest event of this type.
/// </summary>
private void OnSettingsChanged(ProxySettings settings, string what, string route)
{
var data = settings.ToEventData();
data["trigger"] = "changed";
data["change"] = what;
data["route"] = route;
_post.Emit(
eventType: Constants.EventTypes.ProxyConfiguration,
source: Source,
message: $"Proxy settings changed: {what}",
severity: EventSeverity.Warning,
typedPayload: data);
}
}
}
@@ -0,0 +1,287 @@
#nullable enable
using System;
using System.Globalization;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.DecisionCore.Engine;
namespace AutopilotMonitor.Agent.V2.Core.Transport
{
/// <summary>
/// Makes the agent log say which way uploads go and what happened to it. Something on the device
/// can change the proxy settings of the agent's account; .NET follows at once, and from then on
/// no channel may reach the backend, so the log on the device is the only witness.
/// <para>
/// Compares with what held at the start instead of judging a value: "no proxy" is the right
/// state on one network and the broken one on the next. Reads on failures and on registry
/// changes, never by polling. Nothing here throws into the upload path.
/// </para>
/// </summary>
public sealed class ProxyObserver
{
internal const string RouteUnknown = "unknown";
private static readonly TimeSpan DefaultRouteBound = TimeSpan.FromSeconds(3);
private readonly Func<ProxySettings> _readSettings;
private readonly Func<string> _readRoute;
private readonly IClock _clock;
private readonly AgentLogger _logger;
private readonly TimeSpan _routeBound;
private readonly object _sync = new object();
private ProxySettings? _current;
private string _lastRoute = RouteUnknown;
private Task<string>? _routeRead;
private DateTime? _uploadChangeAtUtc;
private string? _uploadChange;
private DateTime? _failingSinceUtc;
private int _failedAttempts;
private DateTime? _lastSuccessUtc;
/// <summary>Settings now, what changed, route now. Raised outside the lock, on the caller's thread.</summary>
internal event Action<ProxySettings, string, string>? SettingsChanged;
public ProxyObserver(string baseUrl, AgentLogger logger, IClock clock)
: this(
ProxySettingsReader.Read,
RouteReaderFor(baseUrl),
logger,
clock,
DefaultRouteBound)
{
}
internal ProxyObserver(
Func<ProxySettings> readSettings,
Func<string> readRoute,
AgentLogger logger,
IClock clock,
TimeSpan routeBound)
{
_readSettings = readSettings ?? throw new ArgumentNullException(nameof(readSettings));
_readRoute = readRoute ?? throw new ArgumentNullException(nameof(readRoute));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
_routeBound = routeBound;
}
private static Func<string> RouteReaderFor(string baseUrl)
{
if (!Uri.TryCreate(baseUrl, UriKind.Absolute, out var target)) return () => RouteUnknown;
return () => ProxySettingsReader.ReadRoute(target);
}
/// <summary>Logs the state to compare against. Runs before the first request of the agent.</summary>
public void CaptureBaseline()
{
var settings = ReadSettingsSafe();
var route = ReadRouteBounded();
lock (_sync)
{
if (_current == null && !settings.IsUnreadable) _current = settings;
_lastRoute = route;
}
// Same text on a rebuild of the clients after an endpoint migration: the repeat goes to Trace.
_logger.Info($"ProxyObserver: start - {settings.DescribeAll()}; route to backend: {route}", repeatKey: "ProxyObserver.baseline");
}
/// <summary>Re-reads the settings and reports a change of content once, whoever notices it first.</summary>
public void CheckForChange()
{
var settings = ReadSettingsSafe();
if (settings.IsUnreadable) return;
ProxySettings previous;
var affectsUploads = false;
lock (_sync)
{
if (_current == null)
{
_current = settings;
return;
}
if (_current.Equals(settings)) return;
previous = _current;
_current = settings;
if (settings.DiffersForUploadsFrom(previous))
{
affectsUploads = true;
_uploadChangeAtUtc = _clock.UtcNow;
_uploadChange = settings.DescribeUploadChangeFrom(previous);
}
}
var route = ReadRouteBounded();
string routeBefore;
lock (_sync)
{
routeBefore = _lastRoute;
_lastRoute = route;
}
var what = settings.DescribeChangeFrom(previous);
_logger.Warning(affectsUploads
? $"ProxyObserver: proxy settings changed - {what}; route to backend: {(routeBefore == route ? route : $"{routeBefore} -> {route}")}. " +
"The agent's uploads follow these settings. Check with: bitsadmin /util /getieproxy localsystem"
: $"ProxyObserver: proxy settings changed - {what}. The agent's uploads do not use WinHTTP, services that rely on its default do.");
try
{
SettingsChanged?.Invoke(settings, what, route);
}
catch (Exception ex)
{
_logger.Debug($"ProxyObserver: change handler threw: {ex.Message}");
}
}
/// <summary>The next hop to the backend: <c>direct</c>, <c>host:port</c> or <c>unknown</c>.</summary>
public string RouteText() => ReadRouteBounded();
/// <summary>
/// An upload did not reach the backend. The first of a series says what the reader needs to
/// know: when it began and whether the proxy settings moved before that.
/// </summary>
public void OnSendFailed(TimeSpan attemptDuration)
{
// Also catches a change the registry watcher did not report.
CheckForChange();
DateTime since;
DateTime? lastSuccess;
DateTime? changedAt;
string? change;
string? current;
lock (_sync)
{
_failedAttempts++;
if (_failingSinceUtc != null) return;
since = _clock.UtcNow - attemptDuration;
_failingSinceUtc = since;
lastSuccess = _lastSuccessUtc;
changedAt = _uploadChangeAtUtc;
change = _uploadChange;
current = _current?.Describe();
}
var route = ReadRouteBounded();
var lastUpload = lastSuccess == null ? "No successful upload yet." : $"Last successful upload {Time(lastSuccess.Value)}.";
if (changedAt != null && (lastSuccess == null || changedAt > lastSuccess))
{
_logger.Warning(
$"ProxyObserver: uploads are failing since {Time(since)}, {Span(since - changedAt.Value)} after the proxy settings changed at {Time(changedAt.Value)} ({change}). " +
$"Route to backend: {route}. {lastUpload}");
}
else
{
var unchangedSince = changedAt == null ? "start" : "the last successful upload";
_logger.Warning(
$"ProxyObserver: uploads are failing since {Time(since)}. Proxy settings of this account unchanged since {unchangedSince}: {current ?? RouteUnknown}. " +
$"Route to backend: {route}. {lastUpload}");
}
}
public void OnSendSucceeded()
{
DateTime since;
int attempts;
lock (_sync)
{
_lastSuccessUtc = _clock.UtcNow;
if (_failingSinceUtc == null) return;
since = _failingSinceUtc.Value;
attempts = _failedAttempts;
_failingSinceUtc = null;
_failedAttempts = 0;
}
CheckForChange();
_logger.Info($"ProxyObserver: uploads work again after {attempts} failed attempt(s) over {Span(_clock.UtcNow - since)}; route to backend: {ReadRouteBounded()}.");
}
/// <summary>The end of the log is read first, so a run that ends while failing says so there.</summary>
public void LogOpenFailures()
{
DateTime since;
int attempts;
DateTime? lastSuccess;
DateTime? changedAt;
string? change;
string? current;
lock (_sync)
{
if (_failingSinceUtc == null) return;
since = _failingSinceUtc.Value;
attempts = _failedAttempts;
lastSuccess = _lastSuccessUtc;
changedAt = _uploadChangeAtUtc;
change = _uploadChange;
current = _current?.Describe();
}
var lastUpload = lastSuccess == null ? "no successful upload" : $"last success {Time(lastSuccess.Value)}";
var settings = changedAt == null
? $"Proxy settings of this account unchanged since start: {current ?? RouteUnknown}."
: $"Proxy settings changed at {Time(changedAt.Value)} ({change}).";
_logger.Warning(
$"ProxyObserver: stopping with uploads still failing: {attempts} failed attempt(s) since {Time(since)}, {lastUpload}, route to backend: {ReadRouteBounded()}. {settings}");
}
private ProxySettings ReadSettingsSafe()
{
try
{
var settings = _readSettings();
if (settings.IsUnreadable) _logger.Debug($"ProxyObserver: proxy settings {settings.Describe()}", repeatKey: "ProxyObserver.unreadable");
return settings;
}
catch (Exception ex)
{
_logger.Debug($"ProxyObserver: reading the proxy settings threw: {ex.Message}", repeatKey: "ProxyObserver.readThrew");
return ProxySettings.Unreadable(-1);
}
}
/// <summary>
/// Auto-detect can hang and must hold up neither the start nor an upload. A read still under
/// way is joined, so a hanging one does not pile up pool threads.
/// </summary>
private string ReadRouteBounded()
{
Task<string> read;
lock (_sync)
{
if (_routeRead == null || _routeRead.IsCompleted) _routeRead = Task.Run(ReadRouteSafe);
read = _routeRead;
}
return read.Wait(_routeBound) ? read.Result : RouteUnknown;
}
private string ReadRouteSafe()
{
try
{
return _readRoute();
}
catch (Exception ex)
{
_logger.Debug($"ProxyObserver: reading the route threw: {ex.Message}", repeatKey: "ProxyObserver.routeThrew");
return RouteUnknown;
}
}
private static string Time(DateTime utc) => utc.ToString("HH:mm:ss", CultureInfo.InvariantCulture);
private static string Span(TimeSpan span)
{
var seconds = Math.Max(0, (long)span.TotalSeconds);
return seconds < 120 ? $"{seconds} s" : $"{seconds / 60} min";
}
}
}
@@ -0,0 +1,131 @@
using System;
using System.Collections.Generic;
namespace AutopilotMonitor.Agent.V2.Core.Transport
{
/// <summary>
/// The proxy settings Windows holds for the account this process runs as (what
/// <c>bitsadmin /util /getieproxy localsystem</c> shows for SYSTEM and what .NET uses), plus the
/// WinHTTP proxy (<c>netsh winhttp show proxy</c>). Compared by content, because WinINET rewrites
/// its settings blob without changing anything. The values end up in logs and events, so
/// credentials and PAC query strings are kept out on the way in.
/// </summary>
internal sealed class ProxySettings : IEquatable<ProxySettings>
{
internal const string CredentialsHidden = "(value with credentials, not shown)";
public bool AutoDetect { get; }
public string PacUrl { get; }
public string Proxy { get; }
public string Bypass { get; }
public string WinHttpProxy { get; }
/// <summary>Win32 error of a failed read; null when the settings were read.</summary>
public int? ReadError { get; }
public bool IsUnreadable => ReadError != null;
public ProxySettings(bool autoDetect, string pacUrl, string proxy, string bypass, string winHttpProxy)
{
AutoDetect = autoDetect;
PacUrl = SanitizeUrl(pacUrl);
Proxy = HideCredentials(proxy);
Bypass = EmptyToNull(bypass);
WinHttpProxy = HideCredentials(winHttpProxy);
}
private ProxySettings(int readError)
{
ReadError = readError;
}
public static ProxySettings Unreadable(int win32Error) => new ProxySettings(win32Error);
/// <summary>Kept in step with ANALYZE-CORR-003, which matches <c>Proxy|PAC</c>.</summary>
public string ProxyType =>
IsUnreadable ? "Unknown"
: Proxy != null ? "Proxy"
: PacUrl != null ? "PAC"
: AutoDetect ? "AutoDetect"
: "Direct";
public string Describe()
{
if (IsUnreadable) return $"unreadable (Win32 error {ReadError})";
var parts = new List<string>(3);
if (AutoDetect) parts.Add("auto-detect");
if (PacUrl != null) parts.Add($"PAC {PacUrl}");
if (Proxy != null) parts.Add($"proxy {Proxy}");
return parts.Count == 0 ? "no proxy" : string.Join(", ", parts);
}
public string DescribeWinHttp() => WinHttpProxy == null ? "direct" : $"proxy {WinHttpProxy}";
public string DescribeAll() => $"IE settings of this account: {Describe()}; WinHTTP: {DescribeWinHttp()}";
/// <summary>The account's own settings, which the agent's uploads follow; WinHTTP is apart.</summary>
public bool DiffersForUploadsFrom(ProxySettings other) =>
AutoDetect != other.AutoDetect
|| PacUrl != other.PacUrl
|| Proxy != other.Proxy
|| Bypass != other.Bypass;
public string DescribeUploadChangeFrom(ProxySettings previous) =>
Describe() == previous.Describe()
? "IE settings of this account: proxy bypass list changed"
: $"IE settings of this account: {previous.Describe()} -> {Describe()}";
public string DescribeChangeFrom(ProxySettings previous)
{
var parts = new List<string>(2);
if (DiffersForUploadsFrom(previous)) parts.Add(DescribeUploadChangeFrom(previous));
if (WinHttpProxy != previous.WinHttpProxy) parts.Add($"WinHTTP: {previous.DescribeWinHttp()} -> {DescribeWinHttp()}");
return string.Join("; ", parts);
}
/// <summary>Payload of the <c>proxy_configuration</c> event.</summary>
public Dictionary<string, object> ToEventData() => new Dictionary<string, object>
{
{ "proxyType", ProxyType },
{ "autoDetect", AutoDetect },
{ "proxyEnabled", Proxy != null },
{ "proxyServer", Proxy },
{ "proxyOverride", Bypass },
{ "autoConfigUrl", PacUrl },
{ "winHttpProxyConfigured", WinHttpProxy != null },
{ "winHttpProxy", WinHttpProxy },
};
public bool Equals(ProxySettings other) =>
other != null
&& ReadError == other.ReadError
&& !DiffersForUploadsFrom(other)
&& WinHttpProxy == other.WinHttpProxy;
public override bool Equals(object obj) => Equals(obj as ProxySettings);
public override int GetHashCode() => (AutoDetect, PacUrl, Proxy, Bypass, WinHttpProxy, ReadError).GetHashCode();
private static string EmptyToNull(string value) => string.IsNullOrEmpty(value) ? null : value;
/// <summary>Scheme, host, port and path. A query string can carry a key; an unparsable URL is not repeated.</summary>
private static string SanitizeUrl(string url)
{
if (string.IsNullOrEmpty(url)) return null;
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) || string.IsNullOrEmpty(uri.Host)) return "(unparsable URL)";
var port = uri.IsDefaultPort ? string.Empty : $":{uri.Port}";
return $"{uri.Scheme}://{uri.Host}{port}{uri.AbsolutePath}";
}
/// <summary>
/// A proxy string has no defined form for credentials and a password can hold <c>=</c>,
/// <c>;</c> or <c>://</c>, so a value with an <c>@</c> is replaced as a whole, not taken apart.
/// </summary>
private static string HideCredentials(string proxy)
{
if (string.IsNullOrEmpty(proxy)) return null;
return proxy.IndexOf('@') < 0 ? proxy : CredentialsHidden;
}
}
}
@@ -0,0 +1,67 @@
using System;
using System.Net;
using System.Runtime.InteropServices;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
namespace AutopilotMonitor.Agent.V2.Core.Transport
{
internal static class ProxySettingsReader
{
public const string RouteDirect = "direct";
/// <summary>Reads the registry through WinHTTP, no network. Does not throw on a failed read.</summary>
public static ProxySettings Read()
{
var ie = new WinHttpNativeMethods.WINHTTP_CURRENT_USER_IE_PROXY_CONFIG();
var winHttp = new WinHttpNativeMethods.WINHTTP_PROXY_INFO();
try
{
if (!WinHttpNativeMethods.WinHttpGetIEProxyConfigForCurrentUser(ref ie))
{
var error = Marshal.GetLastWin32Error();
// The account has never had settings written, so nothing configures a proxy.
if (error != WinHttpNativeMethods.ERROR_FILE_NOT_FOUND) return ProxySettings.Unreadable(error);
}
if (!WinHttpNativeMethods.WinHttpGetDefaultProxyConfiguration(ref winHttp))
{
return ProxySettings.Unreadable(Marshal.GetLastWin32Error());
}
return new ProxySettings(
autoDetect: ie.fAutoDetect,
pacUrl: Marshal.PtrToStringUni(ie.lpszAutoConfigUrl),
proxy: Marshal.PtrToStringUni(ie.lpszProxy),
bypass: Marshal.PtrToStringUni(ie.lpszProxyBypass),
winHttpProxy: winHttp.dwAccessType == WinHttpNativeMethods.WINHTTP_ACCESS_TYPE_NAMED_PROXY
? Marshal.PtrToStringUni(winHttp.lpszProxy)
: null);
}
finally
{
Free(ie.lpszAutoConfigUrl);
Free(ie.lpszProxy);
Free(ie.lpszProxyBypass);
Free(winHttp.lpszProxy);
Free(winHttp.lpszProxyBypass);
}
}
/// <summary>
/// The next hop .NET chooses for <paramref name="target"/>: the same decision an
/// <c>HttpClientHandler</c> with default settings takes, PAC and auto-detect evaluated.
/// Auto-detect can take seconds on a network without WPAD.
/// </summary>
public static string ReadRoute(Uri target)
{
var hop = WebRequest.DefaultWebProxy?.GetProxy(target);
if (hop == null || string.Equals(hop.Host, target.Host, StringComparison.OrdinalIgnoreCase)) return RouteDirect;
return hop.IsDefaultPort ? hop.Host : $"{hop.Host}:{hop.Port}";
}
private static void Free(IntPtr memory)
{
if (memory != IntPtr.Zero) WinHttpNativeMethods.GlobalFree(memory);
}
}
}
@@ -52,6 +52,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
private readonly Logging.AgentLogger? _logger;
private readonly ServerCertificateObserver? _serverCertificates;
private readonly NetworkMetrics? _networkMetrics;
private readonly ProxyObserver? _proxy;
/// <param name="serverCertificates">
/// Observer installed on the <paramref name="httpClient"/>'s handler; its issuer becomes
@@ -60,6 +61,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
/// <param name="networkMetrics">
/// Counts 2xx answers without the correlation echo (<c>agent_metrics_snapshot</c>). Null = not counted.
/// </param>
/// <param name="proxy">
/// Names the route in the failure lines and hears how each upload ended. Null = neither.
/// </param>
public BackendTelemetryUploader(
HttpClient httpClient,
string baseUrl,
@@ -72,7 +76,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
AuthFailureTracker? authFailureTracker = null,
Logging.AgentLogger? logger = null,
ServerCertificateObserver? serverCertificates = null,
NetworkMetrics? networkMetrics = null)
NetworkMetrics? networkMetrics = null,
ProxyObserver? proxy = null)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
if (string.IsNullOrEmpty(baseUrl)) throw new ArgumentException("BaseUrl is mandatory.", nameof(baseUrl));
@@ -89,6 +94,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
_logger = logger;
_serverCertificates = serverCertificates;
_networkMetrics = networkMetrics;
_proxy = proxy;
}
public async Task<UploadResult> UploadBatchAsync(
@@ -153,7 +159,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
catch (TaskCanceledException ex)
{
sw.Stop();
_logger?.Warning($"BackendTelemetryUploader: ingest TIMEOUT after {sw.ElapsedMilliseconds}ms (corr={correlationId}, items={items.Count}): {ex.Message}");
_logger?.Warning($"BackendTelemetryUploader: ingest TIMEOUT after {sw.ElapsedMilliseconds}ms (corr={correlationId}, items={items.Count}{RouteSuffix()}): {ex.Message}");
_proxy?.OnSendFailed(sw.Elapsed);
// HttpClient timeout (not caller cancellation) surfaces as TaskCanceledException
// with cancellationToken.IsCancellationRequested == false.
return UploadResult.Transient($"timeout: {ex.Message}");
@@ -161,9 +168,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
catch (HttpRequestException ex)
{
sw.Stop();
_logger?.Warning($"BackendTelemetryUploader: ingest NETWORK fail after {sw.ElapsedMilliseconds}ms (corr={correlationId}, items={items.Count}): {ex.Message}");
var cause = TransportErrorCodes.DescribeSendFailure(ex);
_logger?.Warning($"BackendTelemetryUploader: ingest NETWORK fail after {sw.ElapsedMilliseconds}ms (corr={correlationId}, items={items.Count}{RouteSuffix()}): {cause}");
_proxy?.OnSendFailed(sw.Elapsed);
// DNS failure, socket error, etc. — network-layer is retryable.
return UploadResult.Transient($"network: {ex.Message}");
return UploadResult.Transient($"network: {cause}");
}
using (response)
@@ -174,6 +183,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
if (result.Success)
{
_logger?.Verbose($"BackendTelemetryUploader: ingest OK (corr={correlationId}, items={items.Count}, firstId={items[0].TelemetryItemId}, lastId={items[items.Count - 1].TelemetryItemId}, bytes={compressedBody.Length}, durationMs={sw.ElapsedMilliseconds}, status={(int)response.StatusCode}).");
_proxy?.OnSendSucceeded();
}
else if (result.IsTransient)
{
@@ -188,7 +198,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
$"BackendTelemetryUploader: ingest INTERCEPTED — the answer did not come from the backend " +
$"(corr={correlationId}, items={items.Count}, durationMs={sw.ElapsedMilliseconds}, status={interception.StatusCode}, " +
$"reason={interception.ForeignReason}, pageTitle=\"{interception.PageTitle ?? "(none)"}\", " +
$"serverCertIssuer=\"{interception.ServerCertIssuer ?? "(unknown)"}\").");
$"serverCertIssuer=\"{interception.ServerCertIssuer ?? "(unknown)"}\"{RouteSuffix()}).");
_proxy?.OnSendFailed(sw.Elapsed);
}
else if (result.IsSessionGone)
{
@@ -204,6 +215,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
}
}
private string RouteSuffix() => _proxy == null ? string.Empty : $", route={_proxy.RouteText()}";
private async Task<UploadResult> MapResponseAsync(HttpResponseMessage response, string correlationId)
{
var statusCode = (int)response.StatusCode;
@@ -99,7 +99,8 @@ namespace AutopilotMonitor.Agent.V2.Runtime
// Measured, not guessed: the reducer snapshot is rewritten on every decision step,
// so its mtime is the last moment the previous run is known to have been alive —
// and unlike LastBootUtc it exists for every exit type, exception_crash included.
previousRunLastAliveUtc: DefaultComponentFactory.ReadPreviousRunLastAliveUtc(stateSubdir));
previousRunLastAliveUtc: DefaultComponentFactory.ReadPreviousRunLastAliveUtc(stateSubdir),
proxyObserver: auth.ProxyObserver);
var whiteGloveSealingPatternIds = (System.Collections.Generic.IReadOnlyCollection<string>)remoteConfig.WhiteGloveSealingPatternIds
?? Array.Empty<string>();
@@ -52,6 +52,10 @@ namespace AutopilotMonitor.Agent.V2.Runtime
// outbound call (legacy BackendApiClient calls + dominant /api/agent/telemetry POSTs).
var networkMetrics = new NetworkMetrics();
// Before the first request, so a registration that fails has the route in the log too.
var proxyObserver = new ProxyObserver(agentConfig.ApiBaseUrl, logger, SystemClock.Instance);
proxyObserver.CaptureBaseline();
var hardware = HardwareInfo.GetHardwareInfo(logger);
// Resolve the MDM client cert when cert-mode is on. Cert-missing is non-fatal here:
@@ -150,6 +154,7 @@ namespace AutopilotMonitor.Agent.V2.Runtime
emergencyReporter: emergencyReporter,
authFailureTracker: authFailureTracker,
serverCertificates: serverCertificates,
proxyObserver: proxyObserver,
clientCertificateThumbprint: clientCertificate?.Thumbprint);
}
@@ -201,7 +206,8 @@ namespace AutopilotMonitor.Agent.V2.Runtime
authFailureTracker: auth.AuthFailureTracker,
logger: logger, // Plan §5 Fix 5 — upload-cadence logging
serverCertificates: auth.ServerCertificates,
networkMetrics: auth.NetworkMetrics);
networkMetrics: auth.NetworkMetrics,
proxy: auth.ProxyObserver);
}
catch (Exception ex)
{
@@ -291,6 +297,9 @@ namespace AutopilotMonitor.Agent.V2.Runtime
/// </summary>
public ServerCertificateObserver ServerCertificates { get; }
/// <summary>Shared by the telemetry uploader built in Phase 5 and the collector host.</summary>
public ProxyObserver ProxyObserver { get; }
/// <summary>
/// Thumbprint of the client certificate this run uses; null without one. Kept beside a
/// failed run's record so the next successful start can tell whether the certificate changed.
@@ -308,6 +317,7 @@ namespace AutopilotMonitor.Agent.V2.Runtime
EmergencyReporter emergencyReporter,
AuthFailureTracker authFailureTracker,
ServerCertificateObserver serverCertificates,
ProxyObserver proxyObserver,
string clientCertificateThumbprint = null)
{
BackendApiClient = backendApiClient;
@@ -320,6 +330,7 @@ namespace AutopilotMonitor.Agent.V2.Runtime
EmergencyReporter = emergencyReporter;
AuthFailureTracker = authFailureTracker;
ServerCertificates = serverCertificates;
ProxyObserver = proxyObserver;
ClientCertificateThumbprint = clientCertificateThumbprint;
}
}
@@ -143,7 +143,6 @@ interface AdminConfigContextValue {
// Actions
handleSaveAdminConfig: () => Promise<void>;
handleResetAdminConfig: () => void;
handleSaveDiagPaths: (paths: DiagnosticsLogPath[]) => Promise<void>;
}
@@ -393,58 +392,6 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
}
}, [isGlobalAdmin, adminConfig, globalRateLimit, userRateLimit, portalUserRateLimit, globalAdminRateLimit, platformStatsBlobSasUrl, agentMigrateApiBaseUrl, agentMigrateTenantOverridesJson, collectorIdleTimeoutMinutes, desktopDetectorNoCandidateTimeoutMinutes, opsEventRetentionDays, slaNotificationCooldownHours, allowAgentDowngrade, enforceClientAppBinding, mcpClientRegistrationEnabled, modernDeploymentHarmlessEventIds, sessionDeletionKillSwitch, autoApproveNewTenants, selfServiceAppHomingEnabled, imeMsiArchivingEnabled, maxImeMsiDownloadSizeMB, getAccessToken]);
// Reset admin config
const handleResetAdminConfig = useCallback(() => {
if (!adminConfig) return;
setGlobalRateLimit(adminConfig.globalRateLimitRequestsPerMinute);
setUserRateLimit(adminConfig.userRateLimitRequestsPerMinute ?? 120);
setPortalUserRateLimit(adminConfig.portalUserRateLimitRequestsPerMinute ?? 600);
setGlobalAdminRateLimit(adminConfig.globalAdminRateLimitRequestsPerMinute ?? 600);
setPlatformStatsBlobSasUrl(adminConfig.platformStatsBlobSasUrl ?? "");
setAgentMigrateApiBaseUrl(adminConfig.agentMigrateApiBaseUrl ?? "");
setAgentMigrateTenantOverridesJson(adminConfig.agentMigrateTenantOverridesJson ?? "");
setCollectorIdleTimeoutMinutes(adminConfig.collectorIdleTimeoutMinutes ?? 15);
setDesktopDetectorNoCandidateTimeoutMinutes(adminConfig.desktopDetectorNoCandidateTimeoutMinutes ?? 10);
setOpsEventRetentionDays(adminConfig.opsEventRetentionDays ?? 90);
setSlaNotificationCooldownHours(adminConfig.slaNotificationCooldownHours ?? 24);
setAllowAgentDowngrade(adminConfig.allowAgentDowngrade ?? false);
setEnforceClientAppBinding(adminConfig.enforceClientAppBinding ?? false);
setMcpClientRegistrationEnabled(adminConfig.mcpClientRegistrationEnabled ?? false);
setModernDeploymentHarmlessEventIds(parseHarmlessEventIdsJson(adminConfig.modernDeploymentHarmlessEventIdsJson));
setSessionDeletionKillSwitch(adminConfig.sessionDeletionKillSwitch ?? false);
setAutoApproveNewTenants(adminConfig.autoApproveNewTenants ?? false);
setSelfServiceAppHomingEnabled(adminConfig.selfServiceAppHomingEnabled ?? false);
setImeMsiArchivingEnabled(adminConfig.imeMsiArchivingEnabled ?? true);
setMaxImeMsiDownloadSizeMB(adminConfig.maxImeMsiDownloadSizeMB ?? 250);
try {
setGlobalDiagPaths(adminConfig.diagnosticsGlobalLogPathsJson ? JSON.parse(adminConfig.diagnosticsGlobalLogPathsJson) : []);
} catch {
setGlobalDiagPaths([]);
}
try {
setOpsAlertRules(adminConfig.opsAlertRulesJson ? JSON.parse(adminConfig.opsAlertRulesJson) : []);
} catch {
setOpsAlertRules([]);
}
try {
setOpsNotificationChannels(
adminConfig.opsNotificationChannelsJson ? JSON.parse(adminConfig.opsNotificationChannelsJson) : []);
} catch {
setOpsNotificationChannels([]);
}
setOpsAlertTelegramEnabled(adminConfig.opsAlertTelegramEnabled ?? false);
setOpsAlertTelegramChatId(adminConfig.opsAlertTelegramChatId ?? "");
setOpsAlertTeamsEnabled(adminConfig.opsAlertTeamsEnabled ?? false);
setOpsAlertTeamsWebhookUrl(adminConfig.opsAlertTeamsWebhookUrl ?? "");
setOpsAlertSlackEnabled(adminConfig.opsAlertSlackEnabled ?? false);
setOpsAlertSlackWebhookUrl(adminConfig.opsAlertSlackWebhookUrl ?? "");
setExcessiveEventCountThreshold(adminConfig.excessiveEventCountThreshold ?? 2000);
setExcessiveEventAutoActionMode(adminConfig.excessiveEventAutoActionMode ?? "Off");
setExcessiveEventAutoActionThreshold(adminConfig.excessiveEventAutoActionThreshold ?? 2500);
setExcessiveEventAutoActionDurationHours(adminConfig.excessiveEventAutoActionDurationHours ?? 24);
setSuccessMessage(null);
setError(null);
}, [adminConfig]);
// Save diagnostics paths
const handleSaveDiagPaths = useCallback(async (paths: DiagnosticsLogPath[]) => {
@@ -591,7 +538,7 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
notificationEmails, setNotificationEmails,
error, setError, successMessage, setSuccessMessage,
getAccessToken,
handleSaveAdminConfig, handleResetAdminConfig, handleSaveDiagPaths, handleSaveOpsAlertConfig,
handleSaveAdminConfig, handleSaveDiagPaths, handleSaveOpsAlertConfig,
opsNotificationChannels, setOpsNotificationChannels,
handleTestOpsChannel, testingOpsChannelId, testOpsChannelResult,
}}>
@@ -46,7 +46,6 @@ interface AdminConfigSettingsSectionProps {
maxImeMsiDownloadSizeMB: number;
setMaxImeMsiDownloadSizeMB: (value: number) => void;
onSave: () => Promise<void>;
onReset: () => void;
}
export function AdminConfigSettingsSection({
@@ -92,7 +91,6 @@ export function AdminConfigSettingsSection({
maxImeMsiDownloadSizeMB,
setMaxImeMsiDownloadSizeMB,
onSave,
onReset,
}: AdminConfigSettingsSectionProps) {
return (
<div className="bg-gradient-to-br from-indigo-50 to-blue-50 dark:from-gray-800 dark:to-gray-800 border-2 border-indigo-300 dark:border-indigo-700 rounded-lg shadow-lg">
@@ -492,13 +490,6 @@ export function AdminConfigSettingsSection({
)}
<div className="flex items-center justify-end space-x-3 pt-2">
<button
onClick={onReset}
disabled={savingConfig}
className="px-5 py-2 border border-indigo-300 dark:border-indigo-600 rounded-md text-indigo-800 dark:text-indigo-200 bg-white dark:bg-gray-700 hover:bg-indigo-50 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Reset
</button>
<button
onClick={onSave}
disabled={savingConfig}
@@ -3,7 +3,7 @@
import { useCallback, useEffect, useState } from "react";
import { api } from "@/lib/api";
import { apiErrorText, fetchJson } from "@/lib/apiClient";
import SaveResetBar from "../../settings/components/SaveResetBar";
import SaveBar from "../../settings/components/SaveBar";
import VulnerabilityExposurePanel from "@/components/VulnerabilityExposurePanel";
import { fromWireAdminConfiguration, type AdminConfiguration } from "@/types/adminConfig";
import { saveAdminConfigChanges } from "@/lib/adminConfigSave";
@@ -185,17 +185,12 @@ export function VulnerabilityDataSection({
} catch (err) {
const message = apiErrorText(err, "Failed to save vulnerability configuration");
setError(message);
throw err; // Let SaveResetBar show "Save failed"
throw err; // Let SaveBar show "Save failed"
} finally {
setSaving(false);
}
}, [adminConfig, nvdApiKey, vulnerabilityCorrelationEnabled, getAccessToken, setError, setSuccessMessage, onAdminConfigUpdated]);
const handleReset = useCallback(() => {
setNvdApiKey(adminConfig?.nvdApiKey ?? "");
setVulnerabilityCorrelationEnabled(adminConfig?.vulnerabilityCorrelationEnabled ?? true);
setSyncResult(null);
}, [adminConfig]);
const handleSync = useCallback(async () => {
setSyncing(true);
@@ -665,10 +660,9 @@ export function VulnerabilityDataSection({
</p>
</div>
{/* Save / Reset Bar */}
<SaveResetBar
{/* Save Bar */}
<SaveBar
onSave={handleSave}
onReset={handleReset}
saving={saving}
canSave={canMutate}
/>
@@ -28,7 +28,7 @@ export function SectionGlobalSettings() {
selfServiceAppHomingEnabled, setSelfServiceAppHomingEnabled,
imeMsiArchivingEnabled, setImeMsiArchivingEnabled,
maxImeMsiDownloadSizeMB, setMaxImeMsiDownloadSizeMB,
handleSaveAdminConfig, handleResetAdminConfig,
handleSaveAdminConfig,
} = useAdminConfig();
useEffect(() => { ensureAdminConfigLoaded(); }, [ensureAdminConfigLoaded]);
@@ -79,7 +79,6 @@ export function SectionGlobalSettings() {
maxImeMsiDownloadSizeMB={maxImeMsiDownloadSizeMB}
setMaxImeMsiDownloadSizeMB={setMaxImeMsiDownloadSizeMB}
onSave={handleSaveAdminConfig}
onReset={handleResetAdminConfig}
/>
</>
);
@@ -606,7 +606,9 @@ function EventRow({ event, showScriptOutput, prevEvent, clockDeltas }: { event:
<div id={`event-${event.eventId}`} className="bg-gray-50 rounded-lg p-3 hover:bg-gray-100 transition-colors">
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3">
{/* flex-wrap: on a phone the event type, one long word, drops below the time and badges
instead of pushing the whole page sideways. */}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
<span className="text-xs text-gray-500 font-mono">
{(recordedAt ?? new Date(event.timestamp)).toLocaleTimeString()}
{recordedAt && (
@@ -632,9 +634,9 @@ function EventRow({ event, showScriptOutput, prevEvent, clockDeltas }: { event:
⧖ Clamped
</span>
)}
<span className="text-sm font-medium text-gray-900">{event.eventType}</span>
<span className="text-sm font-medium text-gray-900 min-w-0 break-words">{event.eventType}</span>
</div>
<p className="mt-1 text-sm text-gray-600" title={event.message || undefined}>{shortenBuildHashInMessage(event.message)}</p>
<p className="mt-1 text-sm text-gray-600 break-words" title={event.message || undefined}>{shortenBuildHashInMessage(event.message)}</p>
{laterCorrection && (
<p className="mt-0.5 text-xs text-gray-500">
{laterCorrection === "foreign"
@@ -0,0 +1,26 @@
"use client";
import { useEffect, useState } from "react";
import { isAgentSilent, isTerminalStatus, nextSilenceCheckMs } from "@/lib/sessionStatus";
const CHECK_INTERVAL_MS = 30_000;
/**
* Whether the agent of an unfinished session has gone quiet, re-checked while the page stays open. Only
* in a visible tab: a tab waking up has not caught up with the events yet and would flash "No data".
* New data clears it at once, since the answer is derived from the latest ingest time at every render.
*/
export function useAgentSilence(status: string | null | undefined, lastIngestAtMs: number | null): boolean {
const [checkedAtMs, setCheckedAtMs] = useState(() => Date.now());
useEffect(() => {
if (isTerminalStatus(status)) return;
const id = setInterval(() => {
if (document.visibilityState !== "visible") return;
setCheckedAtMs(previous => nextSilenceCheckMs(previous, Date.now(), status, lastIngestAtMs));
}, CHECK_INTERVAL_MS);
return () => clearInterval(id);
}, [status, lastIngestAtMs]);
return isAgentSilent(status, lastIngestAtMs, checkedAtMs);
}
@@ -30,6 +30,8 @@ export interface UseSessionDerivedDataReturn {
displayStatus: string;
enrollmentDurationFromEvents: string | null;
lastObservedAtMs: number | null;
/** When the server last received anything from the agent. Server clock, unlike lastObservedAtMs. */
lastIngestAtMs: number | null;
standbySeconds: number | null;
/** The OOBE quality update of a running session (null otherwise); its label depends on the clock. */
osUpdateLive: OsUpdateLiveFacts | null;
@@ -168,6 +170,19 @@ export function useSessionDerivedData(
return max;
}, [events, sessionLastEventAt]);
// The session row is not refreshed with every event, the event's own receipt time is.
const sessionLastIngestAt = session?.lastIngestAt ?? null;
const lastIngestAtMs = useMemo(() => {
const fromSession = sessionLastIngestAt ? new Date(sessionLastIngestAt).getTime() : NaN;
let max: number | null = Number.isFinite(fromSession) ? fromSession : null;
for (const e of events) {
if (!e.receivedAt) continue;
const t = new Date(e.receivedAt).getTime();
if (Number.isFinite(t) && (max === null || t > max)) max = t;
}
return max;
}, [events, sessionLastIngestAt]);
// The OOBE quality update while the session runs — the live counterpart of the time
// attribution's os_update / awaiting_sign_in segments, which exist only once it ended.
const sessionStatus = session?.status;
@@ -269,6 +284,7 @@ export function useSessionDerivedData(
displayStatus,
enrollmentDurationFromEvents,
lastObservedAtMs,
lastIngestAtMs,
standbySeconds,
osUpdateLive,
phaseNamesMap,
@@ -28,6 +28,7 @@ import { useSessionDetail } from "./hooks/useSessionDetail";
import { useSessionEvents } from "./hooks/useSessionEvents";
import { useSessionSignalR } from "./hooks/useSessionSignalR";
import { useSessionDerivedData } from "./hooks/useSessionDerivedData";
import { useAgentSilence } from "./hooks/useAgentSilence";
import { useSessionTenantConfig } from "./hooks/useSessionTenantConfig";
import { useSessionTimeAttribution } from "./hooks/useSessionTimeAttribution";
@@ -193,6 +194,7 @@ function SessionDetailContent() {
displayStatus,
enrollmentDurationFromEvents,
lastObservedAtMs,
lastIngestAtMs,
standbySeconds,
osUpdateLive,
isSkipUserStatusPage,
@@ -206,10 +208,12 @@ function SessionDetailContent() {
userEnrollGrouped,
} = derived;
// Observation end for the progress panels: only once the session is terminal does a row
// without a terminal event stop being "still running" — it becomes incomplete, its timer
// bounded by the agent's last report. Live sessions pass null and keep ticking.
const observedUntilMs = isTerminalStatus(displayStatus) ? lastObservedAtMs : null;
// Observation end for the progress panels: once the session is terminal, or no data has arrived
// for a while, a row without a terminal event stops being "still running" — its timer is bounded
// by the agent's last report. A silent session reads "No data" and goes live again with new
// data; live sessions pass null and keep ticking.
const agentSilent = useAgentSilence(displayStatus, lastIngestAtMs);
const observedUntilMs = isTerminalStatus(displayStatus) || agentSilent ? lastObservedAtMs : null;
// One boundary for the Download / Install / Script panels, so all three split at the same moment.
const userPhaseBoundary = useMemo(
() => findUserPhaseBoundary(events, session?.enrollmentType),
@@ -772,6 +776,7 @@ function SessionDetailContent() {
latestBootstrapVersion={latestBootstrapVersion}
displayNamesByRefKey={scriptDisplayNamesByRefKey}
observedUntilMs={observedUntilMs}
agentSilent={agentSilent}
userPhaseBoundary={userPhaseBoundary}
/>
</div>
@@ -784,6 +789,7 @@ function SessionDetailContent() {
events={downloadEvents}
summaryStats={appSummaryStats}
observedUntilMs={observedUntilMs}
agentSilent={agentSilent}
userPhaseBoundary={userPhaseBoundary}
/>
</div>
@@ -800,6 +806,7 @@ function SessionDetailContent() {
events={installEvents}
summaryStats={appSummaryStats}
observedUntilMs={observedUntilMs}
agentSilent={agentSilent}
userPhaseBoundary={userPhaseBoundary}
/>
</div>
@@ -100,7 +100,7 @@ interface TenantConfigContextValue {
loading: boolean;
/**
* True when the current user may CHANGE tenant configuration (Tenant Admin or Global Admin).
* Operators reach the settings area read-only: sections must render no Save/Reset bar and
* Operators reach the settings area read-only: sections must render no Save bar and
* disable their inputs when this is false. Mirrors the backend write gate (PUT config is
* TenantAdminOrGA), so hiding the affordances is UX — the server enforces regardless.
*/
@@ -172,8 +172,7 @@ interface TenantConfigContextValue {
setModelWhitelist: (v: string) => void;
webhookNotifyOnHardwareRejection: boolean;
setWebhookNotifyOnHardwareRejection: (v: boolean) => void;
handleSaveHardwareWhitelist: () => void;
handleResetHardwareWhitelist: () => void;
handleSaveHardwareWhitelist: () => Promise<void>;
// Agent settings
enablePerformanceCollector: boolean;
@@ -194,8 +193,7 @@ interface TenantConfigContextValue {
setContactEmail: (v: string) => void;
companyName: string;
setCompanyName: (v: string) => void;
handleSaveContact: () => void;
handleResetContact: () => void;
handleSaveContact: () => Promise<void>;
enableGeoLocation: boolean;
setEnableGeoLocation: (v: boolean) => void;
enableTimezoneAutoSet: boolean;
@@ -224,8 +222,8 @@ interface TenantConfigContextValue {
setEnableRealmJoinWatcher: (v: boolean) => void;
enableConfigMgrWatcher: boolean;
setEnableConfigMgrWatcher: (v: boolean) => void;
handleSaveAgentSettings: () => void;
handleResetAgentSettings: () => void;
handleSaveAgentParameters: () => Promise<void>;
handleSaveAgentCollectors: () => Promise<void>;
// Agent analyzers
enableLocalAdminAnalyzer: boolean;
@@ -240,8 +238,7 @@ interface TenantConfigContextValue {
setEnableIntegrityBypassAnalyzer: (v: boolean) => void;
enableConsoleBypassDetection: boolean;
setEnableConsoleBypassDetection: (v: boolean) => void;
handleSaveAgentAnalyzers: () => void;
handleResetAgentAnalyzers: () => void;
handleSaveAgentAnalyzers: () => Promise<void>;
// Unrestricted mode
unrestrictedMode: boolean;
@@ -254,8 +251,7 @@ interface TenantConfigContextValue {
testingChannelId: string | null;
testChannelResult: { channelId: string; success: boolean; message: string } | null;
handleTestChannel: (channelId: string) => Promise<void>;
handleSaveNotifications: () => void;
handleResetNotifications: () => void;
handleSaveNotifications: () => Promise<void>;
// SLA Targets
slaTargetSuccessRate: number | null;
@@ -276,8 +272,7 @@ interface TenantConfigContextValue {
setSlaNotifyOnConsecutiveFailures: (v: boolean) => void;
slaConsecutiveFailureThreshold: number;
setSlaConsecutiveFailureThreshold: (v: number) => void;
handleSaveSlaTargets: () => void;
handleResetSlaTargets: () => void;
handleSaveSlaTargets: () => Promise<void>;
// Diagnostics
diagnosticsBlobSasUrl: string;
@@ -292,8 +287,7 @@ interface TenantConfigContextValue {
setNewDiagPath: (v: string) => void;
newDiagDesc: string;
setNewDiagDesc: (v: string) => void;
handleSaveDiagnostics: () => void;
handleResetDiagnostics: () => void;
handleSaveDiagnostics: () => Promise<void>;
// Admin management
admins: TenantAdmin[];
@@ -325,8 +319,7 @@ interface TenantConfigContextValue {
setDataRetentionDays: (v: number) => void;
sessionTimeoutHours: number;
setSessionTimeoutHours: (v: number) => void;
handleSaveDataManagement: () => void;
handleResetDataManagement: () => void;
handleSaveDataManagement: () => Promise<void>;
// Offboarding
showOffboardDialog: boolean;
@@ -367,7 +360,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
const { addNotification } = useNotifications();
// Operators reach the settings area read-only — only a Tenant Admin or Global Admin may
// change configuration. Drives section rendering (no Save/Reset bar, disabled inputs)
// change configuration. Drives section rendering (no Save bar, disabled inputs)
// and the defensive guard in saveConfiguration; the backend enforces the same gate.
const canEditConfig = user?.isTenantAdmin === true || user?.isGlobalAdmin === true;
@@ -727,10 +720,11 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
// -----------------------------------------------------------------------
// Save configuration (shared by all sections)
// -----------------------------------------------------------------------
const saveConfiguration = useCallback(async (sectionName: SettingsSectionName, overrides?: { validateAutopilotDevice?: boolean; validateCorporateIdentifier?: boolean; validateDeviceAssociation?: boolean; validateCloudPcDevice?: boolean; validateIntuneDeviceBinding?: boolean; unrestrictedMode?: boolean }): Promise<boolean> => {
// Resolves to null when the save went through, otherwise to the text of the failure.
const runSectionSave = useCallback(async (sectionName: SettingsSectionName, overrides?: { validateAutopilotDevice?: boolean; validateCorporateIdentifier?: boolean; validateDeviceAssociation?: boolean; validateCloudPcDevice?: boolean; validateIntuneDeviceBinding?: boolean; unrestrictedMode?: boolean }): Promise<string | null> => {
// Read-only viewers (Operators) have no save affordances; this guard covers any path
// that still reaches a save (the backend would 403 the PATCH regardless).
if (!tenantId || !config || !canEditConfig) return false;
if (!tenantId || !config || !canEditConfig) return "This configuration cannot be edited right now.";
// The validation gates and Unrestricted Mode have no save bar, so their form state must always
// equal the stored row. Some of their toggles move the state before this save runs, so a failed
@@ -832,7 +826,9 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
if (await patchTenantConfigFields(api.config.fields(tenantId), patchFields, `settings:${sectionName}`, getAccessToken)) {
// The feature flags derive from these fields: the cached copy is stale from here on.
invalidateCachedAuthFetch(CONFIG_PATH_PREFIX);
setConfig({ ...config, ...patchFields } as TenantConfiguration);
// Functional merge: the Agent Settings page has two Save bars, and a save still in flight
// must not lose its fields to the later one that started from the same loaded config.
setConfig((prev) => ({ ...(prev ?? config), ...patchFields }) as TenantConfiguration);
}
// Sync the gate-relevant form state with what is now persisted (merge of loaded
@@ -841,13 +837,13 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
trackEvent("settings_saved", { section: sectionName, fieldCount: Object.keys(patchFields).length });
setSuccessMessage("Configuration saved successfully!");
setTimeout(() => setSuccessMessage(null), 3000);
return true;
return null;
} catch (err) {
syncGateState(config);
const msg = apiErrorText(err, "Failed to save configuration");
trackEvent("settings_error", { action: "save", section: sectionName, error: msg });
setError(msg);
return false;
return msg;
} finally {
setSavingSection(null);
}
@@ -868,6 +864,19 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
enableIntegrityBypassAnalyzer, enableConsoleBypassDetection, unrestrictedMode,
]);
// Flows without a save bar (validation gates, Unrestricted Mode, consent) branch on the outcome.
const saveConfiguration = useCallback(
async (sectionName: SettingsSectionName, overrides?: Parameters<typeof runSectionSave>[1]) =>
(await runSectionSave(sectionName, overrides)) === null,
[runSectionSave],
);
// Save bars: a failure throws, so SaveBar shows its reason next to the button.
const saveSection = useCallback(async (sectionName: SettingsSectionName) => {
const failure = await runSectionSave(sectionName);
if (failure !== null) throw new Error(failure);
}, [runSectionSave]);
// -----------------------------------------------------------------------
// Consent flow
// -----------------------------------------------------------------------
@@ -1178,22 +1187,9 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
}, [tenantId, getAccessToken]);
// -----------------------------------------------------------------------
// Per-section save/reset handlers
// Per-section save handlers
// -----------------------------------------------------------------------
const handleSaveHardwareWhitelist = useCallback(() => saveConfiguration("hardwareWhitelist"), [saveConfiguration]);
const handleResetHardwareWhitelist = useCallback(() => {
if (!config) return;
setManufacturerWhitelist(config.manufacturerWhitelist);
setModelWhitelist(config.modelWhitelist);
setWebhookNotifyOnHardwareRejection(config.webhookNotifyOnHardwareRejection ?? false);
// The hardware-rejection toggle writes through to the per-channel flags — restore those
// to their saved values too, without disturbing other unsaved channel edits.
const savedById = new Map(channelsFromConfig(config).map((c) => [c.id, c]));
setNotificationChannels((prev) => prev.map((c) => {
const saved = savedById.get(c.id);
return saved ? { ...c, notifyOnHardwareRejection: saved.notifyOnHardwareRejection } : c;
}));
}, [config]);
const handleSaveHardwareWhitelist = useCallback(() => saveSection("hardwareWhitelist"), [saveSection]);
// Hardware-rejection notify: with channels configured, the hardware-section toggle is a
// convenience view over the per-channel flags (checked = any enabled channel opted in;
@@ -1207,92 +1203,21 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
setNotificationChannels((prev) => prev.map((c) => ({ ...c, notifyOnHardwareRejection: v })));
}, []);
const handleSaveAgentSettings = useCallback(() => saveConfiguration("agentSettings"), [saveConfiguration]);
const handleResetAgentSettings = useCallback(() => {
if (!config) return;
setEnablePerformanceCollector(config.enablePerformanceCollector ?? true);
setPerformanceCollectorInterval(config.performanceCollectorIntervalSeconds ?? 30);
setHelloWaitTimeoutSeconds(config.helloWaitTimeoutSeconds ?? HELLO_WAIT_TIMEOUT_DEFAULT_SECONDS);
setSelfDestructOnComplete(config.selfDestructOnComplete ?? true);
setKeepLogFile(config.keepLogFile ?? false);
setRebootOnComplete(config.rebootOnComplete ?? false);
setRebootDelaySeconds(config.rebootDelaySeconds ?? 10);
// contactEmail/companyName deliberately NOT reset here — they are owned by the Contact section
// (resetting it from the Agent Settings card silently discarded unsaved Contact edits).
setEnableGeoLocation(config.enableGeoLocation ?? true);
setEnableTimezoneAutoSet(config.enableTimezoneAutoSet ?? false);
setEnableDoGroupIdAutoSet(config.enableDoGroupIdAutoSet ?? false);
setEnableImeMatchLog(config.enableImeMatchLog ?? false);
setEnableGatherRuleDebugLog(config.enableGatherRuleDebugLog ?? false);
setLogLevel(config.logLevel ?? "Info");
setShowScriptOutput(config.showScriptOutput ?? true);
setShowEnrollmentSummary(config.showEnrollmentSummary ?? false);
setEnrollmentSummaryTimeoutSeconds(config.enrollmentSummaryTimeoutSeconds ?? 60);
setEnrollmentSummaryBrandingImageUrl(config.enrollmentSummaryBrandingImageUrl ?? "");
setEnrollmentSummaryLaunchRetrySeconds(config.enrollmentSummaryLaunchRetrySeconds ?? 120);
setEnableRealmJoinWatcher(config.enableRealmJoinWatcher ?? false);
setEnableConfigMgrWatcher(config.enableConfigMgrWatcher ?? false);
setKeepAwakeDuringUserEsp(config.keepAwakeDuringUserEsp ?? false);
}, [config]);
const handleSaveAgentParameters = useCallback(() => saveSection("agentParameters"), [saveSection]);
const handleSaveAgentAnalyzers = useCallback(() => saveConfiguration("agentAnalyzers"), [saveConfiguration]);
const handleResetAgentAnalyzers = useCallback(() => {
if (!config) return;
setEnableLocalAdminAnalyzer(config.enableLocalAdminAnalyzer ?? true);
try {
setLocalAdminAllowedAccounts(config.localAdminAllowedAccountsJson ? JSON.parse(config.localAdminAllowedAccountsJson) : []);
} catch { setLocalAdminAllowedAccounts([]); }
setNewAllowedAccount("");
setEnableSoftwareInventoryAnalyzer(config.enableSoftwareInventoryAnalyzer ?? false);
setEnableIntegrityBypassAnalyzer(config.enableIntegrityBypassAnalyzer ?? true);
setEnableConsoleBypassDetection(config.enableConsoleBypassDetection ?? true);
}, [config]);
const handleSaveAgentCollectors = useCallback(() => saveSection("agentCollectors"), [saveSection]);
const handleSaveNotifications = useCallback(() => saveConfiguration("notifications"), [saveConfiguration]);
const handleResetNotifications = useCallback(() => {
if (!config) return;
setNotificationChannels(channelsFromConfig(config));
setTestChannelResult(null);
}, [config]);
const handleSaveAgentAnalyzers = useCallback(() => saveSection("agentAnalyzers"), [saveSection]);
const handleSaveSlaTargets = useCallback(() => saveConfiguration("slaTargets"), [saveConfiguration]);
const handleResetSlaTargets = useCallback(() => {
if (!config) return;
setSlaTargetSuccessRate(config.slaTargetSuccessRate ?? null);
setSlaTargetMaxDurationMinutes(config.slaTargetMaxDurationMinutes ?? null);
setSlaTargetAppInstallSuccessRate(config.slaTargetAppInstallSuccessRate ?? null);
setSlaNotifyOnSuccessRateBreach(config.slaNotifyOnSuccessRateBreach ?? false);
setSlaSuccessRateNotifyThreshold(config.slaSuccessRateNotifyThreshold ?? null);
setSlaNotifyOnDurationBreach(config.slaNotifyOnDurationBreach ?? false);
setSlaNotifyOnAppInstallBreach(config.slaNotifyOnAppInstallBreach ?? false);
setSlaNotifyOnConsecutiveFailures(config.slaNotifyOnConsecutiveFailures ?? false);
setSlaConsecutiveFailureThreshold(config.slaConsecutiveFailureThreshold ?? 5);
}, [config]);
const handleSaveNotifications = useCallback(() => saveSection("notifications"), [saveSection]);
const handleSaveContact = useCallback(() => saveConfiguration("contact"), [saveConfiguration]);
const handleResetContact = useCallback(() => {
if (!config) return;
setContactEmail(config.contactEmail ?? "");
setCompanyName(config.companyName ?? "");
}, [config]);
const handleSaveSlaTargets = useCallback(() => saveSection("slaTargets"), [saveSection]);
const handleSaveDiagnostics = useCallback(() => saveConfiguration("diagnostics"), [saveConfiguration]);
const handleResetDiagnostics = useCallback(() => {
if (!config) return;
setDiagnosticsBlobSasUrl(config.diagnosticsBlobSasUrl ?? "");
setDiagnosticsUploadMode(config.diagnosticsUploadMode ?? "Off");
setDiagnosticsUploadDestination(config.diagnosticsUploadDestination ?? "CustomerSas");
try {
setTenantDiagPaths(config.diagnosticsLogPathsJson ? JSON.parse(config.diagnosticsLogPathsJson) : []);
} catch { setTenantDiagPaths([]); }
}, [config]);
const handleSaveContact = useCallback(() => saveSection("contact"), [saveSection]);
const handleSaveDataManagement = useCallback(() => saveConfiguration("dataManagement"), [saveConfiguration]);
const handleResetDataManagement = useCallback(() => {
if (!config) return;
setDataRetentionDays(config.dataRetentionDays ?? 90);
setSessionTimeoutHours(config.sessionTimeoutHours ?? 5);
}, [config]);
const handleSaveDiagnostics = useCallback(() => saveSection("diagnostics"), [saveSection]);
const handleSaveDataManagement = useCallback(() => saveSection("dataManagement"), [saveSection]);
// Persist the toggle with the new value passed explicitly. The on/off value MUST be threaded
// through as an override rather than read from `unrestrictedMode` state: the toggle calls this
@@ -1588,7 +1513,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
modelWhitelist, setModelWhitelist,
webhookNotifyOnHardwareRejection: effectiveHwRejectionNotify,
setWebhookNotifyOnHardwareRejection: setHwRejectionNotifyWriteThrough,
handleSaveHardwareWhitelist, handleResetHardwareWhitelist,
handleSaveHardwareWhitelist,
// Agent settings
enablePerformanceCollector, setEnablePerformanceCollector,
@@ -1600,7 +1525,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
rebootDelaySeconds, setRebootDelaySeconds,
contactEmail, setContactEmail,
companyName, setCompanyName,
handleSaveContact, handleResetContact,
handleSaveContact,
enableGeoLocation, setEnableGeoLocation,
enableTimezoneAutoSet, setEnableTimezoneAutoSet,
enableDoGroupIdAutoSet, setEnableDoGroupIdAutoSet,
@@ -1615,7 +1540,8 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
enableRealmJoinWatcher, setEnableRealmJoinWatcher,
enableConfigMgrWatcher, setEnableConfigMgrWatcher,
keepAwakeDuringUserEsp, setKeepAwakeDuringUserEsp,
handleSaveAgentSettings, handleResetAgentSettings,
handleSaveAgentParameters,
handleSaveAgentCollectors,
// Agent analyzers
enableLocalAdminAnalyzer, setEnableLocalAdminAnalyzer,
@@ -1624,7 +1550,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
enableSoftwareInventoryAnalyzer, setEnableSoftwareInventoryAnalyzer,
enableIntegrityBypassAnalyzer, setEnableIntegrityBypassAnalyzer,
enableConsoleBypassDetection, setEnableConsoleBypassDetection,
handleSaveAgentAnalyzers, handleResetAgentAnalyzers,
handleSaveAgentAnalyzers,
// Unrestricted mode
unrestrictedMode, setUnrestrictedMode,
@@ -1633,7 +1559,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
// Notifications
notificationChannels, setNotificationChannels,
testingChannelId, testChannelResult,
handleTestChannel, handleSaveNotifications, handleResetNotifications,
handleTestChannel, handleSaveNotifications,
// SLA Targets
slaTargetSuccessRate, setSlaTargetSuccessRate,
@@ -1645,7 +1571,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
slaNotifyOnAppInstallBreach, setSlaNotifyOnAppInstallBreach,
slaNotifyOnConsecutiveFailures, setSlaNotifyOnConsecutiveFailures,
slaConsecutiveFailureThreshold, setSlaConsecutiveFailureThreshold,
handleSaveSlaTargets, handleResetSlaTargets,
handleSaveSlaTargets,
// Diagnostics
diagnosticsBlobSasUrl, setDiagnosticsBlobSasUrl,
@@ -1654,7 +1580,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
tenantDiagPaths, setTenantDiagPaths,
newDiagPath, setNewDiagPath,
newDiagDesc, setNewDiagDesc,
handleSaveDiagnostics, handleResetDiagnostics,
handleSaveDiagnostics,
// Admin management
admins, loadingAdmins,
@@ -1671,7 +1597,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
// Data management
dataRetentionDays, setDataRetentionDays,
sessionTimeoutHours, setSessionTimeoutHours,
handleSaveDataManagement, handleResetDataManagement,
handleSaveDataManagement,
// Offboarding
showOffboardDialog, setShowOffboardDialog,
@@ -1690,32 +1616,33 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
validateIntuneDeviceBinding, handleToggleIntuneDeviceBinding,
saveValidationGate, autopilotConsentInProgress, beginDeviceValidationConsentFlow, detectExistingAccess,
manufacturerWhitelist, modelWhitelist, effectiveHwRejectionNotify, setHwRejectionNotifyWriteThrough,
handleSaveHardwareWhitelist, handleResetHardwareWhitelist,
handleSaveHardwareWhitelist,
enablePerformanceCollector, performanceCollectorInterval, helloWaitTimeoutSeconds,
selfDestructOnComplete, keepLogFile, rebootOnComplete, rebootDelaySeconds,
contactEmail, companyName, handleSaveContact, handleResetContact,
contactEmail, companyName, handleSaveContact,
enableGeoLocation, enableTimezoneAutoSet, enableDoGroupIdAutoSet, enableImeMatchLog, enableGatherRuleDebugLog,
logLevel, showScriptOutput, showEnrollmentSummary, enrollmentSummaryTimeoutSeconds,
enrollmentSummaryBrandingImageUrl, enrollmentSummaryLaunchRetrySeconds,
enableRealmJoinWatcher, enableConfigMgrWatcher, keepAwakeDuringUserEsp,
handleSaveAgentSettings, handleResetAgentSettings,
handleSaveAgentParameters,
handleSaveAgentCollectors,
enableLocalAdminAnalyzer, localAdminAllowedAccounts, newAllowedAccount,
enableSoftwareInventoryAnalyzer, enableIntegrityBypassAnalyzer, enableConsoleBypassDetection,
handleSaveAgentAnalyzers, handleResetAgentAnalyzers,
handleSaveAgentAnalyzers,
unrestrictedMode, handleSaveUnrestrictedMode,
notificationChannels, testingChannelId, testChannelResult,
handleTestChannel, handleSaveNotifications, handleResetNotifications,
handleTestChannel, handleSaveNotifications,
slaTargetSuccessRate, slaTargetMaxDurationMinutes, slaTargetAppInstallSuccessRate,
slaNotifyOnSuccessRateBreach, slaSuccessRateNotifyThreshold, slaNotifyOnDurationBreach,
slaNotifyOnAppInstallBreach, slaNotifyOnConsecutiveFailures, slaConsecutiveFailureThreshold,
handleSaveSlaTargets, handleResetSlaTargets,
handleSaveSlaTargets,
diagnosticsBlobSasUrl, diagnosticsUploadMode, diagnosticsUploadDestination,
tenantDiagPaths, newDiagPath, newDiagDesc,
handleSaveDiagnostics, handleResetDiagnostics,
handleSaveDiagnostics,
admins, loadingAdmins, newAdminEmail, newMemberRole, newMemberKind, addingAdmin, removingAdmin, togglingAdmin,
handleAddAdmin, handleRemoveAdmin, handleToggleTenantAdmin, handleUpdatePermissions,
bootstrapSessions, bootstrapLoading, fetchBootstrapSessions, createBootstrapSession, revokeBootstrapSession,
dataRetentionDays, sessionTimeoutHours, handleSaveDataManagement, handleResetDataManagement,
dataRetentionDays, sessionTimeoutHours, handleSaveDataManagement,
showOffboardDialog, offboardConfirmText, offboarding, offboardError, handleOffboard,
offboardingInProgress, handleDrainBarrierElapsed,
user, getAccessToken,
@@ -32,9 +32,10 @@ const EXPECTED_OWNED_FIELDS = [
"slaNotifyOnAppInstallBreach", "slaNotifyOnConsecutiveFailures", "slaConsecutiveFailureThreshold",
// contact
"contactEmail", "companyName",
// agentSettings
// agentCollectors
"enablePerformanceCollector", "performanceCollectorIntervalSeconds", "helloWaitTimeoutSeconds",
"enableRealmJoinWatcher", "enableConfigMgrWatcher",
// agentParameters
"selfDestructOnComplete", "keepLogFile", "rebootOnComplete", "rebootDelaySeconds",
"enableGeoLocation", "enableTimezoneAutoSet", "enableDoGroupIdAutoSet", "keepAwakeDuringUserEsp",
"enableImeMatchLog", "enableGatherRuleDebugLog",
@@ -92,6 +93,15 @@ describe("SECTION_FIELD_MAP parity", () => {
expect(owned).toEqual(EXPECTED_OWNED_FIELDS);
});
it("the Agent Collectors card saves only its own fields", () => {
// One card, one Save: a field moving back into a shared section would make the Save of one
// card write the unsaved edits of the other.
expect([...SECTION_FIELD_MAP.agentCollectors.fields].sort()).toEqual([
"enableConfigMgrWatcher", "enablePerformanceCollector", "enableRealmJoinWatcher",
"helloWaitTimeoutSeconds", "performanceCollectorIntervalSeconds",
]);
});
it("alsoWrites entries never overlap the same section's owned fields", () => {
for (const [section, spec] of sections) {
for (const field of spec.alsoWrites ?? []) {
@@ -13,7 +13,7 @@ export function SectionAgentAnalyzers() {
enableSoftwareInventoryAnalyzer, setEnableSoftwareInventoryAnalyzer,
enableIntegrityBypassAnalyzer, setEnableIntegrityBypassAnalyzer,
enableConsoleBypassDetection, setEnableConsoleBypassDetection,
handleSaveAgentAnalyzers, handleResetAgentAnalyzers,
handleSaveAgentAnalyzers,
savingSection,
} = useTenantConfig();
@@ -34,7 +34,6 @@ export function SectionAgentAnalyzers() {
enableConsoleBypassDetection={enableConsoleBypassDetection}
setEnableConsoleBypassDetection={setEnableConsoleBypassDetection}
onSave={handleSaveAgentAnalyzers}
onReset={handleResetAgentAnalyzers}
saving={savingSection === "agentAnalyzers"}
readOnly={!canEditConfig}
/>
@@ -2,7 +2,8 @@
import { useTenantConfig } from "../../TenantConfigContext";
import { TenantNotifications } from "../../TenantNotifications";
import AgentSettingsSection from "../../components/AgentSettingsSection";
import AgentParametersSection from "../../components/AgentParametersSection";
import AgentCollectorsSection from "../../components/AgentCollectorsSection";
export function SectionAgentSettings() {
const {
@@ -28,24 +29,15 @@ export function SectionAgentSettings() {
enrollmentSummaryTimeoutSeconds, setEnrollmentSummaryTimeoutSeconds,
enrollmentSummaryBrandingImageUrl, setEnrollmentSummaryBrandingImageUrl,
enrollmentSummaryLaunchRetrySeconds, setEnrollmentSummaryLaunchRetrySeconds,
handleSaveAgentSettings, handleResetAgentSettings,
handleSaveAgentParameters,
handleSaveAgentCollectors,
savingSection,
} = useTenantConfig();
return (
<>
<TenantNotifications />
<AgentSettingsSection
enablePerformanceCollector={enablePerformanceCollector}
setEnablePerformanceCollector={setEnablePerformanceCollector}
performanceCollectorInterval={performanceCollectorInterval}
setPerformanceCollectorInterval={setPerformanceCollectorInterval}
helloWaitTimeoutSeconds={helloWaitTimeoutSeconds}
setHelloWaitTimeoutSeconds={setHelloWaitTimeoutSeconds}
enableRealmJoinWatcher={enableRealmJoinWatcher}
setEnableRealmJoinWatcher={setEnableRealmJoinWatcher}
enableConfigMgrWatcher={enableConfigMgrWatcher}
setEnableConfigMgrWatcher={setEnableConfigMgrWatcher}
<AgentParametersSection
selfDestructOnComplete={selfDestructOnComplete}
setSelfDestructOnComplete={setSelfDestructOnComplete}
keepLogFile={keepLogFile}
@@ -78,9 +70,23 @@ export function SectionAgentSettings() {
setEnrollmentSummaryBrandingImageUrl={setEnrollmentSummaryBrandingImageUrl}
enrollmentSummaryLaunchRetrySeconds={enrollmentSummaryLaunchRetrySeconds}
setEnrollmentSummaryLaunchRetrySeconds={setEnrollmentSummaryLaunchRetrySeconds}
onSave={handleSaveAgentSettings}
onReset={handleResetAgentSettings}
saving={savingSection === "agentSettings"}
onSave={handleSaveAgentParameters}
saving={savingSection === "agentParameters"}
readOnly={!canEditConfig}
/>
<AgentCollectorsSection
enablePerformanceCollector={enablePerformanceCollector}
setEnablePerformanceCollector={setEnablePerformanceCollector}
performanceCollectorInterval={performanceCollectorInterval}
setPerformanceCollectorInterval={setPerformanceCollectorInterval}
helloWaitTimeoutSeconds={helloWaitTimeoutSeconds}
setHelloWaitTimeoutSeconds={setHelloWaitTimeoutSeconds}
enableRealmJoinWatcher={enableRealmJoinWatcher}
setEnableRealmJoinWatcher={setEnableRealmJoinWatcher}
enableConfigMgrWatcher={enableConfigMgrWatcher}
setEnableConfigMgrWatcher={setEnableConfigMgrWatcher}
onSave={handleSaveAgentCollectors}
saving={savingSection === "agentCollectors"}
readOnly={!canEditConfig}
/>
</>
@@ -16,7 +16,7 @@ export function SectionDiagnostics() {
newDiagPath, setNewDiagPath,
newDiagDesc, setNewDiagDesc,
unrestrictedMode,
handleSaveDiagnostics, handleResetDiagnostics,
handleSaveDiagnostics,
savingSection,
} = useTenantConfig();
// Built-in sections + platform-wide paths (MemberRead) — read-only context for every role.
@@ -46,7 +46,6 @@ export function SectionDiagnostics() {
setNewDiagDesc={setNewDiagDesc}
unrestrictedMode={unrestrictedMode}
onSave={handleSaveDiagnostics}
onReset={handleResetDiagnostics}
saving={savingSection === "diagnostics"}
readOnly={!canEditConfig}
/>
@@ -1,6 +1,6 @@
"use client";
import SaveResetBar from "./SaveResetBar";
import SaveBar from "./SaveBar";
import ReadOnlyFieldset from "./ReadOnlyFieldset";
import { SectionCardHeader } from "@/components/SectionCardHeader";
import { DOCS_PATHS } from "@/lib/docsPaths";
@@ -19,9 +19,8 @@ interface AgentAnalyzersSectionProps {
enableConsoleBypassDetection: boolean;
setEnableConsoleBypassDetection: (value: boolean) => void;
onSave: () => Promise<void> | void;
onReset: () => void;
saving: boolean;
/** Read-only viewer (Operator): analyzers visible but inert, no Save/Reset bar. */
/** Read-only viewer (Operator): analyzers visible but inert, no Save bar. */
readOnly?: boolean;
}
@@ -53,7 +52,6 @@ export default function AgentAnalyzersSection({
enableConsoleBypassDetection,
setEnableConsoleBypassDetection,
onSave,
onReset,
saving,
readOnly = false,
}: AgentAnalyzersSectionProps) {
@@ -274,8 +272,8 @@ export default function AgentAnalyzersSection({
</div>
</ReadOnlyFieldset>
{/* Save / Reset */}
{!readOnly && <SaveResetBar onSave={onSave} onReset={onReset} saving={saving} />}
{/* Save */}
{!readOnly && <SaveBar onSave={onSave} saving={saving} />}
</div>
</div>
);
@@ -0,0 +1,154 @@
"use client";
import SaveBar from "./SaveBar";
import ReadOnlyFieldset from "./ReadOnlyFieldset";
import { SectionCardHeader } from "@/components/SectionCardHeader";
import { DOCS_PATHS } from "@/lib/docsPaths";
import {
clampHelloWaitTimeoutSeconds,
HELLO_WAIT_TIMEOUT_DEFAULT_SECONDS,
HELLO_WAIT_TIMEOUT_MAX_SECONDS,
HELLO_WAIT_TIMEOUT_MIN_SECONDS,
} from "../lib/helloWaitTimeout";
interface AgentCollectorsSectionProps {
enablePerformanceCollector: boolean;
setEnablePerformanceCollector: (value: boolean) => void;
performanceCollectorInterval: number;
setPerformanceCollectorInterval: (value: number) => void;
helloWaitTimeoutSeconds: number;
setHelloWaitTimeoutSeconds: (value: number) => void;
enableRealmJoinWatcher: boolean;
setEnableRealmJoinWatcher: (value: boolean) => void;
enableConfigMgrWatcher: boolean;
setEnableConfigMgrWatcher: (value: boolean) => void;
onSave: () => Promise<void> | void;
saving: boolean;
/** Read-only viewer (Operator): settings visible but inert, no Save bar. */
readOnly?: boolean;
}
export default function AgentCollectorsSection({
enablePerformanceCollector,
setEnablePerformanceCollector,
performanceCollectorInterval,
setPerformanceCollectorInterval,
helloWaitTimeoutSeconds,
setHelloWaitTimeoutSeconds,
enableRealmJoinWatcher,
setEnableRealmJoinWatcher,
enableConfigMgrWatcher,
setEnableConfigMgrWatcher,
onSave,
saving,
readOnly = false,
}: AgentCollectorsSectionProps) {
return (
<div className="bg-white rounded-lg shadow">
<SectionCardHeader
tone="emerald"
iconPath="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
title="Agent Collectors"
subtitle="Enable optional data collectors on enrolled devices. These generate additional telemetry traffic."
docsPath={DOCS_PATHS.agentCollectors}
/>
<div className="p-6 space-y-5">
{/* notice={false}: the Agent Parameters card above already shows the read-only notice. */}
<ReadOnlyFieldset readOnly={readOnly} notice={false}>
<div className="space-y-5">
{/* Performance Collector */}
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-emerald-200 transition-colors">
<div className="flex-1">
<div className="flex items-center space-x-2">
<p className="font-medium text-gray-900">Performance Collector</p>
</div>
<p className="text-sm text-gray-500 mt-1">CPU, memory, disk metrics at configurable intervals</p>
{enablePerformanceCollector && (
<div className="mt-2">
<div className="flex items-center space-x-2">
<span className="text-sm text-gray-600">Interval:</span>
<input
type="number"
min={30}
max={300}
value={performanceCollectorInterval}
onChange={(e) => setPerformanceCollectorInterval(parseInt(e.target.value) || 30)}
onBlur={() => setPerformanceCollectorInterval(Math.max(30, Math.min(300, performanceCollectorInterval)))}
className="w-20 px-2 py-1 border border-gray-300 rounded text-sm text-gray-900 focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
/>
<span className="text-sm text-gray-500">seconds</span>
</div>
<p className="text-xs text-gray-400 mt-1">Minimum: 30 seconds, Maximum: 300 seconds (5 minutes)</p>
</div>
)}
</div>
<button
onClick={() => setEnablePerformanceCollector(!enablePerformanceCollector)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors ${enablePerformanceCollector ? 'bg-emerald-500' : 'bg-gray-300'}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${enablePerformanceCollector ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
{/* RealmJoin Watcher */}
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-emerald-200 transition-colors">
<div className="flex-1">
<p className="font-medium text-gray-900">RealmJoin Watcher</p>
<p className="text-sm text-gray-500 mt-1">Tracks RealmJoin deployment state during provisioning — deployment phase, per-package start and completion, and the RealmJoin completion gate. Off by default; enable only for tenants that provision devices with RealmJoin, elsewhere it produces no signal.</p>
</div>
<button
onClick={() => setEnableRealmJoinWatcher(!enableRealmJoinWatcher)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors ${enableRealmJoinWatcher ? 'bg-emerald-500' : 'bg-gray-300'}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${enableRealmJoinWatcher ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
{/* ConfigMgr Watcher */}
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-emerald-200 transition-colors">
<div className="flex-1">
<p className="font-medium text-gray-900">ConfigMgr Watcher</p>
<p className="text-sm text-gray-500 mt-1">Shows in Session Info whether the Configuration Manager client is installed, with its service state, version and site code. Off by default; enable for tenants that co-manage devices with Configuration Manager.</p>
</div>
<button
onClick={() => setEnableConfigMgrWatcher(!enableConfigMgrWatcher)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors ${enableConfigMgrWatcher ? 'bg-emerald-500' : 'bg-gray-300'}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${enableConfigMgrWatcher ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
{/* Hello Wait Timeout */}
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-emerald-200 transition-colors">
<div className="flex-1">
<div className="flex items-center space-x-2">
<p className="font-medium text-gray-900">Hello Wait Timeout</p>
</div>
<p className="text-sm text-gray-500 mt-1">Seconds to wait for the Windows Hello wizard after ESP exit</p>
<div className="mt-2">
<div className="flex items-center space-x-2">
<span className="text-sm text-gray-600">Timeout:</span>
<input
type="number"
min={HELLO_WAIT_TIMEOUT_MIN_SECONDS}
max={HELLO_WAIT_TIMEOUT_MAX_SECONDS}
value={helloWaitTimeoutSeconds}
onChange={(e) => setHelloWaitTimeoutSeconds(parseInt(e.target.value) || HELLO_WAIT_TIMEOUT_DEFAULT_SECONDS)}
onBlur={() => setHelloWaitTimeoutSeconds(clampHelloWaitTimeoutSeconds(helloWaitTimeoutSeconds))}
className="w-20 px-2 py-1 border border-gray-300 rounded text-sm text-gray-900 focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
/>
<span className="text-sm text-gray-500">seconds</span>
</div>
<p className="text-xs text-gray-400 mt-1">Minimum: 30 seconds, Maximum: 3600 seconds (1 hour). Values up to 300 seconds use the built-in 5-minute wait.</p>
</div>
</div>
</div>
</div>
</ReadOnlyFieldset>
{!readOnly && <SaveBar onSave={onSave} saving={saving} />}
</div>
</div>
);
}
@@ -0,0 +1,342 @@
"use client";
import SaveBar from "./SaveBar";
import ReadOnlyFieldset from "./ReadOnlyFieldset";
import { SectionCardHeader } from "@/components/SectionCardHeader";
import { DOCS_PATHS } from "@/lib/docsPaths";
interface AgentParametersSectionProps {
selfDestructOnComplete: boolean;
setSelfDestructOnComplete: (value: boolean) => void;
keepLogFile: boolean;
setKeepLogFile: (value: boolean) => void;
rebootOnComplete: boolean;
setRebootOnComplete: (value: boolean) => void;
rebootDelaySeconds: number;
setRebootDelaySeconds: (value: number) => void;
enableGeoLocation: boolean;
setEnableGeoLocation: (value: boolean) => void;
enableTimezoneAutoSet: boolean;
setEnableTimezoneAutoSet: (value: boolean) => void;
enableDoGroupIdAutoSet: boolean;
setEnableDoGroupIdAutoSet: (value: boolean) => void;
keepAwakeDuringUserEsp: boolean;
setKeepAwakeDuringUserEsp: (value: boolean) => void;
enableImeMatchLog: boolean;
setEnableImeMatchLog: (value: boolean) => void;
enableGatherRuleDebugLog: boolean;
setEnableGatherRuleDebugLog: (value: boolean) => void;
logLevel: string;
setLogLevel: (value: string) => void;
showScriptOutput: boolean;
setShowScriptOutput: (value: boolean) => void;
showEnrollmentSummary: boolean;
setShowEnrollmentSummary: (value: boolean) => void;
enrollmentSummaryTimeoutSeconds: number;
setEnrollmentSummaryTimeoutSeconds: (value: number) => void;
enrollmentSummaryBrandingImageUrl: string;
setEnrollmentSummaryBrandingImageUrl: (value: string) => void;
enrollmentSummaryLaunchRetrySeconds: number;
setEnrollmentSummaryLaunchRetrySeconds: (value: number) => void;
onSave: () => Promise<void> | void;
saving: boolean;
/** Read-only viewer (Operator): settings visible but inert, no Save bar. */
readOnly?: boolean;
}
export default function AgentParametersSection({
selfDestructOnComplete,
setSelfDestructOnComplete,
keepLogFile,
setKeepLogFile,
rebootOnComplete,
setRebootOnComplete,
rebootDelaySeconds,
setRebootDelaySeconds,
enableGeoLocation,
setEnableGeoLocation,
enableTimezoneAutoSet,
setEnableTimezoneAutoSet,
enableDoGroupIdAutoSet,
setEnableDoGroupIdAutoSet,
keepAwakeDuringUserEsp,
setKeepAwakeDuringUserEsp,
enableImeMatchLog,
setEnableImeMatchLog,
enableGatherRuleDebugLog,
setEnableGatherRuleDebugLog,
logLevel,
setLogLevel,
showScriptOutput,
setShowScriptOutput,
showEnrollmentSummary,
setShowEnrollmentSummary,
enrollmentSummaryTimeoutSeconds,
setEnrollmentSummaryTimeoutSeconds,
enrollmentSummaryBrandingImageUrl,
setEnrollmentSummaryBrandingImageUrl,
enrollmentSummaryLaunchRetrySeconds,
setEnrollmentSummaryLaunchRetrySeconds,
onSave,
saving,
readOnly = false,
}: AgentParametersSectionProps) {
return (
<div className="bg-white rounded-lg shadow">
<SectionCardHeader
tone="violet"
iconPath="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4"
title="Agent Parameters"
subtitle="Control agent behavior on enrolled devices. Changes take effect on the next agent config refresh."
docsPath={DOCS_PATHS.agentParameters}
/>
<div className="p-6 space-y-4">
<ReadOnlyFieldset readOnly={readOnly}>
<div className="space-y-4">
{/* Self-Destruct */}
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-violet-200 transition-colors">
<div>
<p className="font-medium text-gray-900">Self-Destruct on Complete</p>
<p className="text-sm text-gray-500">Remove Scheduled Task and all agent files when enrollment completes</p>
</div>
<button onClick={() => setSelfDestructOnComplete(!selfDestructOnComplete)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors ${selfDestructOnComplete ? 'bg-violet-500' : 'bg-gray-300'}`}>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${selfDestructOnComplete ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
{/* Keep Log File */}
{selfDestructOnComplete && (
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-violet-200 transition-colors ml-4">
<div>
<p className="font-medium text-gray-900">Keep Log File</p>
<p className="text-sm text-gray-500">Preserve the agent log during self-destruct (all other files are removed)</p>
</div>
<button onClick={() => setKeepLogFile(!keepLogFile)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors ${keepLogFile ? 'bg-violet-500' : 'bg-gray-300'}`}>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${keepLogFile ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
)}
{/* Reboot on Complete */}
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-violet-200 transition-colors">
<div>
<p className="font-medium text-gray-900">Reboot on Complete</p>
<p className="text-sm text-gray-500">Reboot the device after enrollment completes (and after self-destruct if enabled)</p>
</div>
<button onClick={() => setRebootOnComplete(!rebootOnComplete)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors ${rebootOnComplete ? 'bg-violet-500' : 'bg-gray-300'}`}>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${rebootOnComplete ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
{rebootOnComplete && (
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-violet-200 transition-colors ml-4">
<div>
<p className="font-medium text-gray-900">Reboot Delay</p>
<p className="text-sm text-gray-500">Seconds before reboot is initiated — gives the user time to see what is happening</p>
</div>
<div className="flex items-center gap-2">
<input
type="number"
min={0}
max={3600}
value={rebootDelaySeconds}
onChange={(e) => setRebootDelaySeconds(Math.max(0, parseInt(e.target.value) || 0))}
className="w-20 px-3 py-1.5 border border-gray-300 rounded-lg text-sm text-gray-900 text-right focus:ring-2 focus:ring-violet-500 focus:border-violet-500"
/>
<span className="text-sm text-gray-500 whitespace-nowrap">seconds</span>
</div>
</div>
)}
{/* Geo Location */}
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-violet-200 transition-colors">
<div>
<p className="font-medium text-gray-900">Geo-Location Detection</p>
<p className="text-sm text-gray-500">Capture device location, ISP and network info at enrollment start (queries external IP service)</p>
</div>
<button onClick={() => setEnableGeoLocation(!enableGeoLocation)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors ${enableGeoLocation ? 'bg-violet-500' : 'bg-gray-300'}`}>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${enableGeoLocation ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
{/* Timezone Auto-Set (sub-toggle of Geo-Location) */}
{enableGeoLocation && (
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-violet-200 transition-colors ml-4">
<div>
<p className="font-medium text-gray-900">Set Timezone Automatically</p>
<p className="text-sm text-gray-500">Set the device timezone based on IP geolocation result (uses tzutil /s)</p>
</div>
<button onClick={() => setEnableTimezoneAutoSet(!enableTimezoneAutoSet)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors ${enableTimezoneAutoSet ? 'bg-violet-500' : 'bg-gray-300'}`}>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${enableTimezoneAutoSet ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
)}
{/* Delivery Optimization Group ID */}
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-violet-200 transition-colors">
<div>
<p className="font-medium text-gray-900">Set Delivery Optimization Group ID</p>
<p className="text-sm text-gray-500">
Set the DOGroupId policy from a network fingerprint — a deterministic GUID derived from the default gateway&apos;s IP and MAC address, so devices on the same local network peer with each other. Takes effect with DO Download Mode &quot;Group (2)&quot;; an existing DOGroupId or DOGroupIdSource policy (Intune/GPO) is never overwritten.
</p>
</div>
<button onClick={() => setEnableDoGroupIdAutoSet(!enableDoGroupIdAutoSet)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors ${enableDoGroupIdAutoSet ? 'bg-violet-500' : 'bg-gray-300'}`}>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${enableDoGroupIdAutoSet ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
{/* Keep Awake During User-ESP */}
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-violet-200 transition-colors">
<div>
<p className="font-medium text-gray-900">Keep Awake During User-ESP</p>
<p className="text-sm text-gray-500">Hold the device awake (system and display) during the User-ESP (Account Setup) phase so idle standby or sleep cannot stall app installs and account provisioning. Reboots are unaffected; the hold is released once the phase completes.</p>
</div>
<button onClick={() => setKeepAwakeDuringUserEsp(!keepAwakeDuringUserEsp)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors ${keepAwakeDuringUserEsp ? 'bg-violet-500' : 'bg-gray-300'}`}>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${keepAwakeDuringUserEsp ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
{/* IME Match Log */}
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-violet-200 transition-colors">
<div>
<p className="font-medium text-gray-900">IME Pattern Match Log</p>
<p className="text-sm text-gray-500">
Write every matched IME log line to a local file for diagnostics
{enableImeMatchLog && <span className="block text-xs text-gray-400 mt-0.5 font-mono">%ProgramData%\AutopilotMonitor\Logs\ime-pattern-matches.log</span>}
</p>
</div>
<button onClick={() => setEnableImeMatchLog(!enableImeMatchLog)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors ${enableImeMatchLog ? 'bg-violet-500' : 'bg-gray-300'}`}>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${enableImeMatchLog ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
{/* Gather Rule Debug Log */}
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-violet-200 transition-colors">
<div>
<p className="font-medium text-gray-900">Gather Rule Debug Log</p>
<p className="text-sm text-gray-500">
Trace gather rule evaluation to a local file to diagnose rules that produce no timeline events. The trace never leaves the device.
{enableGatherRuleDebugLog && <span className="block text-xs text-gray-400 mt-0.5 font-mono">%ProgramData%\AutopilotMonitor\Logs\gather-rules-debug.log</span>}
</p>
</div>
<button onClick={() => setEnableGatherRuleDebugLog(!enableGatherRuleDebugLog)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors ${enableGatherRuleDebugLog ? 'bg-violet-500' : 'bg-gray-300'}`}>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${enableGatherRuleDebugLog ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
{/* Show Script Output */}
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-violet-200 transition-colors">
<div className="flex-1">
<p className="font-medium text-gray-900">Show Script Output (stdout)</p>
<p className="text-sm text-gray-500">Show standard output from PowerShell scripts in the timeline. Disable if scripts may output sensitive data. Error output (stderr) is always shown.</p>
</div>
<button onClick={() => setShowScriptOutput(!showScriptOutput)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors ${showScriptOutput ? 'bg-violet-500' : 'bg-gray-300'}`}>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${showScriptOutput ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
{/* Log Level */}
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-violet-200 transition-colors">
<div>
<p className="font-medium text-gray-900">Log Level</p>
<p className="text-sm text-gray-500">Agent log verbosity — Info shows the enrollment flow, Debug adds detail, Verbose adds every detail, Trace adds each internal step (every watcher, poll and queue event)</p>
</div>
<select
value={logLevel}
onChange={(e) => setLogLevel(e.target.value)}
className="px-3 py-1.5 border border-gray-300 rounded-lg text-sm text-gray-900 focus:ring-2 focus:ring-violet-500 focus:border-violet-500"
>
<option value="Info">Info</option>
<option value="Debug">Debug</option>
<option value="Verbose">Verbose</option>
<option value="Trace">Trace</option>
</select>
</div>
{/* Enrollment Summary Dialog */}
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-violet-200 transition-colors">
<div className="flex-1">
<p className="font-medium text-gray-900">Show Enrollment Summary</p>
<p className="text-sm text-gray-500">Display a visual enrollment summary dialog to the end user after enrollment completes (success or failure). Requires the SummaryDialog companion to be deployed alongside the agent.</p>
</div>
<button onClick={() => setShowEnrollmentSummary(!showEnrollmentSummary)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors ${showEnrollmentSummary ? 'bg-violet-500' : 'bg-gray-300'}`}>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${showEnrollmentSummary ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
{showEnrollmentSummary && (
<>
{/* Auto-close timeout */}
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-violet-200 transition-colors ml-4">
<div>
<p className="font-medium text-gray-900">Auto-Close Timeout</p>
<p className="text-sm text-gray-500">Seconds until the summary dialog closes automatically. 0 = no auto-close.</p>
</div>
<div className="flex items-center gap-2">
<input
type="number"
min={0}
max={3600}
value={enrollmentSummaryTimeoutSeconds}
onChange={(e) => setEnrollmentSummaryTimeoutSeconds(Math.max(0, parseInt(e.target.value) || 0))}
className="w-20 px-3 py-1.5 border border-gray-300 rounded-lg text-sm text-gray-900 text-right focus:ring-2 focus:ring-violet-500 focus:border-violet-500"
/>
<span className="text-sm text-gray-500 whitespace-nowrap">seconds</span>
</div>
</div>
{/* Launch Retry Timeout */}
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-violet-200 transition-colors ml-4">
<div>
<p className="font-medium text-gray-900">Launch Retry Timeout</p>
<p className="text-sm text-gray-500">How long the agent retries launching the dialog when the desktop is locked by a credential UI (e.g. Windows Hello). 0 = no retry.</p>
</div>
<div className="flex items-center gap-2 ml-4">
<input
type="number"
min={0}
max={3600}
value={enrollmentSummaryLaunchRetrySeconds}
onChange={(e) => setEnrollmentSummaryLaunchRetrySeconds(Math.min(3600, Math.max(0, parseInt(e.target.value) || 0)))}
className="w-20 px-3 py-1.5 border border-gray-300 rounded-lg text-sm text-gray-900 text-right focus:ring-2 focus:ring-violet-500 focus:border-violet-500"
/>
<span className="text-sm text-gray-500 whitespace-nowrap">seconds</span>
</div>
</div>
{/* Branding Image URL */}
<div className="p-4 rounded-lg border border-gray-200 hover:border-violet-200 transition-colors ml-4">
<div className="mb-2">
<p className="font-medium text-gray-900">Branding Image URL</p>
<p className="text-sm text-gray-500">Optional banner image at the top of the summary dialog. Recommended size: 540 x 80 px. Larger images will be center-cropped.</p>
</div>
<input
type="url"
value={enrollmentSummaryBrandingImageUrl}
onChange={(e) => setEnrollmentSummaryBrandingImageUrl(e.target.value)}
placeholder="https://example.com/branding-banner.png"
className="w-full px-3 py-1.5 border border-gray-300 rounded-lg text-sm text-gray-900 focus:ring-2 focus:ring-violet-500 focus:border-violet-500"
/>
</div>
</>
)}
</div>
</ReadOnlyFieldset>
{!readOnly && <SaveBar onSave={onSave} saving={saving} />}
</div>
</div>
);
}
@@ -1,480 +0,0 @@
"use client";
import SaveResetBar from "./SaveResetBar";
import ReadOnlyFieldset from "./ReadOnlyFieldset";
import { SectionCardHeader } from "@/components/SectionCardHeader";
import { DOCS_PATHS } from "@/lib/docsPaths";
import {
clampHelloWaitTimeoutSeconds,
HELLO_WAIT_TIMEOUT_DEFAULT_SECONDS,
HELLO_WAIT_TIMEOUT_MAX_SECONDS,
HELLO_WAIT_TIMEOUT_MIN_SECONDS,
} from "../lib/helloWaitTimeout";
interface AgentSettingsSectionProps {
enablePerformanceCollector: boolean;
setEnablePerformanceCollector: (value: boolean) => void;
performanceCollectorInterval: number;
setPerformanceCollectorInterval: (value: number) => void;
helloWaitTimeoutSeconds: number;
setHelloWaitTimeoutSeconds: (value: number) => void;
enableRealmJoinWatcher: boolean;
setEnableRealmJoinWatcher: (value: boolean) => void;
enableConfigMgrWatcher: boolean;
setEnableConfigMgrWatcher: (value: boolean) => void;
selfDestructOnComplete: boolean;
setSelfDestructOnComplete: (value: boolean) => void;
keepLogFile: boolean;
setKeepLogFile: (value: boolean) => void;
rebootOnComplete: boolean;
setRebootOnComplete: (value: boolean) => void;
rebootDelaySeconds: number;
setRebootDelaySeconds: (value: number) => void;
enableGeoLocation: boolean;
setEnableGeoLocation: (value: boolean) => void;
enableTimezoneAutoSet: boolean;
setEnableTimezoneAutoSet: (value: boolean) => void;
enableDoGroupIdAutoSet: boolean;
setEnableDoGroupIdAutoSet: (value: boolean) => void;
keepAwakeDuringUserEsp: boolean;
setKeepAwakeDuringUserEsp: (value: boolean) => void;
enableImeMatchLog: boolean;
setEnableImeMatchLog: (value: boolean) => void;
enableGatherRuleDebugLog: boolean;
setEnableGatherRuleDebugLog: (value: boolean) => void;
logLevel: string;
setLogLevel: (value: string) => void;
showScriptOutput: boolean;
setShowScriptOutput: (value: boolean) => void;
showEnrollmentSummary: boolean;
setShowEnrollmentSummary: (value: boolean) => void;
enrollmentSummaryTimeoutSeconds: number;
setEnrollmentSummaryTimeoutSeconds: (value: number) => void;
enrollmentSummaryBrandingImageUrl: string;
setEnrollmentSummaryBrandingImageUrl: (value: string) => void;
enrollmentSummaryLaunchRetrySeconds: number;
setEnrollmentSummaryLaunchRetrySeconds: (value: number) => void;
onSave: () => Promise<void> | void;
onReset: () => void;
saving: boolean;
/** Read-only viewer (Operator): settings visible but inert, no Save/Reset bar. */
readOnly?: boolean;
}
export default function AgentSettingsSection({
enablePerformanceCollector,
setEnablePerformanceCollector,
performanceCollectorInterval,
setPerformanceCollectorInterval,
helloWaitTimeoutSeconds,
setHelloWaitTimeoutSeconds,
enableRealmJoinWatcher,
setEnableRealmJoinWatcher,
enableConfigMgrWatcher,
setEnableConfigMgrWatcher,
selfDestructOnComplete,
setSelfDestructOnComplete,
keepLogFile,
setKeepLogFile,
rebootOnComplete,
setRebootOnComplete,
rebootDelaySeconds,
setRebootDelaySeconds,
enableGeoLocation,
setEnableGeoLocation,
enableTimezoneAutoSet,
setEnableTimezoneAutoSet,
enableDoGroupIdAutoSet,
setEnableDoGroupIdAutoSet,
keepAwakeDuringUserEsp,
setKeepAwakeDuringUserEsp,
enableImeMatchLog,
setEnableImeMatchLog,
enableGatherRuleDebugLog,
setEnableGatherRuleDebugLog,
logLevel,
setLogLevel,
showScriptOutput,
setShowScriptOutput,
showEnrollmentSummary,
setShowEnrollmentSummary,
enrollmentSummaryTimeoutSeconds,
setEnrollmentSummaryTimeoutSeconds,
enrollmentSummaryBrandingImageUrl,
setEnrollmentSummaryBrandingImageUrl,
enrollmentSummaryLaunchRetrySeconds,
setEnrollmentSummaryLaunchRetrySeconds,
onSave,
onReset,
saving,
readOnly = false,
}: AgentSettingsSectionProps) {
return (
<>
{/* Agent Parameters */}
<div className="bg-white rounded-lg shadow">
<SectionCardHeader
tone="violet"
iconPath="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4"
title="Agent Parameters"
subtitle="Control agent behavior on enrolled devices. Changes take effect on the next agent config refresh."
docsPath={DOCS_PATHS.agentParameters}
/>
<div className="p-6 space-y-4">
<ReadOnlyFieldset readOnly={readOnly}>
<div className="space-y-4">
{/* Self-Destruct */}
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-violet-200 transition-colors">
<div>
<p className="font-medium text-gray-900">Self-Destruct on Complete</p>
<p className="text-sm text-gray-500">Remove Scheduled Task and all agent files when enrollment completes</p>
</div>
<button onClick={() => setSelfDestructOnComplete(!selfDestructOnComplete)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors ${selfDestructOnComplete ? 'bg-violet-500' : 'bg-gray-300'}`}>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${selfDestructOnComplete ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
{/* Keep Log File */}
{selfDestructOnComplete && (
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-violet-200 transition-colors ml-4">
<div>
<p className="font-medium text-gray-900">Keep Log File</p>
<p className="text-sm text-gray-500">Preserve the agent log during self-destruct (all other files are removed)</p>
</div>
<button onClick={() => setKeepLogFile(!keepLogFile)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors ${keepLogFile ? 'bg-violet-500' : 'bg-gray-300'}`}>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${keepLogFile ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
)}
{/* Reboot on Complete */}
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-violet-200 transition-colors">
<div>
<p className="font-medium text-gray-900">Reboot on Complete</p>
<p className="text-sm text-gray-500">Reboot the device after enrollment completes (and after self-destruct if enabled)</p>
</div>
<button onClick={() => setRebootOnComplete(!rebootOnComplete)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors ${rebootOnComplete ? 'bg-violet-500' : 'bg-gray-300'}`}>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${rebootOnComplete ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
{rebootOnComplete && (
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-violet-200 transition-colors ml-4">
<div>
<p className="font-medium text-gray-900">Reboot Delay</p>
<p className="text-sm text-gray-500">Seconds before reboot is initiated — gives the user time to see what is happening</p>
</div>
<div className="flex items-center gap-2">
<input
type="number"
min={0}
max={3600}
value={rebootDelaySeconds}
onChange={(e) => setRebootDelaySeconds(Math.max(0, parseInt(e.target.value) || 0))}
className="w-20 px-3 py-1.5 border border-gray-300 rounded-lg text-sm text-gray-900 text-right focus:ring-2 focus:ring-violet-500 focus:border-violet-500"
/>
<span className="text-sm text-gray-500 whitespace-nowrap">seconds</span>
</div>
</div>
)}
{/* Geo Location */}
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-violet-200 transition-colors">
<div>
<p className="font-medium text-gray-900">Geo-Location Detection</p>
<p className="text-sm text-gray-500">Capture device location, ISP and network info at enrollment start (queries external IP service)</p>
</div>
<button onClick={() => setEnableGeoLocation(!enableGeoLocation)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors ${enableGeoLocation ? 'bg-violet-500' : 'bg-gray-300'}`}>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${enableGeoLocation ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
{/* Timezone Auto-Set (sub-toggle of Geo-Location) */}
{enableGeoLocation && (
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-violet-200 transition-colors ml-4">
<div>
<p className="font-medium text-gray-900">Set Timezone Automatically</p>
<p className="text-sm text-gray-500">Set the device timezone based on IP geolocation result (uses tzutil /s)</p>
</div>
<button onClick={() => setEnableTimezoneAutoSet(!enableTimezoneAutoSet)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors ${enableTimezoneAutoSet ? 'bg-violet-500' : 'bg-gray-300'}`}>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${enableTimezoneAutoSet ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
)}
{/* Delivery Optimization Group ID */}
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-violet-200 transition-colors">
<div>
<p className="font-medium text-gray-900">Set Delivery Optimization Group ID</p>
<p className="text-sm text-gray-500">
Set the DOGroupId policy from a network fingerprint — a deterministic GUID derived from the default gateway&apos;s IP and MAC address, so devices on the same local network peer with each other. Takes effect with DO Download Mode &quot;Group (2)&quot;; an existing DOGroupId or DOGroupIdSource policy (Intune/GPO) is never overwritten.
</p>
</div>
<button onClick={() => setEnableDoGroupIdAutoSet(!enableDoGroupIdAutoSet)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors ${enableDoGroupIdAutoSet ? 'bg-violet-500' : 'bg-gray-300'}`}>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${enableDoGroupIdAutoSet ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
{/* Keep Awake During User-ESP */}
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-violet-200 transition-colors">
<div>
<p className="font-medium text-gray-900">Keep Awake During User-ESP</p>
<p className="text-sm text-gray-500">Hold the device awake (system and display) during the User-ESP (Account Setup) phase so idle standby or sleep cannot stall app installs and account provisioning. Reboots are unaffected; the hold is released once the phase completes.</p>
</div>
<button onClick={() => setKeepAwakeDuringUserEsp(!keepAwakeDuringUserEsp)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors ${keepAwakeDuringUserEsp ? 'bg-violet-500' : 'bg-gray-300'}`}>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${keepAwakeDuringUserEsp ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
{/* IME Match Log */}
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-violet-200 transition-colors">
<div>
<p className="font-medium text-gray-900">IME Pattern Match Log</p>
<p className="text-sm text-gray-500">
Write every matched IME log line to a local file for diagnostics
{enableImeMatchLog && <span className="block text-xs text-gray-400 mt-0.5 font-mono">%ProgramData%\AutopilotMonitor\Logs\ime-pattern-matches.log</span>}
</p>
</div>
<button onClick={() => setEnableImeMatchLog(!enableImeMatchLog)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors ${enableImeMatchLog ? 'bg-violet-500' : 'bg-gray-300'}`}>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${enableImeMatchLog ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
{/* Gather Rule Debug Log */}
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-violet-200 transition-colors">
<div>
<p className="font-medium text-gray-900">Gather Rule Debug Log</p>
<p className="text-sm text-gray-500">
Trace gather rule evaluation to a local file to diagnose rules that produce no timeline events. The trace never leaves the device.
{enableGatherRuleDebugLog && <span className="block text-xs text-gray-400 mt-0.5 font-mono">%ProgramData%\AutopilotMonitor\Logs\gather-rules-debug.log</span>}
</p>
</div>
<button onClick={() => setEnableGatherRuleDebugLog(!enableGatherRuleDebugLog)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors ${enableGatherRuleDebugLog ? 'bg-violet-500' : 'bg-gray-300'}`}>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${enableGatherRuleDebugLog ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
{/* Show Script Output */}
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-violet-200 transition-colors">
<div className="flex-1">
<p className="font-medium text-gray-900">Show Script Output (stdout)</p>
<p className="text-sm text-gray-500">Show standard output from PowerShell scripts in the timeline. Disable if scripts may output sensitive data. Error output (stderr) is always shown.</p>
</div>
<button onClick={() => setShowScriptOutput(!showScriptOutput)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors ${showScriptOutput ? 'bg-violet-500' : 'bg-gray-300'}`}>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${showScriptOutput ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
{/* Log Level */}
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-violet-200 transition-colors">
<div>
<p className="font-medium text-gray-900">Log Level</p>
<p className="text-sm text-gray-500">Agent log verbosity — Info shows the enrollment flow, Debug adds detail, Verbose adds every detail, Trace adds each internal step (every watcher, poll and queue event)</p>
</div>
<select
value={logLevel}
onChange={(e) => setLogLevel(e.target.value)}
className="px-3 py-1.5 border border-gray-300 rounded-lg text-sm text-gray-900 focus:ring-2 focus:ring-violet-500 focus:border-violet-500"
>
<option value="Info">Info</option>
<option value="Debug">Debug</option>
<option value="Verbose">Verbose</option>
<option value="Trace">Trace</option>
</select>
</div>
{/* Enrollment Summary Dialog */}
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-violet-200 transition-colors">
<div className="flex-1">
<p className="font-medium text-gray-900">Show Enrollment Summary</p>
<p className="text-sm text-gray-500">Display a visual enrollment summary dialog to the end user after enrollment completes (success or failure). Requires the SummaryDialog companion to be deployed alongside the agent.</p>
</div>
<button onClick={() => setShowEnrollmentSummary(!showEnrollmentSummary)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors ${showEnrollmentSummary ? 'bg-violet-500' : 'bg-gray-300'}`}>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${showEnrollmentSummary ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
{showEnrollmentSummary && (
<>
{/* Auto-close timeout */}
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-violet-200 transition-colors ml-4">
<div>
<p className="font-medium text-gray-900">Auto-Close Timeout</p>
<p className="text-sm text-gray-500">Seconds until the summary dialog closes automatically. 0 = no auto-close.</p>
</div>
<div className="flex items-center gap-2">
<input
type="number"
min={0}
max={3600}
value={enrollmentSummaryTimeoutSeconds}
onChange={(e) => setEnrollmentSummaryTimeoutSeconds(Math.max(0, parseInt(e.target.value) || 0))}
className="w-20 px-3 py-1.5 border border-gray-300 rounded-lg text-sm text-gray-900 text-right focus:ring-2 focus:ring-violet-500 focus:border-violet-500"
/>
<span className="text-sm text-gray-500 whitespace-nowrap">seconds</span>
</div>
</div>
{/* Launch Retry Timeout */}
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-violet-200 transition-colors ml-4">
<div>
<p className="font-medium text-gray-900">Launch Retry Timeout</p>
<p className="text-sm text-gray-500">How long the agent retries launching the dialog when the desktop is locked by a credential UI (e.g. Windows Hello). 0 = no retry.</p>
</div>
<div className="flex items-center gap-2 ml-4">
<input
type="number"
min={0}
max={3600}
value={enrollmentSummaryLaunchRetrySeconds}
onChange={(e) => setEnrollmentSummaryLaunchRetrySeconds(Math.min(3600, Math.max(0, parseInt(e.target.value) || 0)))}
className="w-20 px-3 py-1.5 border border-gray-300 rounded-lg text-sm text-gray-900 text-right focus:ring-2 focus:ring-violet-500 focus:border-violet-500"
/>
<span className="text-sm text-gray-500 whitespace-nowrap">seconds</span>
</div>
</div>
{/* Branding Image URL */}
<div className="p-4 rounded-lg border border-gray-200 hover:border-violet-200 transition-colors ml-4">
<div className="mb-2">
<p className="font-medium text-gray-900">Branding Image URL</p>
<p className="text-sm text-gray-500">Optional banner image at the top of the summary dialog. Recommended size: 540 x 80 px. Larger images will be center-cropped.</p>
</div>
<input
type="url"
value={enrollmentSummaryBrandingImageUrl}
onChange={(e) => setEnrollmentSummaryBrandingImageUrl(e.target.value)}
placeholder="https://example.com/branding-banner.png"
className="w-full px-3 py-1.5 border border-gray-300 rounded-lg text-sm text-gray-900 focus:ring-2 focus:ring-violet-500 focus:border-violet-500"
/>
</div>
</>
)}
</div>
</ReadOnlyFieldset>
</div>
</div>
{/* Agent Collectors */}
<div className="bg-white rounded-lg shadow">
<SectionCardHeader
tone="emerald"
iconPath="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
title="Agent Collectors"
subtitle="Enable optional data collectors on enrolled devices. These generate additional telemetry traffic."
docsPath={DOCS_PATHS.agentCollectors}
/>
<div className="p-6 space-y-5">
<ReadOnlyFieldset readOnly={readOnly} notice={false}>
<div className="space-y-5">
{/* Performance Collector */}
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-emerald-200 transition-colors">
<div className="flex-1">
<div className="flex items-center space-x-2">
<p className="font-medium text-gray-900">Performance Collector</p>
</div>
<p className="text-sm text-gray-500 mt-1">CPU, memory, disk metrics at configurable intervals</p>
{enablePerformanceCollector && (
<div className="mt-2">
<div className="flex items-center space-x-2">
<span className="text-sm text-gray-600">Interval:</span>
<input
type="number"
min={30}
max={300}
value={performanceCollectorInterval}
onChange={(e) => setPerformanceCollectorInterval(parseInt(e.target.value) || 30)}
onBlur={() => setPerformanceCollectorInterval(Math.max(30, Math.min(300, performanceCollectorInterval)))}
className="w-20 px-2 py-1 border border-gray-300 rounded text-sm text-gray-900 focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
/>
<span className="text-sm text-gray-500">seconds</span>
</div>
<p className="text-xs text-gray-400 mt-1">Minimum: 30 seconds, Maximum: 300 seconds (5 minutes)</p>
</div>
)}
</div>
<button
onClick={() => setEnablePerformanceCollector(!enablePerformanceCollector)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors ${enablePerformanceCollector ? 'bg-emerald-500' : 'bg-gray-300'}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${enablePerformanceCollector ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
{/* RealmJoin Watcher */}
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-emerald-200 transition-colors">
<div className="flex-1">
<p className="font-medium text-gray-900">RealmJoin Watcher</p>
<p className="text-sm text-gray-500 mt-1">Tracks RealmJoin deployment state during provisioning — deployment phase, per-package start and completion, and the RealmJoin completion gate. Off by default; enable only for tenants that provision devices with RealmJoin, elsewhere it produces no signal.</p>
</div>
<button
onClick={() => setEnableRealmJoinWatcher(!enableRealmJoinWatcher)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors ${enableRealmJoinWatcher ? 'bg-emerald-500' : 'bg-gray-300'}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${enableRealmJoinWatcher ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
{/* ConfigMgr Watcher */}
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-emerald-200 transition-colors">
<div className="flex-1">
<p className="font-medium text-gray-900">ConfigMgr Watcher</p>
<p className="text-sm text-gray-500 mt-1">Shows in Session Info whether the Configuration Manager client is installed, with its service state, version and site code. Off by default; enable for tenants that co-manage devices with Configuration Manager.</p>
</div>
<button
onClick={() => setEnableConfigMgrWatcher(!enableConfigMgrWatcher)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors ${enableConfigMgrWatcher ? 'bg-emerald-500' : 'bg-gray-300'}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${enableConfigMgrWatcher ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
{/* Hello Wait Timeout */}
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-emerald-200 transition-colors">
<div className="flex-1">
<div className="flex items-center space-x-2">
<p className="font-medium text-gray-900">Hello Wait Timeout</p>
</div>
<p className="text-sm text-gray-500 mt-1">Seconds to wait for the Windows Hello wizard after ESP exit</p>
<div className="mt-2">
<div className="flex items-center space-x-2">
<span className="text-sm text-gray-600">Timeout:</span>
<input
type="number"
min={HELLO_WAIT_TIMEOUT_MIN_SECONDS}
max={HELLO_WAIT_TIMEOUT_MAX_SECONDS}
value={helloWaitTimeoutSeconds}
onChange={(e) => setHelloWaitTimeoutSeconds(parseInt(e.target.value) || HELLO_WAIT_TIMEOUT_DEFAULT_SECONDS)}
onBlur={() => setHelloWaitTimeoutSeconds(clampHelloWaitTimeoutSeconds(helloWaitTimeoutSeconds))}
className="w-20 px-2 py-1 border border-gray-300 rounded text-sm text-gray-900 focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
/>
<span className="text-sm text-gray-500">seconds</span>
</div>
<p className="text-xs text-gray-400 mt-1">Minimum: 30 seconds, Maximum: 3600 seconds (1 hour). Values up to 300 seconds use the built-in 5-minute wait.</p>
</div>
</div>
</div>
</div>
</ReadOnlyFieldset>
{/* One Save/Reset bar for both cards: Agent Parameters and Agent Collectors are one
section (agentSettings) and are saved together. */}
{!readOnly && <SaveResetBar onSave={onSave} onReset={onReset} saving={saving} />}
</div>
</div>
</>
);
}
@@ -1,6 +1,6 @@
"use client";
import SaveResetBar from "./SaveResetBar";
import SaveBar from "./SaveBar";
import ReadOnlyFieldset from "./ReadOnlyFieldset";
import { SectionCardHeader } from "@/components/SectionCardHeader";
import { DOCS_PATHS } from "@/lib/docsPaths";
@@ -14,9 +14,8 @@ interface DataManagementSectionProps {
/** Edition retention cap (Community 90 / Pro 365) — from feature-flags entitlements. */
retentionCapDays?: number;
onSave: () => Promise<void> | void;
onReset: () => void;
saving: boolean;
/** Read-only viewer (Operator): values visible but inert, no Save/Reset bar. */
/** Read-only viewer (Operator): values visible but inert, no Save bar. */
readOnly?: boolean;
}
@@ -28,7 +27,6 @@ export default function DataManagementSection({
isGlobalAdmin,
retentionCapDays = 90,
onSave,
onReset,
saving,
readOnly = false,
}: DataManagementSectionProps) {
@@ -125,7 +123,7 @@ export default function DataManagementSection({
</div>
</ReadOnlyFieldset>
{!readOnly && <SaveResetBar onSave={onSave} onReset={onReset} saving={saving} />}
{!readOnly && <SaveBar onSave={onSave} saving={saving} />}
</div>
</div>
);
@@ -7,7 +7,7 @@ import { validateDiagnosticsPath } from "@/lib/guardValidation";
import { ValidationIndicator } from "@/components/ValidationIndicator";
import { BuiltInSectionsList } from "@/components/diagnostics/BuiltInSectionsList";
import { ContextPill, DiagnosticsPathRow } from "@/components/diagnostics/DiagnosticsPathRow";
import SaveResetBar from "./SaveResetBar";
import SaveBar from "./SaveBar";
import ReadOnlyFieldset from "./ReadOnlyFieldset";
import { parseSasExpiry } from "./diagnosticsSasExpiry";
import { SectionCardHeader } from "@/components/SectionCardHeader";
@@ -39,9 +39,8 @@ interface DiagnosticsSectionProps {
setNewDiagDesc: (value: string) => void;
unrestrictedMode?: boolean;
onSave: () => Promise<void> | void;
onReset: () => void;
saving: boolean;
/** Read-only viewer (Operator): settings visible but inert, no Save/Reset bar. */
/** Read-only viewer (Operator): settings visible but inert, no Save bar. */
readOnly?: boolean;
}
@@ -64,7 +63,6 @@ export default function DiagnosticsSection({
setNewDiagDesc,
unrestrictedMode = false,
onSave,
onReset,
saving,
readOnly = false,
}: DiagnosticsSectionProps) {
@@ -368,7 +366,7 @@ export default function DiagnosticsSection({
</div>
</ReadOnlyFieldset>
{!readOnly && <SaveResetBar onSave={onSave} onReset={onReset} saving={saving} />}
{!readOnly && <SaveBar onSave={onSave} saving={saving} />}
</div>
</div>
);
@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import SaveResetBar from "./SaveResetBar";
import SaveBar from "./SaveBar";
import ReadOnlyFieldset from "./ReadOnlyFieldset";
import { joinList, parseList } from "../lib/hardwareWhitelist";
import { SectionCardHeader } from "@/components/SectionCardHeader";
@@ -13,9 +13,8 @@ interface HardwareWhitelistSectionProps {
modelWhitelist: string;
setModelWhitelist: (value: string) => void;
onSave: () => Promise<void> | void;
onReset: () => void;
saving: boolean;
/** Read-only viewer (Operator): inputs disabled, no Save/Reset bar. */
/** Read-only viewer (Operator): inputs disabled, no Save bar. */
readOnly?: boolean;
}
@@ -129,7 +128,6 @@ export default function HardwareWhitelistSection({
modelWhitelist,
setModelWhitelist,
onSave,
onReset,
saving,
readOnly = false,
}: HardwareWhitelistSectionProps) {
@@ -188,7 +186,7 @@ export default function HardwareWhitelistSection({
</div>
</ReadOnlyFieldset>
{!readOnly && <SaveResetBar onSave={onSave} onReset={onReset} saving={saving} />}
{!readOnly && <SaveBar onSave={onSave} saving={saving} />}
</div>
</div>
);
@@ -1,6 +1,6 @@
"use client";
import SaveResetBar from "./SaveResetBar";
import SaveBar from "./SaveBar";
import ReadOnlyFieldset from "./ReadOnlyFieldset";
import { MAX_NOTIFICATION_CHANNELS, NotificationChannel } from "../types";
import { ChannelEditor } from "@/components/notifications/ChannelEditor";
@@ -16,9 +16,8 @@ interface NotificationsSectionProps {
testingChannelId: string | null;
testChannelResult: { channelId: string; success: boolean; message: string } | null;
onSave: () => Promise<void> | void;
onReset: () => void;
saving: boolean;
/** Read-only viewer (Operator): channels visible but inert, no Save/Reset bar. */
/** Read-only viewer (Operator): channels visible but inert, no Save bar. */
readOnly?: boolean;
/** Global Admin: the platform-bot Telegram provider is offered in the provider dropdown. */
showTelegramProvider?: boolean;
@@ -34,7 +33,6 @@ export default function NotificationsSection({
testingChannelId,
testChannelResult,
onSave,
onReset,
saving,
readOnly = false,
showTelegramProvider = false,
@@ -103,7 +101,7 @@ export default function NotificationsSection({
</div>
</ReadOnlyFieldset>
{!readOnly && <SaveResetBar onSave={onSave} onReset={onReset} saving={saving} />}
{!readOnly && <SaveBar onSave={onSave} saving={saving} />}
</div>
</div>
);
@@ -4,7 +4,7 @@
* Wraps a settings section's interactive content for read-only viewers (Operators).
* The native fieldset `disabled` attribute inertly disables every nested input, select,
* textarea and button while values stay visible and copyable; a small notice explains why.
* Sections must ADDITIONALLY not render their SaveResetBar when read-only — a read-only
* Sections must ADDITIONALLY not render their SaveBar when read-only — a read-only
* viewer gets no save affordance at all, not a disabled one.
*/
export default function ReadOnlyFieldset({
@@ -1,54 +1,57 @@
"use client";
import { useState, useCallback } from "react";
import { useState, useCallback, useRef } from "react";
import { apiErrorText } from "@/lib/apiClient";
interface SaveResetBarProps {
interface SaveBarProps {
/** A failed save throws: the bar shows the reason next to the button, where the user is looking. */
onSave: () => Promise<void> | void;
onReset: () => void;
saving: boolean;
/** When false, Save/Reset are disabled (read-only viewer). Defaults to true. */
/** When false, Save is disabled (read-only viewer). Defaults to true. */
canSave?: boolean;
/** Input the user can still fix: Save is disabled with this reason. */
saveBlockedReason?: string;
}
export default function SaveResetBar({ onSave, onReset, saving, canSave = true }: SaveResetBarProps) {
export default function SaveBar({ onSave, saving, canSave = true, saveBlockedReason }: SaveBarProps) {
const [saveResult, setSaveResult] = useState<"idle" | "saved" | "error">("idle");
const [failureReason, setFailureReason] = useState("");
const savedTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const handleSave = useCallback(async () => {
clearTimeout(savedTimer.current);
setSaveResult("idle");
try {
await onSave();
setSaveResult("saved");
setTimeout(() => setSaveResult("idle"), 3000);
} catch {
savedTimer.current = setTimeout(() => setSaveResult("idle"), 3000);
} catch (err) {
// Stays until the next Save: unlike "Saved!" it needs reading and acting on.
setFailureReason(apiErrorText(err, ""));
setSaveResult("error");
setTimeout(() => setSaveResult("idle"), 4000);
}
}, [onSave]);
return (
<div className="flex items-center justify-end space-x-3 pt-4 border-t border-gray-200">
<div className="flex flex-wrap items-center justify-end gap-3 pt-4 border-t border-gray-200">
{!canSave && (
<span className="text-sm text-gray-400">Read-only</span>
)}
{saveBlockedReason && (
<span className="min-w-0 break-words text-sm text-amber-600">{saveBlockedReason}</span>
)}
{/* Inline feedback (errors only — success is shown via button state) */}
{saveResult === "error" && (
<span className="flex items-center text-sm text-red-600 font-medium">
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<span role="alert" className="flex items-center min-w-0 text-sm text-red-600 font-medium">
<svg className="w-4 h-4 mr-1 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M6 18L18 6M6 6l12 12" />
</svg>
Save failed
<span className="min-w-0 break-words">Save failed{failureReason && `: ${failureReason}`}</span>
</span>
)}
<button
onClick={onReset}
disabled={saving || !canSave}
className="px-4 py-2 border border-gray-300 rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors text-sm"
>
Reset
</button>
<button
onClick={handleSave}
disabled={saving || !canSave}
disabled={saving || !canSave || !!saveBlockedReason}
className={`px-4 py-2 rounded-md text-sm text-white font-medium disabled:opacity-50 disabled:cursor-not-allowed transition-all flex items-center space-x-2 ${
saveResult === "saved" ? "bg-emerald-600" : "bg-green-600 hover:bg-green-700"
}`}
@@ -11,7 +11,7 @@ export function SectionDataManagement() {
dataRetentionDays, setDataRetentionDays,
sessionTimeoutHours, setSessionTimeoutHours,
editionInfo,
handleSaveDataManagement, handleResetDataManagement,
handleSaveDataManagement,
savingSection,
} = useTenantConfig();
@@ -31,7 +31,6 @@ export function SectionDataManagement() {
isGlobalAdmin={isGlobalAdminView}
retentionCapDays={editionInfo.entitlements.retentionCapDays}
onSave={handleSaveDataManagement}
onReset={handleResetDataManagement}
saving={savingSection === "dataManagement"}
readOnly={!canEditConfig}
/>
@@ -61,13 +61,8 @@ export const SECTION_FIELD_MAP = {
contact: {
fields: ["contactEmail", "companyName"],
},
agentSettings: {
agentParameters: {
fields: [
"enablePerformanceCollector",
"performanceCollectorIntervalSeconds",
"helloWaitTimeoutSeconds",
"enableRealmJoinWatcher",
"enableConfigMgrWatcher",
"selfDestructOnComplete",
"keepLogFile",
"rebootOnComplete",
@@ -86,6 +81,15 @@ export const SECTION_FIELD_MAP = {
"enrollmentSummaryLaunchRetrySeconds",
],
},
agentCollectors: {
fields: [
"enablePerformanceCollector",
"performanceCollectorIntervalSeconds",
"helloWaitTimeoutSeconds",
"enableRealmJoinWatcher",
"enableConfigMgrWatcher",
],
},
agentAnalyzers: {
fields: [
"enableLocalAdminAnalyzer",
@@ -1,7 +1,7 @@
"use client";
import { useTenantConfig } from "../../TenantConfigContext";
import SaveResetBar from "../../components/SaveResetBar";
import SaveBar from "../../components/SaveBar";
import ReadOnlyFieldset from "../../components/ReadOnlyFieldset";
import { SectionCardHeader } from "@/components/SectionCardHeader";
import { DOCS_PATHS } from "@/lib/docsPaths";
@@ -11,7 +11,7 @@ export function SectionContact() {
canEditConfig,
contactEmail, setContactEmail,
companyName, setCompanyName,
handleSaveContact, handleResetContact,
handleSaveContact,
savingSection,
} = useTenantConfig();
@@ -19,6 +19,11 @@ export function SectionContact() {
const looksInvalid = trimmed.length > 0 && !trimmed.includes("@");
// Mirrors TenantConfigValidation.MaxCompanyNameLength — the backend rejects longer values.
const companyTooLong = companyName.trim().length > 200;
const saveBlockedReason = looksInvalid
? "Enter a valid email address to save."
: companyTooLong
? "Shorten the company name to save."
: undefined;
return (
<div className="bg-white rounded-lg shadow">
@@ -87,11 +92,10 @@ export function SectionContact() {
</div>
{canEditConfig && (
<SaveResetBar
<SaveBar
onSave={handleSaveContact}
onReset={handleResetContact}
saving={savingSection === "contact"}
canSave={!looksInvalid && !companyTooLong}
saveBlockedReason={saveBlockedReason}
/>
)}
</div>
@@ -17,7 +17,7 @@ export function SectionHardwareWhitelist() {
modelWhitelist, setModelWhitelist,
webhookNotifyOnHardwareRejection, setWebhookNotifyOnHardwareRejection,
notificationChannels,
handleSaveHardwareWhitelist, handleResetHardwareWhitelist,
handleSaveHardwareWhitelist,
savingSection,
} = useTenantConfig();
@@ -42,7 +42,6 @@ export function SectionHardwareWhitelist() {
modelWhitelist={modelWhitelist}
setModelWhitelist={setModelWhitelist}
onSave={handleSaveHardwareWhitelist}
onReset={handleResetHardwareWhitelist}
saving={savingSection === "hardwareWhitelist"}
readOnly={!canEditConfig}
/>
@@ -19,7 +19,7 @@ export function SectionNotifications() {
canEditConfig,
notificationChannels, setNotificationChannels,
handleTestChannel, testingChannelId, testChannelResult,
handleSaveNotifications, handleResetNotifications,
handleSaveNotifications,
savingSection,
} = useTenantConfig();
@@ -55,7 +55,6 @@ export function SectionNotifications() {
testingChannelId={testingChannelId}
testChannelResult={testChannelResult}
onSave={handleSaveNotifications}
onReset={handleResetNotifications}
saving={savingSection === "notifications"}
readOnly={!canEditConfig}
showTelegramProvider={showTelegramProvider}
@@ -1,7 +1,7 @@
"use client";
import { useTenantConfig } from "../../TenantConfigContext";
import SaveResetBar from "../../components/SaveResetBar";
import SaveBar from "../../components/SaveBar";
import ReadOnlyFieldset from "../../components/ReadOnlyFieldset";
import { trackEvent } from "@/lib/appInsights";
import { SectionCardHeader } from "@/components/SectionCardHeader";
@@ -19,7 +19,7 @@ export function SectionSlaTargets() {
slaNotifyOnAppInstallBreach, setSlaNotifyOnAppInstallBreach,
slaNotifyOnConsecutiveFailures, setSlaNotifyOnConsecutiveFailures,
slaConsecutiveFailureThreshold, setSlaConsecutiveFailureThreshold,
handleSaveSlaTargets, handleResetSlaTargets,
handleSaveSlaTargets,
savingSection,
} = useTenantConfig();
@@ -218,7 +218,7 @@ export function SectionSlaTargets() {
</ReadOnlyFieldset>
{canEditConfig && (
<SaveResetBar
<SaveBar
onSave={() => {
trackEvent('sla_targets_saved', {
hasSuccessRateTarget: slaTargetSuccessRate != null,
@@ -227,10 +227,6 @@ export function SectionSlaTargets() {
});
return handleSaveSlaTargets();
}}
onReset={() => {
trackEvent('sla_targets_reset');
return handleResetSlaTargets();
}}
saving={savingSection === "slaTargets"}
/>
)}
@@ -5,9 +5,11 @@ import { partitionHistoricReplayEvents, type ReplayInputEvent } from "@/lib/hist
import TruncatedLabel from "@/components/TruncatedLabel";
import PendingAppRow from "@/components/PendingAppRow";
import PhaseDivider from "@/components/PhaseDivider";
import ProgressPanelHeader from "@/components/ProgressPanelHeader";
import AssignmentPill from "@/components/AssignmentPill";
import { userPhaseSplitIndex, type UserPhaseBoundary } from "@/lib/userPhaseBoundary";
import { shouldSkipLowBytesTotal, shouldSkipNoActivity, hasByteActivity } from "@/lib/downloadProgressFilters";
import { frozenRowText } from "@/lib/sessionStatus";
import { formatBytes, formatThroughput, formatDuration } from "@/lib/formatting";
import DoBreakdownBar from "./DoBreakdownBar";
@@ -65,9 +67,11 @@ interface SummaryStats {
interface DownloadProgressProps {
events: DownloadEvent[];
summaryStats?: SummaryStats | null;
// Epoch ms of the agent's last report, passed only once the session is terminal. A download
// that never finished by then is incomplete, not active.
// Epoch ms of the agent's last report, passed once the session is terminal or the agent went
// silent. A download that never finished by then is not active any more.
observedUntilMs?: number | null;
// The session has not ended, data just stopped arriving: the row reads No data, not Incomplete.
agentSilent?: boolean;
// When the Enrollment Status Page entered Account Setup; null when the session has no such split.
userPhaseBoundary?: UserPhaseBoundary | null;
}
@@ -155,7 +159,7 @@ function effectiveDurationMs(dl: DownloadItem): number {
return 0;
}
export default function DownloadProgress({ events, summaryStats, observedUntilMs = null, userPhaseBoundary = null }: DownloadProgressProps) {
export default function DownloadProgress({ events, summaryStats, observedUntilMs = null, agentSilent = false, userPhaseBoundary = null }: DownloadProgressProps) {
// Legacy-agent guard: drop download events replayed from a previous enrollment's IME log.
// Silent (empty finals set) — the InstallProgress panel already reports the hidden count
// for the same apps; a second note here would double-report them.
@@ -276,8 +280,8 @@ export default function DownloadProgress({ events, summaryStats, observedUntilMs
// This avoids active items jumping when completion status changes.
return a.firstSeenIndex - b.firstSeenIndex;
});
// Observation ended (terminal session): a download that never finished stays at its last
// observed bytes and is reported as incomplete rather than active.
// Observation ended (terminal session or silent agent): a download that never finished stays
// at its last observed bytes and is not reported as active.
if (observedUntilMs == null) return folded;
return folded.map(d => (!d.isComplete && !d.isSkipped && d.hasByteEvidence ? { ...d, isIncomplete: true } : d));
}, [current, observedUntilMs]);
@@ -348,6 +352,7 @@ export default function DownloadProgress({ events, summaryStats, observedUntilMs
// rows without any observed download activity sit behind the "no download" toggle.
const activeCount = downloads.filter(d => !d.isComplete && !d.isSkipped && !d.isUninstall && d.hasByteEvidence && !d.isIncomplete).length;
const incompleteCount = downloads.filter(d => d.isIncomplete && !d.isUninstall).length;
const frozenText = frozenRowText("downloading", agentSilent);
const completedCount = downloads.filter(d => d.isComplete && !d.isSkipped && !d.isUninstall && d.hasByteEvidence).length;
const uninstallCount = downloads.filter(d => d.isUninstall && !d.isSkipped && d.hasByteEvidence).length;
const noDownloadCount = downloads.filter(d => !d.isSkipped && !d.hasByteEvidence).length;
@@ -365,104 +370,98 @@ export default function DownloadProgress({ events, summaryStats, observedUntilMs
return (
<div className="bg-white shadow rounded-lg p-6 mb-6">
<button
onClick={() => setExpanded(!expanded)}
className="flex items-center justify-between w-full text-left"
>
{/* flex-wrap + gap (not space-x): on narrow screens the pills wrap as whole units
below the title instead of overflowing the viewport; whitespace-nowrap inherits
so no pill breaks internally. */}
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 min-w-0 whitespace-nowrap">
<ProgressPanelHeader
title="Download Progress"
expanded={expanded}
onToggle={() => setExpanded(!expanded)}
icon={
<svg className="w-5 h-5 text-blue-500 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
<h2 className="text-lg font-semibold text-gray-900">Download Progress</h2>
{totalDownloadable != null && downloadedCount != null ? (
}
>
{totalDownloadable != null && downloadedCount != null ? (
<span
className="text-xs text-gray-400"
title="Install assignments only — skipped apps and uninstall downloads are not counted here."
>
({downloadedCount} of {totalDownloadable} downloaded)
</span>
) : (
<span className="text-xs text-gray-400">({downloads.length} {downloads.length === 1 ? 'download' : 'downloads'})</span>
)}
{totalDuration != null && (
<span className="text-xs text-gray-400">
— Total: {formatDuration(Math.floor(totalDuration / 1000))}
</span>
)}
{totalP2P != null && (
<span
className="text-xs text-gray-400"
title={`${formatBytes(totalP2P.peers)} from peers of ${formatBytes(totalP2P.total)} total`}
>
— P2P: {totalP2P.percent}%
</span>
)}
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs">
{activeCount > 0 && (
<span className="px-2 py-0.5 rounded-full bg-blue-100 text-blue-700 font-medium">
{activeCount} active
</span>
)}
{completedCount > 0 && (
<span className="px-2 py-0.5 rounded-full bg-green-100 text-green-700 font-medium">
{completedCount} completed
</span>
)}
{incompleteCount > 0 && (
// Slate like the session-level Incomplete badge: unknown outcome, not a failure.
<span
className="text-xs text-gray-400"
title="Install assignments only — skipped apps and uninstall downloads are not counted here."
className="px-2 py-0.5 rounded-full bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300 font-medium"
title={frozenText.countTitle}
>
({downloadedCount} of {totalDownloadable} downloaded)
</span>
) : (
<span className="text-xs text-gray-400">({downloads.length} {downloads.length === 1 ? 'download' : 'downloads'})</span>
)}
{totalDuration != null && (
<span className="text-xs text-gray-400">
— Total: {formatDuration(Math.floor(totalDuration / 1000))}
{incompleteCount} {frozenText.countLabel}
</span>
)}
{totalP2P != null && (
{uninstallCount > 0 && (
// Muted gray on purpose (dark-mode covered by the global gray overrides) —
// it's context, not a state the user needs to act on.
<span
className="text-xs text-gray-400"
title={`${formatBytes(totalP2P.peers)} from peers of ${formatBytes(totalP2P.total)} total`}
className="px-2 py-0.5 rounded-full bg-gray-100 text-gray-600 font-medium"
title="Package content downloaded for uninstall assignments — Win32 uninstalls re-download the full package before removing the app."
>
— P2P: {totalP2P.percent}%
{uninstallCount} uninstall
</span>
)}
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs">
{activeCount > 0 && (
<span className="px-2 py-0.5 rounded-full bg-blue-100 text-blue-700 font-medium">
{activeCount} active
</span>
)}
{completedCount > 0 && (
<span className="px-2 py-0.5 rounded-full bg-green-100 text-green-700 font-medium">
{completedCount} completed
</span>
)}
{incompleteCount > 0 && (
// Slate like the session-level Incomplete badge: unknown outcome, not a failure.
<span
className="px-2 py-0.5 rounded-full bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300 font-medium"
title="Still downloading when the agent stopped observing — the outcome was never seen. Not a failure."
>
{incompleteCount} incomplete
</span>
)}
{uninstallCount > 0 && (
// Muted gray on purpose (dark-mode covered by the global gray overrides) —
// it's context, not a state the user needs to act on.
<span
className="px-2 py-0.5 rounded-full bg-gray-100 text-gray-600 font-medium"
title="Package content downloaded for uninstall assignments — Win32 uninstalls re-download the full package before removing the app."
>
{uninstallCount} uninstall
</span>
)}
{skippedCount > 0 && (
<button
onClick={(e) => { e.stopPropagation(); setShowSkipped(!showSkipped); }}
className={`px-2 py-0.5 rounded-full font-medium transition-colors ${showSkipped ? "bg-gray-200 text-gray-700" : "bg-gray-100 text-gray-400"}`}
title={showSkipped ? "Hide skipped apps" : "Show skipped apps"}
>
{skippedCount} skipped {showSkipped ? "▾" : "▸"}
</button>
)}
{noDownloadCount > 0 && (
<button
onClick={(e) => { e.stopPropagation(); setShowNoDownload(!showNoDownload); }}
className={`px-2 py-0.5 rounded-full font-medium transition-colors ${showNoDownload ? "bg-gray-200 text-gray-700" : "bg-gray-100 text-gray-400"}`}
title="Assignments the management extension reported without downloading any package content — typically Store/WinGet uninstalls and skips."
>
{noDownloadCount} no download {showNoDownload ? "▾" : "▸"}
</button>
)}
{pendingNames.length > 0 && (
<button
onClick={(e) => { e.stopPropagation(); setShowPending(!showPending); }}
className={`px-2 py-0.5 rounded-full font-medium transition-colors ${showPending ? "bg-gray-200 text-gray-700" : "bg-gray-100 text-gray-400"}`}
title="Assigned to this device but not picked up by the management extension yet — no download activity has been observed."
>
{pendingNames.length} pending {showPending ? "▾" : "▸"}
</button>
)}
</div>
{skippedCount > 0 && (
<button
onClick={(e) => { e.stopPropagation(); setShowSkipped(!showSkipped); }}
className={`px-2 py-0.5 rounded-full font-medium transition-colors ${showSkipped ? "bg-gray-200 text-gray-700" : "bg-gray-100 text-gray-400"}`}
title={showSkipped ? "Hide skipped apps" : "Show skipped apps"}
>
{skippedCount} skipped {showSkipped ? "▾" : "▸"}
</button>
)}
{noDownloadCount > 0 && (
<button
onClick={(e) => { e.stopPropagation(); setShowNoDownload(!showNoDownload); }}
className={`px-2 py-0.5 rounded-full font-medium transition-colors ${showNoDownload ? "bg-gray-200 text-gray-700" : "bg-gray-100 text-gray-400"}`}
title="Assignments the management extension reported without downloading any package content — typically Store/WinGet uninstalls and skips."
>
{noDownloadCount} no download {showNoDownload ? "▾" : "▸"}
</button>
)}
{pendingNames.length > 0 && (
<button
onClick={(e) => { e.stopPropagation(); setShowPending(!showPending); }}
className={`px-2 py-0.5 rounded-full font-medium transition-colors ${showPending ? "bg-gray-200 text-gray-700" : "bg-gray-100 text-gray-400"}`}
title="Assigned to this device but not picked up by the management extension yet — no download activity has been observed."
>
{pendingNames.length} pending {showPending ? "▾" : "▸"}
</button>
)}
</div>
<svg className={`w-5 h-5 text-gray-400 flex-shrink-0 transition-transform duration-200 ${expanded ? 'rotate-90' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
</ProgressPanelHeader>
{expanded && <div className="space-y-3 mt-4">
{userPhaseBoundary && userSplit > 0 && <PhaseDivider boundary={userPhaseBoundary} side="before" />}
@@ -474,7 +473,7 @@ export default function DownloadProgress({ events, summaryStats, observedUntilMs
return (
<Fragment key={dl.appName}>
{userPhaseBoundary && i === userSplit && <PhaseDivider boundary={userPhaseBoundary} side="after" />}
<DownloadItem download={dl} progressPercent={progressPercent} />
<DownloadItem download={dl} progressPercent={progressPercent} agentSilent={agentSilent} />
</Fragment>
);
})}
@@ -486,9 +485,10 @@ export default function DownloadProgress({ events, summaryStats, observedUntilMs
);
}
function DownloadItem({ download: dl, progressPercent }: { download: DownloadItem; progressPercent: number }) {
function DownloadItem({ download: dl, progressPercent, agentSilent }: { download: DownloadItem; progressPercent: number; agentSilent: boolean }) {
const [showDetails, setShowDetails] = useState(false);
const [showDoStats, setShowDoStats] = useState(false);
const frozenText = frozenRowText("downloading", agentSilent);
const hasKnownTotal = dl.bytesTotal > 0;
const durationMs = effectiveDurationMs(dl);
// No-download rows get neither a progress bar nor success green — there was no download.
@@ -546,9 +546,9 @@ function DownloadItem({ download: dl, progressPercent }: { download: DownloadIte
{dl.isIncomplete && (
<span
className="text-xs px-2 py-0.5 rounded-full bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300 font-medium"
title="Still downloading when the agent stopped observing. The outcome is unknown — the download may well have finished after the session ended."
title={frozenText.rowTitle}
>
Incomplete
{frozenText.label}
</span>
)}
</div>
@@ -4,10 +4,12 @@ import { Fragment, useEffect, useMemo, useState } from "react";
import { getErrorCodeEntry, formatErrorCode, errorCodeTooltip } from "@/lib/errorCodeMap";
import { partitionHistoricReplayEvents } from "@/lib/historicReplay";
import { applyObservationEnd, buildInstallItems, isRebootOrRetryClass, type InstallEvent, type InstallItem } from "@/lib/installProgress";
import { frozenRowText } from "@/lib/sessionStatus";
import TruncatedLabel from "@/components/TruncatedLabel";
import { InstallSourcePill } from "@/components/InstallSourcePill";
import PendingAppRow from "@/components/PendingAppRow";
import PhaseDivider from "@/components/PhaseDivider";
import ProgressPanelHeader from "@/components/ProgressPanelHeader";
import AssignmentPill from "@/components/AssignmentPill";
import { userPhaseSplitIndex, type UserPhaseBoundary } from "@/lib/userPhaseBoundary";
@@ -25,9 +27,11 @@ interface SummaryStats {
interface InstallProgressProps {
events: InstallEvent[];
summaryStats?: SummaryStats | null;
// Epoch ms of the agent's last report, passed only once the session is terminal. A row still
// installing at that point renders as Incomplete instead of ticking against the wall clock.
// Epoch ms of the agent's last report, passed once the session is terminal or the agent went
// silent. A row still installing at that point stops ticking against the wall clock.
observedUntilMs?: number | null;
// The session has not ended, data just stopped arriving: the row reads No data, not Incomplete.
agentSilent?: boolean;
// When the Enrollment Status Page entered Account Setup; null when the session has no such split.
userPhaseBoundary?: UserPhaseBoundary | null;
}
@@ -47,7 +51,40 @@ function formatDuration(ms: number): string {
return `${hours}h ${remainingMinutes}m`;
}
export default function InstallProgress({ events, summaryStats, observedUntilMs = null, userPhaseBoundary = null }: InstallProgressProps) {
interface DetectionBadge {
label: string;
/** Label below `sm`; null hides the pill there — it then appears in the row's Details only. */
phoneLabel: string | null;
tone: string;
title: string;
}
// IME's check right after the install, or after the restart the installer requested (D-392).
// Any result other than Detected/NotDetected is IME's DetectedDifferentVersion.
function postInstallDetectionBadge(result: string, afterReboot: boolean | undefined): DetectionBadge {
const check = `Intune's detection check right after the ${afterReboot ? "restart the installer requested" : "installer finished"}`;
if (result === "Detected") {
// Only confirms what the check icon and the green row already say, so on a phone it yields
// its room to the app name instead of squeezing it to a few letters.
return { label: "Detected after install", phoneLabel: null, tone: "bg-green-100 text-green-700", title: `${check}: the app was found.` };
}
if (result === "NotDetected") {
return {
label: "Not detected after install",
phoneLabel: "Not detected",
tone: "bg-red-200 text-red-800",
title: `${check}: the app was not found, so Intune counts the install as failed (0x87D1041C).`,
};
}
return {
label: "Different version detected",
phoneLabel: "Other version",
tone: "bg-amber-200 text-amber-700",
title: `${check}: a different version was found; Intune counts the app as installed.`,
};
}
export default function InstallProgress({ events, summaryStats, observedUntilMs = null, agentSilent = false, userPhaseBoundary = null }: InstallProgressProps) {
// Legacy-agent guard: split off app events replayed from a previous enrollment's IME log
// (newer agents suppress them at the source) so week-old installs never render as current.
// office_*/realmjoin_* events never carry rejectedSourceTimestamp and pass through untouched.
@@ -118,110 +155,105 @@ export default function InstallProgress({ events, summaryStats, observedUntilMs
const installedFromSummary = summaryStats?.installed;
const userSplit = userPhaseSplitIndex(filteredInstalls, i => Date.parse(i.firstSeenAt), userPhaseBoundary);
const frozenText = frozenRowText("installing", agentSilent);
return (
<div className="bg-white shadow rounded-lg p-6 mb-6">
<button
onClick={() => setExpanded(!expanded)}
className="flex items-center justify-between w-full text-left"
>
{/* flex-wrap + gap (not space-x): on narrow screens the pills wrap as whole units
below the title instead of overflowing the viewport; whitespace-nowrap inherits
so no pill breaks internally. */}
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 min-w-0 whitespace-nowrap">
<ProgressPanelHeader
title="Install Progress"
expanded={expanded}
onToggle={() => setExpanded(!expanded)}
icon={
<svg className="w-5 h-5 text-indigo-500 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
</svg>
<h2 className="text-lg font-semibold text-gray-900">Install Progress</h2>
{totalFromSummary != null && installedFromSummary != null && (
<span className="text-xs text-gray-400">
({installedFromSummary} of {totalFromSummary} installed)
}
>
{totalFromSummary != null && installedFromSummary != null && (
<span className="text-xs text-gray-400">
({installedFromSummary} of {totalFromSummary} installed)
</span>
)}
{totalDuration != null && (
<span className="text-xs text-gray-400">
— Total: {formatDuration(totalDuration)}
</span>
)}
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs">
{activeCount > 0 && (
<span className="px-2 py-0.5 rounded-full bg-indigo-100 text-indigo-700 font-medium">
{activeCount} active
</span>
)}
{totalDuration != null && (
<span className="text-xs text-gray-400">
— Total: {formatDuration(totalDuration)}
{completedCount > 0 && (
<span className="px-2 py-0.5 rounded-full bg-green-100 text-green-700 font-medium">
{completedCount} completed
</span>
)}
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs">
{activeCount > 0 && (
<span className="px-2 py-0.5 rounded-full bg-indigo-100 text-indigo-700 font-medium">
{activeCount} active
</span>
)}
{completedCount > 0 && (
<span className="px-2 py-0.5 rounded-full bg-green-100 text-green-700 font-medium">
{completedCount} completed
</span>
)}
{uninstalledCount > 0 && (
// Deliberately muted (gray family, dark-mode covered by the global overrides) —
// a saturated pill here pulls the eye to what is a successful non-event.
<span
className="px-2 py-0.5 rounded-full bg-gray-100 text-gray-600 font-medium"
title="Uninstall assignments enforced during this enrollment — the app was removed, not installed."
>
{uninstalledCount} uninstalled
</span>
)}
{failedCount > 0 && (
<span className="px-2 py-0.5 rounded-full bg-red-100 text-red-700 font-medium">
{failedCount} failed
</span>
)}
{likelyStuckCount > 0 && (
<span
className="px-2 py-0.5 rounded-full bg-orange-100 text-orange-700 font-medium"
title="ESP timed out while these apps were still installing — final status couldn't be confirmed."
>
{likelyStuckCount} likely stuck
</span>
)}
{incompleteCount > 0 && (
// Slate like the session-level Incomplete badge: unknown outcome, not a failure.
<span
className="px-2 py-0.5 rounded-full bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300 font-medium"
title="Still installing when the agent stopped observing — the outcome was never seen. Not a failure."
>
{incompleteCount} incomplete
</span>
)}
{postponedCount > 0 && (
<span className="px-2 py-0.5 rounded-full bg-amber-100 text-amber-700 font-medium">
{postponedCount} postponed
</span>
)}
{skippedCount > 0 && (
<button
onClick={(e) => { e.stopPropagation(); setShowSkipped(!showSkipped); }}
className={`px-2 py-0.5 rounded-full font-medium transition-colors ${showSkipped ? "bg-gray-200 text-gray-700" : "bg-gray-100 text-gray-400"}`}
title={showSkipped ? "Hide skipped apps" : "Show skipped apps"}
>
{skippedCount} skipped {showSkipped ? "▾" : "▸"}
</button>
)}
{pendingNames.length > 0 && (
<button
onClick={(e) => { e.stopPropagation(); setShowPending(!showPending); }}
className={`px-2 py-0.5 rounded-full font-medium transition-colors ${showPending ? "bg-gray-200 text-gray-700" : "bg-gray-100 text-gray-400"}`}
title="Assigned to this device but not picked up by the management extension yet — no install activity has been observed."
>
{pendingNames.length} pending {showPending ? "▾" : "▸"}
</button>
)}
</div>
{uninstalledCount > 0 && (
// Deliberately muted (gray family, dark-mode covered by the global overrides) —
// a saturated pill here pulls the eye to what is a successful non-event.
<span
className="px-2 py-0.5 rounded-full bg-gray-100 text-gray-600 font-medium"
title="Uninstall assignments enforced during this enrollment — the app was removed, not installed."
>
{uninstalledCount} uninstalled
</span>
)}
{failedCount > 0 && (
<span className="px-2 py-0.5 rounded-full bg-red-100 text-red-700 font-medium">
{failedCount} failed
</span>
)}
{likelyStuckCount > 0 && (
<span
className="px-2 py-0.5 rounded-full bg-orange-100 text-orange-700 font-medium"
title="ESP timed out while these apps were still installing — final status couldn't be confirmed."
>
{likelyStuckCount} likely stuck
</span>
)}
{incompleteCount > 0 && (
// Slate like the session-level Incomplete badge: unknown outcome, not a failure.
<span
className="px-2 py-0.5 rounded-full bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300 font-medium"
title={frozenText.countTitle}
>
{incompleteCount} {frozenText.countLabel}
</span>
)}
{postponedCount > 0 && (
<span className="px-2 py-0.5 rounded-full bg-amber-100 text-amber-700 font-medium">
{postponedCount} postponed
</span>
)}
{skippedCount > 0 && (
<button
onClick={(e) => { e.stopPropagation(); setShowSkipped(!showSkipped); }}
className={`px-2 py-0.5 rounded-full font-medium transition-colors ${showSkipped ? "bg-gray-200 text-gray-700" : "bg-gray-100 text-gray-400"}`}
title={showSkipped ? "Hide skipped apps" : "Show skipped apps"}
>
{skippedCount} skipped {showSkipped ? "▾" : "▸"}
</button>
)}
{pendingNames.length > 0 && (
<button
onClick={(e) => { e.stopPropagation(); setShowPending(!showPending); }}
className={`px-2 py-0.5 rounded-full font-medium transition-colors ${showPending ? "bg-gray-200 text-gray-700" : "bg-gray-100 text-gray-400"}`}
title="Assigned to this device but not picked up by the management extension yet — no install activity has been observed."
>
{pendingNames.length} pending {showPending ? "▾" : "▸"}
</button>
)}
</div>
<svg className={`w-5 h-5 text-gray-400 flex-shrink-0 transition-transform duration-200 ${expanded ? 'rotate-90' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
</ProgressPanelHeader>
{expanded && <div className="space-y-3 mt-4">
{userPhaseBoundary && userSplit > 0 && <PhaseDivider boundary={userPhaseBoundary} side="before" />}
{filteredInstalls.map((item, i) => (
<Fragment key={item.key}>
{userPhaseBoundary && i === userSplit && <PhaseDivider boundary={userPhaseBoundary} side="after" />}
<InstallItemRow item={item} />
<InstallItemRow item={item} agentSilent={agentSilent} />
</Fragment>
))}
{showPending && pendingNames.map((name) => (
@@ -240,8 +272,9 @@ export default function InstallProgress({ events, summaryStats, observedUntilMs
);
}
function InstallItemRow({ item }: { item: InstallItem }) {
function InstallItemRow({ item, agentSilent }: { item: InstallItem; agentSilent: boolean }) {
const [showDetails, setShowDetails] = useState(false);
const frozenText = frozenRowText("installing", agentSilent);
// Live 1s timer while this app is installing. The tick state carries the
// startedAt it was computed from so the read below can mask stale values from
// a previous activation — the effect only starts/stops the interval; the
@@ -263,6 +296,10 @@ function InstallItemRow({ item }: { item: InstallItem }) {
? liveElapsed.elapsedMs
: null;
// A failed row already says "Detection failed", so its NotDetected verdict is not repeated.
const detection = item.postInstallDetection && !(item.state === "Failed" && item.postInstallDetection === "NotDetected")
? postInstallDetectionBadge(item.postInstallDetection, item.postInstallAfterReboot)
: null;
// Session 080edee9 follow-up — only the genuine "no HRESULT available" timeout
// case wears the hedged orange treatment. Detection-failures and install-failures
@@ -343,29 +380,19 @@ function InstallItemRow({ item }: { item: InstallItem }) {
Uninstall
</span>
)}
{item.postInstallDetection && !(item.state === "Failed" && item.postInstallDetection === "NotDetected") && (
// IME's check right after the install (D-392); a failed row already says "Detection failed".
{detection && (
// The pill never shrinks (nowrap) while the name does, so on a phone a long label
// squeezed the app name to a few letters: short label there, or none at all.
<span
className={`text-xs px-2 py-0.5 rounded-full font-medium whitespace-nowrap ${
item.postInstallDetection === "Detected"
? "bg-green-100 text-green-700"
: item.postInstallDetection === "NotDetected"
? "bg-red-200 text-red-800"
: "bg-amber-200 text-amber-700"
}`}
title={`Intune's detection check right after the ${item.postInstallAfterReboot ? "restart the installer requested" : "installer finished"}: ${
item.postInstallDetection === "Detected"
? "the app was found."
: item.postInstallDetection === "NotDetected"
? "the app was not found, so Intune counts the install as failed (0x87D1041C)."
: "a different version was found; Intune counts the app as installed."
}`}
className={`text-xs px-2 py-0.5 rounded-full font-medium whitespace-nowrap ${detection.tone}${detection.phoneLabel == null ? " hidden sm:inline" : ""}`}
title={detection.title}
>
{item.postInstallDetection === "Detected"
? "Detected after install"
: item.postInstallDetection === "NotDetected"
? "Not detected after install"
: "Different version detected"}
{detection.phoneLabel == null ? detection.label : (
<>
<span className="sm:hidden">{detection.phoneLabel}</span>
<span className="hidden sm:inline">{detection.label}</span>
</>
)}
</span>
)}
{item.state === "Failed" && item.isLikelyStuck && (
@@ -401,9 +428,9 @@ function InstallItemRow({ item }: { item: InstallItem }) {
{item.isIncomplete && (
<span
className="text-xs px-2 py-0.5 rounded-full bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300 font-medium"
title="Still installing when the agent stopped observing. The outcome is unknown — the install may well have finished after the session ended."
title={frozenText.rowTitle}
>
Incomplete
{frozenText.label}
</span>
)}
{item.state === "Preinstalled" && (
@@ -506,7 +533,7 @@ function InstallItemRow({ item }: { item: InstallItem }) {
);
})()}
{item.isError && item.errorDetail && !item.isLikelyStuck && (
<div className="text-xs text-red-600">{item.errorDetail}</div>
<div className="text-xs text-red-600 break-words">{item.errorDetail}</div>
)}
{item.isLikelyStuck && (
<div className="text-xs text-orange-700">
@@ -516,6 +543,15 @@ function InstallItemRow({ item }: { item: InstallItem }) {
</div>
) : null}
{/* Phones: the row header shows the detection result short or not at all, and touch has
no hover tooltip — Details carries the full verdict. */}
{showDetails && detection && (
<div className="sm:hidden mt-3 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs">
<span className={`px-2 py-0.5 rounded-full font-medium whitespace-nowrap ${detection.tone}`}>{detection.label}</span>
<span className="text-gray-600">{detection.title}</span>
</div>
)}
{/* Event details (expandable) */}
{showDetails && item.eventData && (
<div className="mt-3 p-2 bg-gray-900 rounded text-xs text-gray-100 font-mono overflow-x-auto">
@@ -0,0 +1,43 @@
"use client";
import type { ReactNode } from "react";
interface ProgressPanelHeaderProps {
title: string;
icon: ReactNode;
expanded: boolean;
onToggle: () => void;
// Summary text and pills after the title. May hold buttons of their own (the skipped and
// pending toggles), which call stopPropagation so they do not also collapse the panel.
children?: ReactNode;
}
/**
* Collapsible head of the Download and Install panels. A plain div takes the mouse click so the
* whole head stays a click target, and only the chevron is a real button: the pills hold buttons,
* and a button inside a button is invalid HTML that hides the inner ones from screen readers.
*/
export default function ProgressPanelHeader({ title, icon, expanded, onToggle, children }: ProgressPanelHeaderProps) {
return (
<div onClick={onToggle} className="flex items-center justify-between w-full text-left cursor-pointer select-none">
{/* flex-wrap + gap (not space-x): on narrow screens the pills wrap as whole units
below the title instead of overflowing the viewport; whitespace-nowrap inherits
so no pill breaks internally. */}
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 min-w-0 whitespace-nowrap">
{icon}
<h2 className="text-lg font-semibold text-gray-900">{title}</h2>
{children}
</div>
<button
onClick={(e) => { e.stopPropagation(); onToggle(); }}
aria-expanded={expanded}
aria-label={title}
className="flex-shrink-0"
>
<svg className={`w-5 h-5 text-gray-400 flex-shrink-0 transition-transform duration-200 ${expanded ? 'rotate-90' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
);
}
@@ -22,6 +22,7 @@ import {
} from "@/lib/scriptExecutions";
import { lookupScriptDisplayName, type DisplayNamesByRefKey } from "@/lib/scriptDisplayNames";
import { getErrorCodeEntry, formatErrorCode, errorCodeTooltip } from "@/lib/errorCodeMap";
import { frozenRowText } from "@/lib/sessionStatus";
import { userPhaseSplitIndex, type UserPhaseBoundary } from "@/lib/userPhaseBoundary";
import PhaseDivider from "@/components/PhaseDivider";
@@ -53,14 +54,16 @@ interface ScriptExecutionsProps {
* stay distinct (rare in practice but never silently collide).
*/
displayNamesByRefKey?: DisplayNamesByRefKey;
// Epoch ms of the agent's last report, passed only once the session is terminal. A script
// still running at that point renders as Incomplete instead of ticking against the wall clock.
// Epoch ms of the agent's last report, passed once the session is terminal or the agent went
// silent. A script still running at that point stops ticking against the wall clock.
observedUntilMs?: number | null;
// The session has not ended, data just stopped arriving: the row reads No data, not Incomplete.
agentSilent?: boolean;
// When the Enrollment Status Page entered Account Setup; null when the session has no such split.
userPhaseBoundary?: UserPhaseBoundary | null;
}
export default function ScriptExecutions({ events, showScriptOutput, latestBootstrapVersion, displayNamesByRefKey, observedUntilMs = null, userPhaseBoundary = null }: ScriptExecutionsProps) {
export default function ScriptExecutions({ events, showScriptOutput, latestBootstrapVersion, displayNamesByRefKey, observedUntilMs = null, agentSilent = false, userPhaseBoundary = null }: ScriptExecutionsProps) {
// Legacy-agent guard: split off script events replayed from a previous enrollment's IME
// log (newer agents suppress them at the source) so week-old runs never render as current
// executions. The muted note below keeps the gap explainable.
@@ -79,7 +82,7 @@ export default function ScriptExecutions({ events, showScriptOutput, latestBoots
// post-detection were non-compliant en route.
const runningCount = cards.filter(c => c.headerState === "Running").length;
// Still running when observation ended — unknown outcome, counted apart from "running"
// so a finished session never claims a script is executing.
// so a finished or silent session never claims a script is executing.
const incompleteCount = cards.filter(c => c.headerState === "Incomplete").length;
const successCount = cards.filter(c => c.headerState === "Success").length;
const nonCompliantCount = cards.filter(c => c.headerState === "NonCompliant").length;
@@ -88,6 +91,7 @@ export default function ScriptExecutions({ events, showScriptOutput, latestBoots
const remediationCount = cards.filter(c => c.scriptType === "remediation").length;
const userSplit = userPhaseSplitIndex(cards, c => Date.parse(c.timestamp), userPhaseBoundary);
const frozenText = frozenRowText("running", agentSilent);
return (
<div className="bg-white shadow rounded-lg p-6 mb-6">
@@ -110,9 +114,9 @@ export default function ScriptExecutions({ events, showScriptOutput, latestBoots
// Slate like the session-level Incomplete badge: unknown outcome, not a failure.
<span
className="px-2 py-0.5 rounded-full bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300 font-medium"
title="Still running when the agent stopped observing — the outcome was never seen. Not a failure."
title={frozenText.countTitle}
>
{incompleteCount} incomplete
{incompleteCount} {frozenText.countLabel}
</span>
)}
{successCount > 0 && (
@@ -158,6 +162,7 @@ export default function ScriptExecutions({ events, showScriptOutput, latestBoots
showScriptOutput={showScriptOutput}
latestBootstrapVersion={latestBootstrapVersion}
displayNamesByRefKey={displayNamesByRefKey}
agentSilent={agentSilent}
/>
</Fragment>
))}
@@ -217,7 +222,7 @@ function cardStatusTextColor(state: ScriptCard["headerState"]): string {
* the parent header + nested phase rows under it; platform / single-phase remediation
* cards collapse the parent header into the single phase row (no nesting).
*/
function ScriptCardView({ card, showScriptOutput, latestBootstrapVersion, displayNamesByRefKey }: { card: ScriptCard; showScriptOutput?: boolean; latestBootstrapVersion?: string | null; displayNamesByRefKey?: DisplayNamesByRefKey }) {
function ScriptCardView({ card, showScriptOutput, latestBootstrapVersion, displayNamesByRefKey, agentSilent }: { card: ScriptCard; showScriptOutput?: boolean; latestBootstrapVersion?: string | null; displayNamesByRefKey?: DisplayNamesByRefKey; agentSilent: boolean }) {
// Cycles default to COLLAPSED so a session with many remediation policies stays
// scannable — the header carries the cycle-level outcome which is the at-a-glance
// signal the user wants first. Click the chevron to drill into per-phase details.
@@ -231,6 +236,7 @@ function ScriptCardView({ card, showScriptOutput, latestBootstrapVersion, displa
showScriptOutput={showScriptOutput}
latestBootstrapVersion={latestBootstrapVersion}
displayName={lookupScriptDisplayName(displayNamesByRefKey, card.phases[0].scriptType, card.phases[0].policyId)}
agentSilent={agentSilent}
/>
);
}
@@ -255,6 +261,7 @@ function ScriptCardView({ card, showScriptOutput, latestBootstrapVersion, displa
// ("Reported after"), never here.
const cardDurationLabel = formatScriptDuration(card.durationSeconds);
const isCardSlow = card.durationSeconds != null && card.durationSeconds >= STALE_RUNNING_THRESHOLD_SECONDS;
const frozenText = card.headerState === "Incomplete" ? frozenRowText("running", agentSilent) : null;
return (
<div className={`rounded-lg ${containerClass}`}>
@@ -284,7 +291,7 @@ function ScriptCardView({ card, showScriptOutput, latestBootstrapVersion, displa
)}
</div>
<div className="flex items-center space-x-3 text-xs flex-shrink-0 ml-2">
<span className={`font-medium ${statusColor}`}>{card.headerLabel}</span>
<span className={`font-medium ${statusColor}`} title={frozenText?.rowTitle}>{frozenText?.label ?? card.headerLabel}</span>
{cardDurationLabel && (
<span
className={`font-mono ${isCardSlow ? "text-amber-600 font-medium" : "text-gray-500"}`}
@@ -309,6 +316,7 @@ function ScriptCardView({ card, showScriptOutput, latestBootstrapVersion, displa
showScriptOutput={showScriptOutput}
latestBootstrapVersion={latestBootstrapVersion}
displayName={lookupScriptDisplayName(displayNamesByRefKey, phase.scriptType, phase.policyId)}
agentSilent={agentSilent}
nested
/>
))}
@@ -356,7 +364,7 @@ function CardIcon({ state, className }: { state: ScriptCard["headerState"]; clas
);
}
function ScriptItemRow({ item, showScriptOutput, latestBootstrapVersion, nested, displayName }: { item: ScriptItem; showScriptOutput?: boolean; latestBootstrapVersion?: string | null; nested?: boolean; displayName?: string | null }) {
function ScriptItemRow({ item, showScriptOutput, latestBootstrapVersion, nested, displayName, agentSilent }: { item: ScriptItem; showScriptOutput?: boolean; latestBootstrapVersion?: string | null; nested?: boolean; displayName?: string | null; agentSilent: boolean }) {
const [showDetails, setShowDetails] = useState(false);
// Re-render every 5s while live in Running state so the elapsed time updates; an incomplete
// placeholder is frozen at the observation end and never ticks.
@@ -399,9 +407,10 @@ function ScriptItemRow({ item, showScriptOutput, latestBootstrapVersion, nested,
const intuneUrl = getIntuneScriptUrl(item.policyId, item.scriptType);
// Status text for the right-hand summary cell
const frozenText = frozenRowText("running", agentSilent);
let statusText: string;
if (item.isIncomplete) {
statusText = "Incomplete";
statusText = frozenText.label;
} else if (item.state === "Running") {
statusText = isStale ? `Running (${elapsedSeconds}s — stuck?)` : `Running (${elapsedSeconds}s)`;
} else if (item.scriptType === "remediation" && item.complianceResult) {
@@ -515,7 +524,7 @@ function ScriptItemRow({ item, showScriptOutput, latestBootstrapVersion, nested,
: isNonCompliant ? "text-amber-700"
: "text-green-600"
}`}
title={item.isIncomplete ? "Still running when the agent stopped observing. The outcome is unknown — the script may well have finished after the session ended." : undefined}
title={item.isIncomplete ? frozenText.rowTitle : undefined}
>
{statusText}
</span>
@@ -0,0 +1,40 @@
import { describe, it, expect } from "vitest";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import InstallProgress from "../InstallProgress";
import type { InstallEvent } from "@/lib/installProgress";
function evt(eventType: string, timestamp: string, data: Record<string, unknown>): InstallEvent {
return { eventType, timestamp, data };
}
function renderWithCheckAfterInstall(postInstallDetection: string): string {
return renderToStaticMarkup(
createElement(InstallProgress, {
events: [
evt("app_install_started", "2026-10-21T16:40:00Z", { appName: "Contoso VPN" }),
evt("app_install_completed", "2026-10-21T16:42:39Z", { appName: "Contoso VPN", state: "Installed" }),
evt("app_post_install_detection", "2026-10-21T16:42:41Z", { appName: "Contoso VPN", postInstallDetection, afterReboot: "false" }),
],
}),
);
}
describe("InstallProgress — detection pill on narrow screens", () => {
it("hides the green confirmation below sm so the app name keeps its room", () => {
const html = renderWithCheckAfterInstall("Detected");
expect(html).toContain("bg-green-100 text-green-700 hidden sm:inline");
expect(html).toContain(">Detected after install</span>");
expect(html).not.toContain('class="sm:hidden"');
});
it("keeps the red and amber verdicts on phones, with a short label", () => {
const notDetected = renderWithCheckAfterInstall("NotDetected");
expect(notDetected).toContain('<span class="sm:hidden">Not detected</span>');
expect(notDetected).toContain('<span class="hidden sm:inline">Not detected after install</span>');
const otherVersion = renderWithCheckAfterInstall("DetectedDifferentVersion");
expect(otherVersion).toContain('<span class="sm:hidden">Other version</span>');
expect(otherVersion).toContain('<span class="hidden sm:inline">Different version detected</span>');
});
});
@@ -0,0 +1,43 @@
import { describe, it, expect } from "vitest";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import ProgressPanelHeader from "../ProgressPanelHeader";
const noop = () => {};
function render(expanded: boolean) {
return renderToStaticMarkup(
createElement(
ProgressPanelHeader,
{ title: "Install Progress", icon: createElement("svg"), expanded, onToggle: noop },
createElement("button", null, "3 skipped"),
),
);
}
// Walks the markup and reports whether a button ever opens while another one is still open.
function hasNestedButton(html: string): boolean {
let depth = 0;
for (const [tag] of html.matchAll(/<\/?button\b/g)) {
depth += tag === "<button" ? 1 : -1;
if (depth > 1) return true;
}
return false;
}
describe("ProgressPanelHeader", () => {
it("keeps the pill buttons and the panel toggle as siblings, never nested", () => {
const html = render(true);
expect(html.match(/<button\b/g)?.length).toBe(2);
expect(hasNestedButton(html)).toBe(false);
});
it("names the toggle after the panel and reports its state", () => {
expect(render(true)).toContain('aria-expanded="true" aria-label="Install Progress"');
expect(render(false)).toContain('aria-expanded="false" aria-label="Install Progress"');
});
it("detects a nested button", () => {
expect(hasNestedButton("<button><span><button></button></span></button>")).toBe(true);
});
});
@@ -95,36 +95,41 @@ export function MySubmissionsList({ kind, getAccessToken, refreshKey, overrideTe
return (
<div className="bg-white rounded-lg shadow">
<button
{/* The div takes the mouse click so the whole head stays a click target; the title and the
filter are sibling buttons, because a button inside a button hides the inner one from
the keyboard and from screen readers. */}
<div
onClick={() => setCollapsed((c) => !c)}
className="w-full flex items-center justify-between px-4 py-3 text-left"
className="w-full flex items-center justify-between px-4 py-3 text-left cursor-pointer select-none"
>
<span className="flex items-center gap-2 text-sm font-medium text-gray-900">
<button
onClick={(e) => { e.stopPropagation(); setCollapsed((c) => !c); }}
aria-expanded={!collapsed}
className="flex items-center gap-2 text-sm font-medium text-gray-900 text-left"
>
<svg className={`w-4 h-4 text-gray-400 transition-transform ${collapsed ? "" : "rotate-90"}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
Community submissions
<span className="text-xs font-normal text-gray-500">({items.length}{pending > 0 ? `, ${pending} pending` : ""})</span>
</span>
</button>
{!collapsed && hidden > 0 && (
<span
role="button"
<button
onClick={(e) => { e.stopPropagation(); setShowAll(true); }}
className="text-xs text-indigo-600 hover:text-indigo-800"
>
Show all ({hidden} more)
</span>
</button>
)}
{!collapsed && showAll && items.length > pending && (
<span
role="button"
<button
onClick={(e) => { e.stopPropagation(); setShowAll(false); }}
className="text-xs text-indigo-600 hover:text-indigo-800"
>
Pending only
</span>
</button>
)}
</button>
</div>
{!collapsed && visible.length === 0 && (
<p className="border-t border-gray-100 px-4 py-2 text-xs text-gray-500">Nothing pending.</p>
)}
@@ -1,5 +1,11 @@
import { describe, it, expect } from "vitest";
import { isTerminalStatus } from "../../lib/sessionStatus";
import {
AGENT_SILENCE_THRESHOLD_MS,
frozenRowText,
isAgentSilent,
isTerminalStatus,
nextSilenceCheckMs,
} from "../../lib/sessionStatus";
describe("isTerminalStatus", () => {
it("returns true for terminal statuses", () => {
@@ -25,3 +31,71 @@ describe("isTerminalStatus", () => {
expect(isTerminalStatus("FAILED")).toBe(false);
});
});
describe("isAgentSilent", () => {
const NOW = Date.UTC(2026, 10, 2, 8, 0, 0);
const quietFor = (ms: number) => NOW - ms;
it.each([
["InProgress", AGENT_SILENCE_THRESHOLD_MS - 1, false],
["InProgress", AGENT_SILENCE_THRESHOLD_MS, true],
["Pending", AGENT_SILENCE_THRESHOLD_MS, true],
["AwaitingUser", AGENT_SILENCE_THRESHOLD_MS, true],
["Unknown", AGENT_SILENCE_THRESHOLD_MS, true],
["Stalled", AGENT_SILENCE_THRESHOLD_MS, true],
// An agent that reported the stall itself is still talking.
["Stalled", 60_000, false],
["Succeeded", AGENT_SILENCE_THRESHOLD_MS * 10, false],
["Failed", AGENT_SILENCE_THRESHOLD_MS * 10, false],
["Incomplete", AGENT_SILENCE_THRESHOLD_MS * 10, false],
])("%s, quiet for %i ms: %s", (status, quiet, expected) => {
expect(isAgentSilent(status, quietFor(quiet), NOW)).toBe(expected);
});
it("without an ingest time only a Stalled session counts", () => {
expect(isAgentSilent("Stalled", null, NOW)).toBe(true);
expect(isAgentSilent("InProgress", null, NOW)).toBe(false);
expect(isAgentSilent(undefined, null, NOW)).toBe(false);
expect(isAgentSilent("Succeeded", null, NOW)).toBe(false);
});
});
describe("nextSilenceCheckMs", () => {
const LAST_INGEST = Date.UTC(2026, 10, 2, 8, 0, 0);
it("keeps the old clock while the answer stays the same, so a quiet page does not render", () => {
const before = LAST_INGEST + 60_000;
expect(nextSilenceCheckMs(before, before + 30_000, "InProgress", LAST_INGEST)).toBe(before);
const silent = LAST_INGEST + AGENT_SILENCE_THRESHOLD_MS;
expect(nextSilenceCheckMs(silent, silent + 30_000, "InProgress", LAST_INGEST)).toBe(silent);
});
it("moves the clock when the answer flips", () => {
const before = LAST_INGEST + AGENT_SILENCE_THRESHOLD_MS - 1;
const after = LAST_INGEST + AGENT_SILENCE_THRESHOLD_MS;
expect(nextSilenceCheckMs(before, after, "InProgress", LAST_INGEST)).toBe(after);
});
});
describe("frozenRowText", () => {
it("a session that ended keeps its wording", () => {
const text = frozenRowText("installing", false);
expect(text.label).toBe("Incomplete");
expect(text.countLabel).toBe("incomplete");
expect(text.countTitle).toBe("Still installing when the agent stopped observing — the outcome was never seen. Not a failure.");
expect(text.rowTitle).toBe("Still installing when the agent stopped observing. The outcome is unknown — the install may well have finished after the session ended.");
});
it.each([
["installing", "install"],
["downloading", "download"],
["running", "script"],
] as const)("a silent agent reads as no data: %s", (activity, subject) => {
const text = frozenRowText(activity, true);
expect(text.label).toBe("No data");
expect(text.countLabel).toBe("no data");
expect(text.countTitle).toBe(`Still ${activity} at the agent's last report. No data has arrived since, so the current state is unknown. Not a failure.`);
expect(text.rowTitle).toBe(`Still ${activity} at the agent's last report. No data has arrived since. The ${subject} may well have finished.`);
});
});
@@ -372,8 +372,9 @@ export function buildInstallItems(events: InstallEvent[]): InstallItem[] {
/**
* Marks rows that were still installing when observation ended. `observedUntilMs` is the last
* moment the agent reported anything (session `lastEventAt`) and must be passed only once the
* session is terminal — `null` means "still live", and every row comes back untouched so the
* panel keeps its live timer. A start after the observation end (clock skew) clamps to 0.
* session is terminal or its agent has gone silent — `null` means "still live", and every row
* comes back untouched so the panel keeps its live timer. A start after the observation end
* (clock skew) clamps to 0.
*/
export function applyObservationEnd(items: InstallItem[], observedUntilMs: number | null): InstallItem[] {
if (observedUntilMs == null) return items;
@@ -598,9 +598,9 @@ export function reduceScriptEvents(events: ScriptInputEvent[]): ScriptItem[] {
/**
* Marks placeholders that were still running when observation ended. `observedUntilMs` is the
* last moment the agent reported anything (the session's lastEventAt) and is only passed once
* the session is terminal; null (live session) leaves every row untouched. Mirrors the install
* panel's applyObservationEnd: no timer runs on such a row, and `observedMs` is the watched
* span from the start line — a lower bound, never the run time.
* the session is terminal or its agent has gone silent; null (live session) leaves every row
* untouched. Mirrors the install panel's applyObservationEnd: no timer runs on such a row, and
* `observedMs` is the watched span from the start line — a lower bound, never the run time.
*/
export function applyObservationEnd(items: ScriptItem[], observedUntilMs: number | null): ScriptItem[] {
if (observedUntilMs == null) return items;
@@ -10,3 +10,59 @@
export function isTerminalStatus(status: string | null | undefined): boolean {
return status === "Succeeded" || status === "Failed" || status === "Incomplete";
}
// How long an unfinished session may stay quiet before its rows stop claiming to be live. A live
// agent reports every 30 s while anything happens; its periodic collectors stop after 15 min without
// a real event, and an OS-update restart was quiet for 12 min in the field.
export const AGENT_SILENCE_THRESHOLD_MS = 20 * 60 * 1000;
/**
* True when nothing has arrived from the agent of an unfinished session for the threshold. Measured
* on the server clock: device timestamps can be hours off. A Stalled session counts without a
* timestamp, the sweep has already seen the silence. An agent that reported the stall itself is
* still talking, so its app row keeps its timer until it goes quiet too.
*/
export function isAgentSilent(
status: string | null | undefined,
lastIngestAtMs: number | null,
nowMs: number,
): boolean {
if (isTerminalStatus(status)) return false;
if (lastIngestAtMs == null) return status === "Stalled";
return nowMs - lastIngestAtMs >= AGENT_SILENCE_THRESHOLD_MS;
}
/** The clock value to keep after a check: the old one unless the answer flips, so a quiet page does not render. */
export function nextSilenceCheckMs(
previousMs: number,
nowMs: number,
status: string | null | undefined,
lastIngestAtMs: number | null,
): number {
return isAgentSilent(status, lastIngestAtMs, previousMs) === isAgentSilent(status, lastIngestAtMs, nowMs)
? previousMs
: nowMs;
}
const FROZEN_ROW_SUBJECT = { installing: "install", downloading: "download", running: "script" } as const;
/**
* The words for a row frozen at the agent's last report. `agentSilent`: the session has not ended and
* data may still come. Otherwise the session ended without the outcome.
*/
export function frozenRowText(activity: keyof typeof FROZEN_ROW_SUBJECT, agentSilent: boolean) {
const subject = FROZEN_ROW_SUBJECT[activity];
return agentSilent
? {
label: "No data",
countLabel: "no data",
countTitle: `Still ${activity} at the agent's last report. No data has arrived since, so the current state is unknown. Not a failure.`,
rowTitle: `Still ${activity} at the agent's last report. No data has arrived since. The ${subject} may well have finished.`,
}
: {
label: "Incomplete",
countLabel: "incomplete",
countTitle: `Still ${activity} when the agent stopped observing — the outcome was never seen. Not a failure.`,
rowTitle: `Still ${activity} when the agent stopped observing. The outcome is unknown — the ${subject} may well have finished after the session ended.`,
};
}
-1
View File
@@ -17,7 +17,6 @@
"1d7bffc15ccf7436": 1,
"29bbb90f9b9e306a": 1,
"2a985532f2e52066": 1,
"33829a769f2eacb9": 1,
"376b94382a0f2fc3": 1,
"391740d0bd84d73b": 1,
"3a83e25903a9f78b": 1,