mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 20:53:06 +00:00
feat(agent-v2): detect pre-installed OEM/consumer Office, kill false office_install_failed
The OfficeInstallDetector fired a false office_install_failed on devices with
pre-installed OEM/consumer inbox Office (e.g. O365HomePremRetail +
OneNoteFreeRetail) running a background C2R CLIENTUPDATE/maintenance scenario:
the already-resident Office was mis-read as a failed Microsoft 365 Apps install,
with a benign small-integer scenario value ("2") treated as a failure code.
Field telemetry (202 office_install_started events) showed 200/202 real installs
start with streamingFinished=false (a live download) and only 2 already-resident
— so the clean discriminator is "already on disk", not the scenario name. A
"require INSTALL scenario to start" gate was rejected: 47% of real starts fire
via the early DO-CDN job before C2R writes its registry.
Changes:
- New office_preinstalled_detected (Info): emitted when the core Office binaries
are already on disk at the first signal AND the product set is consumer/OEM
inbox (OfficeProductClassifier). Anchored on the filesystem, NOT the flaky
StreamingFinished value (absent on some machines). Enterprise Office already on
disk keeps the started->completed path (CSP/Win32-wrapper case).
- Does NOT latch terminal: an enrollment often uninstalls the inbox Office and
installs a fresh one, so the detector stays armed (emit-once guard +
StatePreinstalled persisted for cross-reboot dedup, ResumePreinstalled set
before the process watcher's startup probe).
- Error attribution scoped to the INSTALL scenario only (+ errorSource); a
CLIENTUPDATE error never fails an install.
- Registry idle-start gated on InstallScenarioPresent (not any scenario).
- IsNonZeroNumericCode rejects implausibly small positive decimals (<1000).
- DO bytes are seeded only once a real lifecycle opens, so a preinstalled/
CLIENTUPDATE download cannot leak into a later install's doSummary.
- Web InstallProgress renders a sky "Pre-installed" info row; MCP + web event
catalogs updated.
V2.Core 1454/1454, web tsc clean, MCP 240 passed. Inert until redeploy.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+202
-2
@@ -53,7 +53,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Office
|
||||
public List<FakeSignalIngressSink.PostedSignal> OfficeEvents() => Sink.Posted
|
||||
.Where(p => p.Payload != null
|
||||
&& p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et)
|
||||
&& et != null && et.StartsWith("office_install_", StringComparison.Ordinal))
|
||||
&& et != null && et.StartsWith("office_", StringComparison.Ordinal))
|
||||
.ToList();
|
||||
|
||||
public void Dispose() => _tmp.Dispose();
|
||||
@@ -69,6 +69,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Office
|
||||
StreamingFinished = false,
|
||||
ActiveScenarioPresent = true,
|
||||
ActiveScenarioName = "INSTALL",
|
||||
InstallScenarioPresent = true,
|
||||
InstallScenarioName = "INSTALL",
|
||||
OfficeC2RClientRunning = true,
|
||||
};
|
||||
s.Products.Add("O365ProPlusRetail");
|
||||
@@ -92,6 +94,28 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Office
|
||||
return s;
|
||||
}
|
||||
|
||||
// An Office that is already fully resident on disk at the first signal: OEM/consumer inbox
|
||||
// Office (O365HomePremRetail + OneNoteFreeRetail) running a background CLIENTUPDATE — the
|
||||
// false-positive shape from field session fa526757.
|
||||
private static OfficeC2RSnapshot PreinstalledConsumer(string scenario = "CLIENTUPDATE")
|
||||
{
|
||||
var s = new OfficeC2RSnapshot
|
||||
{
|
||||
ConfigurationKeyPresent = true,
|
||||
Channel = "Current",
|
||||
Platform = "x64",
|
||||
StreamingFinished = true,
|
||||
ActiveScenarioPresent = true,
|
||||
ActiveScenarioName = scenario,
|
||||
OfficeC2RClientRunning = true,
|
||||
InstallationPath = @"C:\Program Files\Microsoft Office",
|
||||
VersionToReport = "16.0.16327.20264",
|
||||
};
|
||||
s.Products.Add("O365HomePremRetail");
|
||||
s.Products.Add("OneNoteFreeRetail");
|
||||
return s;
|
||||
}
|
||||
|
||||
private static string EventType(FakeSignalIngressSink.PostedSignal p) => p.Payload![SignalPayloadKeys.EventType];
|
||||
private static string Severity(FakeSignalIngressSink.PostedSignal p) => p.Payload![SignalPayloadKeys.Severity];
|
||||
private static Dictionary<string, object> Data(FakeSignalIngressSink.PostedSignal p)
|
||||
@@ -382,12 +406,188 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Office
|
||||
Assert.Equal("0x80070005", Data(failed)["errorCode"]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- preinstalled (already-resident)
|
||||
|
||||
[Fact]
|
||||
public void Resident_consumer_office_emits_preinstalled_not_started_or_failed()
|
||||
{
|
||||
// OEM/consumer inbox Office already on disk at the first signal, running a background
|
||||
// CLIENTUPDATE — surface an informational office_preinstalled_detected, never a
|
||||
// started/failed pair (field session fa526757 / the CLIENTUPDATE errorCode "2" false alarm).
|
||||
using var rig = new Rig(PreinstalledConsumer());
|
||||
rig.Sut.CoreBinariesProbe = _ => true; // core Office binaries already present on disk
|
||||
|
||||
rig.Sut.OnWorkerStarted();
|
||||
|
||||
var ev = Assert.Single(rig.OfficeEvents());
|
||||
Assert.Equal(Constants.EventTypes.OfficePreinstalledDetected, EventType(ev));
|
||||
Assert.Equal("Info", Severity(ev));
|
||||
var data = Data(ev);
|
||||
Assert.Equal("office_already_resident", data["reason"]);
|
||||
Assert.Equal("CLIENTUPDATE", data["scenario"]);
|
||||
Assert.Contains("O365HomePremRetail", (List<string>)data["products"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resident_consumer_office_with_install_scenario_still_preinstalled()
|
||||
{
|
||||
// The real field false positive had scenario=INSTALL yet Office was already resident — the
|
||||
// filesystem proof (not the scenario name) is what classifies it as preinstalled.
|
||||
using var rig = new Rig(PreinstalledConsumer(scenario: "INSTALL"));
|
||||
rig.Sut.CoreBinariesProbe = _ => true;
|
||||
|
||||
rig.Sut.OnWorkerStarted();
|
||||
|
||||
Assert.Equal(Constants.EventTypes.OfficePreinstalledDetected, EventType(Assert.Single(rig.OfficeEvents())));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resident_enterprise_office_still_starts_not_preinstalled()
|
||||
{
|
||||
// Enterprise Office already on disk (Office CSP / Win32-wrapper re-run, field session
|
||||
// a7525e97) is NOT preinstalled inbox Office — keep the started→completed path.
|
||||
var snap = ActiveStreaming(); // O365ProPlusRetail
|
||||
snap.InstallationPath = @"C:\Program Files\Microsoft Office";
|
||||
using var rig = new Rig(snap);
|
||||
rig.Sut.CoreBinariesProbe = _ => true;
|
||||
|
||||
rig.Sut.OnWorkerStarted();
|
||||
|
||||
Assert.Equal(Constants.EventTypes.OfficeInstallStarted, EventType(Assert.Single(rig.OfficeEvents())));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fresh_install_without_binaries_on_disk_starts_normally()
|
||||
{
|
||||
// The common case (200/202 field starts): early DO trigger, nothing on disk yet → started.
|
||||
using var rig = new Rig(PreinstalledConsumer());
|
||||
rig.Sut.CoreBinariesProbe = _ => false; // no binaries on disk yet → a genuine fresh install
|
||||
|
||||
rig.Sut.OnWorkerStarted();
|
||||
|
||||
Assert.Equal(Constants.EventTypes.OfficeInstallStarted, EventType(Assert.Single(rig.OfficeEvents())));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Preinstalled_persists_nonterminal_state_so_next_run_rearms()
|
||||
{
|
||||
// Persisted for emit-once dedup, but NOT terminal — the factory must still arm the detector
|
||||
// next run (an enrollment often uninstalls the inbox Office and installs a fresh one).
|
||||
using var rig = new Rig(PreinstalledConsumer(), withPersistence: true);
|
||||
rig.Sut.CoreBinariesProbe = _ => true;
|
||||
|
||||
rig.Sut.OnWorkerStarted();
|
||||
|
||||
var state = rig.Persistence!.Load();
|
||||
Assert.Equal(OfficeInstallStateData.StatePreinstalled, state!.State);
|
||||
Assert.False(state.IsTerminal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Preinstalled_emits_once_then_a_later_fresh_install_still_starts()
|
||||
{
|
||||
// Resident inbox Office reports preinstalled (no lifecycle). Then the enrollment lays down a
|
||||
// fresh enterprise Microsoft 365 Apps (real download, no binaries on disk yet) — that MUST
|
||||
// still open a normal started lifecycle (the re-arm requirement).
|
||||
using var rig = new Rig(PreinstalledConsumer());
|
||||
rig.Sut.CoreBinariesProbe = _ => true;
|
||||
rig.Sut.OnWorkerStarted(); // → office_preinstalled_detected, stays Idle/armed
|
||||
|
||||
// A second resident-consumer signal must NOT emit a duplicate preinstalled.
|
||||
rig.Sut.OnWorkerStarted();
|
||||
|
||||
var afterFirst = rig.OfficeEvents();
|
||||
Assert.Single(afterFirst);
|
||||
Assert.Equal(Constants.EventTypes.OfficePreinstalledDetected, EventType(afterFirst[0]));
|
||||
|
||||
// Now a fresh enterprise install begins (binaries not yet on disk → real download).
|
||||
rig.Current = ActiveStreaming(); // O365ProPlusRetail, streaming
|
||||
rig.Sut.CoreBinariesProbe = _ => false;
|
||||
rig.Sut.OnOfficeDoSample(new OfficeDoSample { JobCount = 2, FileSize = 1000, TotalBytesDownloaded = 100 });
|
||||
|
||||
var events = rig.OfficeEvents();
|
||||
Assert.Equal(2, events.Count);
|
||||
Assert.Equal(Constants.EventTypes.OfficeInstallStarted, EventType(events[1]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Preinstalled_do_bytes_do_not_leak_into_a_later_real_install_summary()
|
||||
{
|
||||
// A CLIENTUPDATE DO download (large bytes) drives the preinstalled short-circuit — those
|
||||
// bytes must NOT seed _peakDo, or a later real install's doSummary would report them.
|
||||
using var rig = new Rig(PreinstalledConsumer());
|
||||
rig.Sut.CoreBinariesProbe = _ => true;
|
||||
rig.Sut.OnOfficeDoSample(new OfficeDoSample { JobCount = 3, FileSize = 5000, TotalBytesDownloaded = 4000 });
|
||||
Assert.Equal(Constants.EventTypes.OfficePreinstalledDetected, EventType(Assert.Single(rig.OfficeEvents())));
|
||||
|
||||
// Now a real enterprise install begins (smaller download) and completes.
|
||||
rig.Current = ActiveStreaming();
|
||||
rig.Sut.CoreBinariesProbe = _ => false;
|
||||
rig.Sut.OnOfficeDoSample(new OfficeDoSample { JobCount = 1, FileSize = 1000, TotalBytesDownloaded = 600, BytesFromHttp = 600 });
|
||||
rig.Current = CompletedIdle();
|
||||
rig.Sut.CoreBinariesProbe = _ => true;
|
||||
Assert.Equal(OfficeInstallDetector.CompletionOutcome.Completed, rig.Sut.TryFinalizeCompletion());
|
||||
|
||||
var completed = rig.OfficeEvents().Last();
|
||||
Assert.Equal(Constants.EventTypes.OfficeInstallCompleted, EventType(completed));
|
||||
var summary = Assert.IsType<Dictionary<string, object>>(Data(completed)["doSummary"]);
|
||||
Assert.Equal(600L, summary["totalBytesDownloaded"]); // the real install's bytes, NOT the 4000 CLIENTUPDATE bytes
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResumePreinstalled_suppresses_duplicate_emit_but_stays_armed()
|
||||
{
|
||||
using var rig = new Rig(PreinstalledConsumer());
|
||||
rig.Sut.CoreBinariesProbe = _ => true;
|
||||
|
||||
// Simulate a restart where preinstalled was already reported in a previous run.
|
||||
rig.Sut.ResumePreinstalled(new OfficeInstallStateData { State = OfficeInstallStateData.StatePreinstalled });
|
||||
|
||||
// Resident-consumer signal this run must NOT re-emit preinstalled.
|
||||
rig.Sut.OnWorkerStarted();
|
||||
Assert.Empty(rig.OfficeEvents());
|
||||
|
||||
// But a fresh enterprise install still starts (armed).
|
||||
rig.Current = ActiveStreaming();
|
||||
rig.Sut.CoreBinariesProbe = _ => false;
|
||||
rig.Sut.OnWorkerStarted();
|
||||
Assert.Equal(Constants.EventTypes.OfficeInstallStarted, EventType(Assert.Single(rig.OfficeEvents())));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- product classifier
|
||||
|
||||
[Theory]
|
||||
[InlineData(true, "O365HomePremRetail", "OneNoteFreeRetail")] // OEM consumer inbox
|
||||
[InlineData(true, "OneNoteFreeRetail")]
|
||||
[InlineData(true, "HomeBusinessRetail")] // consumer perpetual — NOT the managed O365Business
|
||||
[InlineData(true, "HomeStudent2019Retail")]
|
||||
[InlineData(false, "O365ProPlusRetail")] // M365 Apps for enterprise
|
||||
[InlineData(false, "O365BusinessRetail")] // M365 Apps for business (managed)
|
||||
[InlineData(false, "O365HomePremRetail", "O365ProPlusRetail")] // mixed → managed wins
|
||||
[InlineData(false, "SomeUnknownSku")] // unknown → not consumer
|
||||
public void IsConsumerInboxProductSet_classifies_consumer_vs_managed(bool expected, params string[] products)
|
||||
{
|
||||
Assert.Equal(expected, OfficeProductClassifier.IsConsumerInboxProductSet(products));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsConsumerInboxProductSet_empty_is_not_consumer()
|
||||
{
|
||||
Assert.False(OfficeProductClassifier.IsConsumerInboxProductSet(new List<string>()));
|
||||
Assert.False(OfficeProductClassifier.IsConsumerInboxProductSet(null));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- error-code heuristic
|
||||
|
||||
[Theory]
|
||||
[InlineData("0x80070005", true)] // hex HRESULT
|
||||
[InlineData("17002", true)] // decimal C2R exit code
|
||||
[InlineData("30015", true)] // decimal C2R exit code
|
||||
[InlineData("-2147024891", true)] // negative decimal HRESULT
|
||||
[InlineData("2", false)] // implausibly small positive — a state/enum, not an error
|
||||
[InlineData("9", false)]
|
||||
[InlineData("999", false)] // just below the plausibility floor
|
||||
[InlineData("1000", true)] // at the floor
|
||||
[InlineData("0x0", false)] // zero hex
|
||||
[InlineData("0", false)] // zero decimal
|
||||
[InlineData("Success", false)] // benign textual value (e.g. Result=Success)
|
||||
@@ -395,7 +595,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Office
|
||||
[InlineData("InProgress", false)]
|
||||
[InlineData("", false)]
|
||||
[InlineData(null, false)]
|
||||
public void IsNonZeroNumericCode_only_treats_nonzero_numbers_as_errors(string? value, bool expected)
|
||||
public void IsNonZeroNumericCode_only_treats_plausible_nonzero_numbers_as_errors(string? value, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, OfficeInstallDetector.IsNonZeroNumericCode(value!));
|
||||
}
|
||||
|
||||
+1
@@ -96,6 +96,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Office
|
||||
[InlineData(OfficeInstallStateData.StateActive, false)]
|
||||
[InlineData(OfficeInstallStateData.StateCompleted, true)]
|
||||
[InlineData(OfficeInstallStateData.StateFailed, true)]
|
||||
[InlineData(OfficeInstallStateData.StatePreinstalled, false)] // persisted for dedup, but re-arms
|
||||
public void IsTerminal_only_for_completed_and_failed(string state, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, new OfficeInstallStateData { State = state }.IsTerminal);
|
||||
|
||||
+138
-10
@@ -59,6 +59,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
// names against a field enrollment and widen only if needed.
|
||||
private static readonly string[] ErrorValueHints = { "errorcode", "hresult", "exitcode", "lasterror", "failurecode" };
|
||||
|
||||
// Install-class C2R scenario subkey names. Only these anchor an error code (a failure in a
|
||||
// CLIENTUPDATE / UPDATE / maintenance scenario must not fail an install). Deliberately narrow
|
||||
// — the Autopilot enrollment cares about the fresh product lay-down, not client self-updates.
|
||||
private static readonly string[] InstallScenarioNames = { "INSTALL" };
|
||||
|
||||
private static bool IsInstallScenario(string? name)
|
||||
=> name != null && InstallScenarioNames.Any(s => string.Equals(s, name, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
// Core Office app binaries used as the on-disk completion proof. ANY of these present under
|
||||
// {InstallationPath}\root\* means the C2R lay-down finished — "any of", because a deployment can
|
||||
// exclude products (e.g. no Outlook), so requiring all would miss legitimate completions.
|
||||
@@ -93,6 +101,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
internal enum CompletionOutcome { Completed, Failed, NotYet }
|
||||
|
||||
private DetectorState _state = DetectorState.Idle;
|
||||
private bool _preinstalledReported; // office_preinstalled_detected already emitted (emit-once guard)
|
||||
private DateTime? _startedAtUtc;
|
||||
private string? _startedTrigger;
|
||||
private OfficeDoSample? _lastDo;
|
||||
@@ -141,6 +150,22 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Restores the "preinstalled already reported" guard after an agent restart so resident inbox
|
||||
/// Office does not re-emit <c>office_preinstalled_detected</c> every reboot — while leaving the
|
||||
/// detector Idle/armed so a later fresh (enterprise) install in this enrollment is still caught.
|
||||
/// </summary>
|
||||
internal void ResumePreinstalled(OfficeInstallStateData state)
|
||||
{
|
||||
if (state == null || state.State != OfficeInstallStateData.StatePreinstalled) return;
|
||||
lock (_lock)
|
||||
{
|
||||
if (_state != DetectorState.Idle) return;
|
||||
_preinstalledReported = true;
|
||||
_logger.Info($"[{SourceName}] resumed preinstalled-reported guard (armed for a later fresh install)");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Surfaces the C2R <c>InstallationPath</c> to the host (once) as soon as it is known, so the
|
||||
/// host can arm the <see cref="OfficeBinaryWatcher"/> on the correct tree. Caller holds the lock.
|
||||
@@ -189,7 +214,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
|
||||
if (_state == DetectorState.Idle)
|
||||
{
|
||||
if (snap.ActiveScenarioPresent) BeginIfIdle("registry", snap);
|
||||
// Start from the registry ONLY for an install-class scenario. A CLIENTUPDATE / UPDATE
|
||||
// scenario key (the C2R client self-update / maintenance) must not open an install
|
||||
// lifecycle. The early DO-job trigger (OnOfficeDoSample) still catches a real install
|
||||
// before its INSTALL key is written — the common case (47% of field starts).
|
||||
if (snap.InstallScenarioPresent) BeginIfIdle("registry", snap);
|
||||
return;
|
||||
}
|
||||
EvaluateActive(snap);
|
||||
@@ -209,9 +238,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
if (sample == null) return;
|
||||
lock (_lock)
|
||||
{
|
||||
_lastDo = sample;
|
||||
var peakGrew = _peakDo == null || sample.TotalBytesDownloaded > _peakDo.TotalBytesDownloaded;
|
||||
if (peakGrew) _peakDo = sample;
|
||||
if (_state == DetectorState.Terminal) return;
|
||||
|
||||
if (_state == DetectorState.Idle)
|
||||
@@ -220,11 +246,24 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
var snapStart = ReadSnapshotSafe();
|
||||
if (snapStart == null) return;
|
||||
BeginIfIdle("do", snapStart);
|
||||
// Seed the DO summary from the triggering sample ONLY if a real lifecycle actually
|
||||
// opened. A preinstalled short-circuit stays Idle — capturing its CLIENTUPDATE bytes
|
||||
// here would leak them into a LATER real install's doSummary (which is built from
|
||||
// _peakDo). The seed is persisted directly (it is the lifecycle's first DO sample).
|
||||
if (_state == DetectorState.Active)
|
||||
{
|
||||
_lastDo = sample;
|
||||
_peakDo = sample;
|
||||
PersistState(OfficeInstallStateData.StateActive);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Active: no progress event — the sample only updates the DO summary data. A grown
|
||||
// peak is re-persisted (throttled) so a mid-install agent restart keeps the doSummary.
|
||||
_lastDo = sample;
|
||||
var peakGrew = _peakDo == null || sample.TotalBytesDownloaded > _peakDo.TotalBytesDownloaded;
|
||||
if (peakGrew) _peakDo = sample;
|
||||
if (peakGrew && _statePersistence != null
|
||||
&& (!_lastStatePersistUtc.HasValue
|
||||
|| (_clock.UtcNow - _lastStatePersistUtc.Value).TotalSeconds >= PeakPersistThrottleSeconds))
|
||||
@@ -294,6 +333,37 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
private void BeginIfIdle(string trigger, OfficeC2RSnapshot snap)
|
||||
{
|
||||
if (_state != DetectorState.Idle) return;
|
||||
|
||||
// Already-resident detection — the precision gate. When the core Office binaries are
|
||||
// ALREADY on disk at the very first signal, this C2R activity is NOT a fresh install:
|
||||
// Office was laid down before the detector opened. Two flavours:
|
||||
// * consumer / OEM-inbox product set → surface an informational office_preinstalled_detected
|
||||
// (the C2R client running a background CLIENTUPDATE on pre-provisioned Office — field
|
||||
// session fa526757), NOT a started/failed pair.
|
||||
// * enterprise product set → fall through to the existing started→completed path (Office
|
||||
// CSP / Win32-wrapper re-run on already-present Office — field session a7525e97).
|
||||
// Anchored on the filesystem, NOT the StreamingFinished registry value (absent on some
|
||||
// machines → reads false), so it holds even when that value is missing.
|
||||
//
|
||||
// Crucially this does NOT latch a terminal: an enrollment commonly UNINSTALLS the inbox
|
||||
// Office and lays down a fresh (enterprise) Microsoft 365 Apps afterwards, which we must
|
||||
// still report. So we emit the informational fact ONCE (idempotent guard) and stay Idle —
|
||||
// a later fresh install (enterprise product set, or a real download with no binaries on
|
||||
// disk yet) opens a normal started→completed lifecycle from here.
|
||||
if (ProbeCoreBinaries(snap.InstallationPath)
|
||||
&& OfficeProductClassifier.IsConsumerInboxProductSet(snap.Products))
|
||||
{
|
||||
if (!_preinstalledReported)
|
||||
{
|
||||
_preinstalledReported = true;
|
||||
_startedAtUtc = _clock.UtcNow;
|
||||
_startedTrigger = trigger;
|
||||
EmitPreinstalled(snap);
|
||||
PersistState(OfficeInstallStateData.StatePreinstalled);
|
||||
}
|
||||
return; // stay Idle (armed) — never open an install lifecycle for resident inbox Office
|
||||
}
|
||||
|
||||
_state = DetectorState.Active;
|
||||
_startedAtUtc = _clock.UtcNow;
|
||||
_startedTrigger = trigger;
|
||||
@@ -330,6 +400,35 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
PersistState(OfficeInstallStateData.StateFailed);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emits the informational <c>office_preinstalled_detected</c> for an Office that was already
|
||||
/// resident on disk at the first signal (OEM/consumer inbox Office running a background
|
||||
/// CLIENTUPDATE/maintenance scenario). This is NOT an enrollment install — Info severity, no
|
||||
/// started/failed pair. The caller has set the emit-once guard + start metadata and deliberately
|
||||
/// leaves the detector Idle/armed (no terminal latch) so a later fresh install is still detected.
|
||||
/// </summary>
|
||||
private void EmitPreinstalled(OfficeC2RSnapshot snap)
|
||||
{
|
||||
var data = BuildPayload(snap, "Preinstalled", isTerminal: true);
|
||||
if (!string.IsNullOrEmpty(_startedTrigger)) data["startedTrigger"] = _startedTrigger!;
|
||||
// Why we classified it as preinstalled — core binaries already on disk at first signal.
|
||||
data["reason"] = "office_already_resident";
|
||||
|
||||
var product = snap.Products != null && snap.Products.Count > 0 ? string.Join(",", snap.Products) : AppName;
|
||||
_post.Emit(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
TenantId = _tenantId,
|
||||
EventType = Constants.EventTypes.OfficePreinstalledDetected,
|
||||
Severity = EventSeverity.Info,
|
||||
Source = SourceName,
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = $"{SourceName}: {product} already present (pre-installed OEM/consumer Office — no enrollment install)",
|
||||
Data = data,
|
||||
ImmediateUpload = true,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Persists the lifecycle state (fail-soft). Active and the two terminals are persisted;
|
||||
/// <see cref="AbandonSilently"/> deliberately is NOT — it fires on every dispose/shutdown
|
||||
@@ -447,6 +546,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
{ "streamingFinished", snap.StreamingFinished },
|
||||
{ "officeC2RClientRunning", snap.OfficeC2RClientRunning },
|
||||
{ "errorCode", (object?)snap.ErrorCode ?? string.Empty },
|
||||
{ "errorSource", (object?)snap.ErrorSource ?? string.Empty },
|
||||
{ "installationPath", snap.InstallationPath ?? string.Empty },
|
||||
{ "scanErrors", (snap.Errors ?? new List<string>()).ToList() },
|
||||
};
|
||||
@@ -587,6 +687,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
|
||||
foreach (var scenarioName in scenarioNames)
|
||||
{
|
||||
// Attribute an error code ONLY from an install-class scenario. A failure value
|
||||
// in a CLIENTUPDATE / UPDATE / maintenance scenario (the C2R client self-update
|
||||
// and similar) must never fail an Office *install* — field session fa526757 /
|
||||
// the CLIENTUPDATE errorCode "2" false positive.
|
||||
var isInstallScenario = IsInstallScenario(scenarioName);
|
||||
if (isInstallScenario && snap.InstallScenarioName == null)
|
||||
{
|
||||
snap.InstallScenarioPresent = true;
|
||||
snap.InstallScenarioName = scenarioName;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using (var scenarioKey = scenarioRoot.OpenSubKey(scenarioName, writable: false))
|
||||
@@ -598,11 +709,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
if (value == null) continue;
|
||||
snap.ScenarioValues[$"{scenarioName}\\{valueName}"] = value;
|
||||
|
||||
if (snap.ErrorCode == null
|
||||
if (isInstallScenario && snap.ErrorCode == null
|
||||
&& ErrorValueHints.Any(h => valueName.IndexOf(h, StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
&& IsNonZeroNumericCode(value))
|
||||
{
|
||||
snap.ErrorCode = value;
|
||||
snap.ErrorSource = $"{scenarioName}\\{valueName}";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -660,10 +772,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
return v == "1" || v.Equals("true", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
// Below this, a bare positive decimal is treated as an implausible failure code (a state /
|
||||
// phase / mode / retry-count enum, not an error). Real C2R failures are HRESULTs (hex or
|
||||
// negative decimal) or 5-digit C2R codes (17002, 30015, 30088…) — all >= this floor. Field
|
||||
// session fa526757 / the CLIENTUPDATE "2" false positive motivated this.
|
||||
private const long MinPlausibleDecimalErrorCode = 1000;
|
||||
|
||||
/// <summary>
|
||||
/// True only when <paramref name="value"/> is a non-zero numeric or hex (0x…) code. Textual
|
||||
/// values such as "Success" / "Completed" / "InProgress" are NOT error codes and return false,
|
||||
/// so a benign "Result=Success" never masquerades as a failure.
|
||||
/// True only when <paramref name="value"/> is a PLAUSIBLE non-zero error code: a non-zero hex
|
||||
/// (0x…) value, a negative decimal HRESULT, or a positive decimal at/above
|
||||
/// <see cref="MinPlausibleDecimalErrorCode"/>. Textual values ("Success" / "Completed" /
|
||||
/// "InProgress") and implausibly small positive decimals (e.g. "2") return false, so a benign
|
||||
/// status/enum value never masquerades as a failure.
|
||||
/// </summary>
|
||||
internal static bool IsNonZeroNumericCode(string value)
|
||||
{
|
||||
@@ -673,8 +793,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
{
|
||||
return long.TryParse(v.Substring(2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var hex) && hex != 0;
|
||||
}
|
||||
// Decimal (HRESULTs can be large or negative); reject anything non-numeric.
|
||||
return long.TryParse(v, NumberStyles.Integer, CultureInfo.InvariantCulture, out var dec) && dec != 0;
|
||||
// Decimal: reject non-numeric, zero, and implausibly small positive values; keep negative
|
||||
// HRESULTs and large positive C2R codes.
|
||||
if (!long.TryParse(v, NumberStyles.Integer, CultureInfo.InvariantCulture, out var dec) || dec == 0) return false;
|
||||
return dec < 0 || dec >= MinPlausibleDecimalErrorCode;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -694,8 +816,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
public bool StreamingFinished { get; set; }
|
||||
public bool ActiveScenarioPresent { get; set; }
|
||||
public string? ActiveScenarioName { get; set; }
|
||||
/// <summary>True when an install-class scenario (INSTALL) subkey is present.</summary>
|
||||
public bool InstallScenarioPresent { get; set; }
|
||||
/// <summary>The install-class scenario subkey name, when present (else null).</summary>
|
||||
public string? InstallScenarioName { get; set; }
|
||||
public Dictionary<string, string> ScenarioValues { get; } = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
public string? ErrorCode { get; set; }
|
||||
/// <summary>Which <c>scenario\value</c> produced <see cref="ErrorCode"/> — for diagnosis.</summary>
|
||||
public string? ErrorSource { get; set; }
|
||||
public bool OfficeC2RClientRunning { get; set; }
|
||||
public List<string> Errors { get; } = new List<string>();
|
||||
}
|
||||
|
||||
+9
-1
@@ -92,8 +92,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
public const string StateActive = "Active";
|
||||
public const string StateCompleted = "Completed";
|
||||
public const string StateFailed = "Failed";
|
||||
// The C2R activity was on an already-resident Office (OEM/consumer inbox) — a
|
||||
// office_preinstalled_detected went out, no install lifecycle was opened. Persisted ONLY to
|
||||
// suppress a duplicate emit across reboots; deliberately NOT terminal (see IsTerminal), because
|
||||
// an enrollment commonly uninstalls the inbox Office and lays down a fresh one afterwards — the
|
||||
// next run must re-arm the detector to catch that real install.
|
||||
public const string StatePreinstalled = "Preinstalled";
|
||||
|
||||
/// <summary><see cref="StateActive"/>, <see cref="StateCompleted"/> or <see cref="StateFailed"/>.</summary>
|
||||
/// <summary><see cref="StateActive"/>, <see cref="StateCompleted"/>, <see cref="StateFailed"/> or <see cref="StatePreinstalled"/>.</summary>
|
||||
public string? State { get; set; }
|
||||
|
||||
public DateTime? StartedAtUtc { get; set; }
|
||||
@@ -102,6 +108,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
/// <summary>Highest-bytes DO sample seen — basis for the doSummary on a resumed completion.</summary>
|
||||
public OfficeDoPeakData? PeakDo { get; set; }
|
||||
|
||||
// Preinstalled is intentionally excluded — it is persisted for emit-once dedup but must NOT
|
||||
// block the next run from arming the detector (a later fresh install must still be detected).
|
||||
[JsonIgnore]
|
||||
public bool IsTerminal => State == StateCompleted || State == StateFailed;
|
||||
}
|
||||
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
{
|
||||
/// <summary>
|
||||
/// Classifies a C2R <c>ProductReleaseIds</c> set as consumer / OEM-inbox Office vs an IT-managed
|
||||
/// deployment. Used to label an already-resident Office (core binaries present at the first
|
||||
/// signal) as preinstalled inbox Office — e.g. the <c>O365HomePremRetail</c> + <c>OneNoteFreeRetail</c>
|
||||
/// that ship pre-provisioned on many OEM Windows images and run background CLIENTUPDATE scenarios
|
||||
/// during an enrollment — rather than mis-reporting it as a Microsoft 365 Apps install/failure.
|
||||
/// </summary>
|
||||
internal static class OfficeProductClassifier
|
||||
{
|
||||
// Enterprise / IT-managed C2R product markers (Microsoft 365 Apps for enterprise/business,
|
||||
// volume). When ANY product carries one of these the set is treated as managed, never
|
||||
// "consumer inbox" — even if a consumer SKU is co-listed. NOTE: the business marker is the
|
||||
// specific "O365Business" prefix (the managed "Microsoft 365 Apps for business" id, e.g.
|
||||
// O365BusinessRetail), NOT a bare "Business" — otherwise the consumer perpetual HomeBusiness*Retail
|
||||
// SKUs would be wrongly caught here before the consumer markers below.
|
||||
private static readonly string[] ManagedMarkers = { "ProPlus", "O365Business", "Volume" };
|
||||
|
||||
// Consumer / OEM-preinstall SKU markers (substring, case-insensitive).
|
||||
private static readonly string[] ConsumerMarkers =
|
||||
{
|
||||
"HomePrem", "HomeStudent", "HomeBusiness", "Personal", "OneNoteFree", "Professional",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// True when the product set is non-empty AND every entry looks like a consumer / OEM-inbox SKU
|
||||
/// and none is an enterprise/business/volume SKU. Conservative: an unknown SKU (matching neither
|
||||
/// list) makes the set NOT consumer, so an unrecognized managed product is never mislabeled.
|
||||
/// </summary>
|
||||
internal static bool IsConsumerInboxProductSet(IReadOnlyList<string>? products)
|
||||
{
|
||||
if (products == null || products.Count == 0) return false;
|
||||
foreach (var p in products)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(p)) return false;
|
||||
if (ManagedMarkers.Any(m => p.IndexOf(m, StringComparison.OrdinalIgnoreCase) >= 0)) return false;
|
||||
if (!ConsumerMarkers.Any(m => p.IndexOf(m, StringComparison.OrdinalIgnoreCase) >= 0)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -298,9 +298,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
// several reboots, and Scenario\INSTALL persists post-install / OfficeC2RClient.exe
|
||||
// runs again for update checks / Office updates stream from the same CDN — all three
|
||||
// start triggers would falsely re-open the window and emit a duplicate
|
||||
// started+completed pair every restart). A persisted terminal means the install was
|
||||
// already reported → don't arm the detector at all this run; a persisted Active is
|
||||
// resumed inside the host (no second started, missed completion delivered late).
|
||||
// started+completed pair every restart). A persisted terminal (Completed/Failed) means
|
||||
// the install was already reported → don't arm the detector at all this run; a persisted
|
||||
// Active is resumed inside the host (no second started, missed completion delivered late).
|
||||
// A persisted Preinstalled is NOT terminal — the host is still armed (an enrollment often
|
||||
// uninstalls the inbox Office and installs a fresh one), it only suppresses a duplicate
|
||||
// office_preinstalled_detected via ResumePreinstalled.
|
||||
var officeStatePersistence = new OfficeInstallStatePersistence(_stateDirectory, logger);
|
||||
var officeState = officeStatePersistence.Load();
|
||||
if (officeState != null && officeState.IsTerminal)
|
||||
|
||||
+20
-5
@@ -33,6 +33,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
/// An explicit error code → failed. No binary ever appears → the lifecycle simply stays open and is
|
||||
/// abandoned silently on dispose (never a false completed/failed).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Already-resident short-circuit:</b> when the core Office binaries are already on disk at the
|
||||
/// first signal, the detector does not open an install lifecycle — a consumer/OEM-inbox product set
|
||||
/// emits an informational <c>office_preinstalled_detected</c> (no binary watcher armed), enterprise
|
||||
/// Office falls through to the synchronous started→completed path. See <see cref="OfficeInstallDetector"/>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal sealed class OfficeInstallDetectorHost : ICollectorHost
|
||||
{
|
||||
@@ -91,6 +97,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
|
||||
public void Start()
|
||||
{
|
||||
// Restore the preinstalled emit-once guard BEFORE arming any watcher. The process watcher's
|
||||
// startup probe can synchronously fire Started (→ OnWorkerStarted → BeginIfIdle) when
|
||||
// OfficeC2RClient.exe is already running, which would otherwise re-emit
|
||||
// office_preinstalled_detected for the resident inbox Office. ResumePreinstalled only sets
|
||||
// the guard (no watcher/InstallationPath interaction), so it is safe to run this early.
|
||||
if (_resumeState != null && _resumeState.State == OfficeInstallStateData.StatePreinstalled)
|
||||
_detector.ResumePreinstalled(_resumeState);
|
||||
|
||||
// Arm the registry watcher up front (not at worker-start) so the early Scenario\INSTALL key
|
||||
// is caught before the (late) worker process. The watcher's bootstrap fallback handles the
|
||||
// case where the ClickToRun key does not exist yet on a clean first install.
|
||||
@@ -102,11 +116,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
}
|
||||
_processWatcher.Start();
|
||||
|
||||
// Resume a lifecycle left open by a previous agent run (enrollments span reboots) AFTER
|
||||
// the watchers are armed: the resume surfaces the InstallationPath, which arms the binary
|
||||
// watcher — when Office finished installing while the agent was down, its initial scan
|
||||
// completes the lifecycle synchronously and the teardown disposes the watchers again.
|
||||
if (_resumeState != null) _detector.ResumeActive(_resumeState);
|
||||
// Resume a persisted *Active* lifecycle (enrollments span reboots) AFTER the watchers are
|
||||
// armed: the resume surfaces the InstallationPath, which arms the binary watcher — when
|
||||
// Office finished installing while the agent was down, its initial scan completes the
|
||||
// lifecycle synchronously and the teardown disposes the just-armed watchers again.
|
||||
if (_resumeState != null && _resumeState.State != OfficeInstallStateData.StatePreinstalled)
|
||||
_detector.ResumeActive(_resumeState);
|
||||
}
|
||||
|
||||
private void OnWorkerStarted(object? sender, EventArgs e) => _detector.OnWorkerStarted();
|
||||
|
||||
@@ -69,6 +69,7 @@ export const EVENT_TYPES_CATALOG = {
|
||||
'office_install_progress',
|
||||
'office_install_completed',
|
||||
'office_install_failed',
|
||||
'office_preinstalled_detected',
|
||||
],
|
||||
script_events: ['script_started', 'script_completed', 'script_failed'],
|
||||
network_events: [
|
||||
|
||||
@@ -324,6 +324,10 @@ namespace AutopilotMonitor.Shared
|
||||
public const string OfficeInstallProgress = "office_install_progress";
|
||||
public const string OfficeInstallCompleted = "office_install_completed";
|
||||
public const string OfficeInstallFailed = "office_install_failed";
|
||||
// Emitted instead of a started/failed pair when the C2R activity is on an Office that is
|
||||
// ALREADY fully resident on disk at the first signal (OEM/consumer inbox Office running a
|
||||
// background CLIENTUPDATE/maintenance scenario). Informational — NOT an enrollment failure.
|
||||
public const string OfficePreinstalledDetected = "office_preinstalled_detected";
|
||||
|
||||
// Stall detection (Ebene 2 — StallProbeCollector)
|
||||
public const string StallProbeCheck = "stall_probe_check"; // Trace heartbeat from Probe 2 (15 min) when no anomaly found
|
||||
|
||||
@@ -96,6 +96,8 @@ export const KNOWN_EVENT_TYPES: EventTypeEntry[] = [
|
||||
description: "Office C2R install completed (streaming finished, scenario cleared); carries version reached and duration." },
|
||||
{ value: "office_install_failed", label: "office_install_failed", category: "app",
|
||||
description: "Office C2R install ended without reaching completion, or a failure code was observed." },
|
||||
{ value: "office_preinstalled_detected", label: "office_preinstalled_detected", category: "app",
|
||||
description: "Office was already fully resident on disk at the first signal (OEM/consumer inbox Office running a background CLIENTUPDATE) — informational, not an enrollment install or failure." },
|
||||
|
||||
// -------- Scripts --------
|
||||
{ value: "script_started", label: "script_started", category: "script",
|
||||
|
||||
@@ -600,7 +600,7 @@ export default function SessionDetailPage() {
|
||||
<InstallProgress
|
||||
events={events.filter(
|
||||
e => e.eventType === "app_install_started" || e.eventType === "app_install_completed" || e.eventType === "app_install_failed" || e.eventType === "app_install_postponed" || e.eventType === "app_install_skipped"
|
||||
|| e.eventType === "office_install_started" || e.eventType === "office_install_completed" || e.eventType === "office_install_failed"
|
||||
|| e.eventType === "office_install_started" || e.eventType === "office_install_completed" || e.eventType === "office_install_failed" || e.eventType === "office_preinstalled_detected"
|
||||
|| e.eventType === "realmjoin_package_started" || e.eventType === "realmjoin_package_completed"
|
||||
)}
|
||||
summaryStats={appSummaryStats}
|
||||
|
||||
@@ -32,7 +32,7 @@ const ESP_APPS_INSTALL_FAILURE = "esp_apps_install_failure";
|
||||
interface InstallItem {
|
||||
appName: string;
|
||||
appId: string;
|
||||
state: "Installing" | "Installed" | "Failed" | "Postponed" | "Skipped";
|
||||
state: "Installing" | "Installed" | "Failed" | "Postponed" | "Skipped" | "Preinstalled";
|
||||
startedAt?: string;
|
||||
completedAt?: string;
|
||||
durationMs?: number;
|
||||
@@ -206,6 +206,24 @@ export default function InstallProgress({ events, summaryStats }: InstallProgres
|
||||
firstSeenIndex: existing?.firstSeenIndex ?? insertionIndex++,
|
||||
eventData: d,
|
||||
});
|
||||
} else if (type === "office_preinstalled_detected") {
|
||||
// Office was already fully resident on disk at the first signal (OEM/consumer inbox Office
|
||||
// running a background CLIENTUPDATE) — informational, not an enrollment install or failure.
|
||||
// Don't overwrite a real terminal state if one somehow co-exists.
|
||||
if (existing?.state === "Installed" || existing?.state === "Failed") continue;
|
||||
installMap.set(appName, {
|
||||
appName,
|
||||
appId,
|
||||
state: "Preinstalled",
|
||||
completedAt: eventTs,
|
||||
isCompleted: true,
|
||||
isError: false,
|
||||
isLikelyStuck: false,
|
||||
isDetectionFailure: false,
|
||||
isInstallFailure: false,
|
||||
firstSeenIndex: existing?.firstSeenIndex ?? insertionIndex++,
|
||||
eventData: d,
|
||||
});
|
||||
} else if (evt.eventType === "app_install_postponed") {
|
||||
// Don't downgrade from Installed to Postponed.
|
||||
if (existing?.state === "Installed") continue;
|
||||
@@ -384,13 +402,15 @@ function InstallItemRow({ item }: { item: InstallItem }) {
|
||||
// have a concrete HRESULT and are confirmed errors → red, same as any other.
|
||||
const containerClass = item.state === "Skipped"
|
||||
? "bg-gray-50 border border-gray-300"
|
||||
: item.isLikelyStuck
|
||||
? "bg-orange-50 border border-orange-200"
|
||||
: item.isError
|
||||
? "bg-red-50 border border-red-200"
|
||||
: item.isCompleted
|
||||
? "bg-green-50 border border-green-200"
|
||||
: "bg-gray-50 border border-gray-200";
|
||||
: item.state === "Preinstalled"
|
||||
? "bg-sky-50 border border-sky-200"
|
||||
: item.isLikelyStuck
|
||||
? "bg-orange-50 border border-orange-200"
|
||||
: item.isError
|
||||
? "bg-red-50 border border-red-200"
|
||||
: item.isCompleted
|
||||
? "bg-green-50 border border-green-200"
|
||||
: "bg-gray-50 border border-gray-200";
|
||||
|
||||
return (
|
||||
<div className={`rounded-lg p-3 ${containerClass}`}>
|
||||
@@ -400,6 +420,11 @@ function InstallItemRow({ item }: { item: InstallItem }) {
|
||||
<svg className="w-4 h-4 text-gray-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 5l7 7-7 7M5 5l7 7-7 7" />
|
||||
</svg>
|
||||
) : item.state === "Preinstalled" ? (
|
||||
// Info circle — already-resident Office, neither install nor failure.
|
||||
<svg className="w-4 h-4 text-sky-500 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
) : item.isLikelyStuck ? (
|
||||
// Question-mark inside circle — explicit "we don't actually know" iconography,
|
||||
// distinct from the hard X used for confirmed failures.
|
||||
@@ -455,6 +480,14 @@ function InstallItemRow({ item }: { item: InstallItem }) {
|
||||
{item.state === "Postponed" && (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-amber-200 text-amber-700 font-medium">Postponed</span>
|
||||
)}
|
||||
{item.state === "Preinstalled" && (
|
||||
<span
|
||||
className="text-xs px-2 py-0.5 rounded-full bg-sky-200 text-sky-800 font-medium"
|
||||
title="Office was already present on disk at enrollment start (OEM/consumer inbox Office) — not installed by this enrollment"
|
||||
>
|
||||
Pre-installed
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center space-x-3 text-xs text-gray-500 flex-shrink-0 ml-2">
|
||||
{elapsedMs != null && elapsedMs > 0 && (
|
||||
|
||||
Reference in New Issue
Block a user