mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
feat(reconcile): surface silence duration + decision time in timeout reconcile
The "Backend reconciled" badge showed only the reason text with no timestamps, so operators/customers could not tell "user finished setup and powered the device off" apart from "the mechanism declared success too early" (session efbc17ff): last-contact, silence window and the exact decision moment were all invisible, and the stage-1 "Agent silent for Nmin" note is wiped by the Succeeded-write reconcile hygiene. - EnrollmentTimeoutClassifier.ClassifyTimedOutSession takes an optional lastEventAtUtc and appends a timing clause to the two Succeeded-reconcile reasons (AccountSetup-complete + desktop/Hello): last agent contact, silence duration, and the sweep's declaration time. Lives in the reason string so it survives the hygiene and reaches badge/MCP/exports. - MaintenanceService passes session.LastEventAt through. - Web: add Session.stalledAt; reconcile banner renders structured UTC anchors (last agent contact + first-flagged-silent with the observed silence gap). Verified: build 0 errors; classifier 36/36 (2 new timing tests); maintenance/status/projection 160/160; web tsc clean; vitest 557/557. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -205,6 +205,41 @@ public class EnrollmentTimeoutClassifierTests
|
||||
Assert.Contains("RealmJoin", reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Classify_reconcile_reason_carries_silence_timing_transparency()
|
||||
{
|
||||
// Transparency (session efbc17ff): the reconcile reason must name the last agent
|
||||
// contact, the silence duration, and the exact moment the platform declared success —
|
||||
// so a customer can tell "user powered the device off" apart from "declared too early".
|
||||
var lastEvent = Start.AddMinutes(37); // agent last reported 37 min after start
|
||||
var now = Start.AddHours(5); // sweep declared success 5h after start
|
||||
var rollup = EnrollmentTimeoutClassifier.ExtractRollup(new[]
|
||||
{
|
||||
Esp(DeviceSetup44), Esp(AccountSetup15),
|
||||
Evt("desktop_arrived"), Evt("hello_provisioning_completed"), Evt("realmjoin_detected"),
|
||||
});
|
||||
var (status, reason) = EnrollmentTimeoutClassifier.ClassifyTimedOutSession(
|
||||
rollup, Start, now, graceHours: 72, lastEventAtUtc: lastEvent);
|
||||
|
||||
Assert.Equal(SessionStatus.Succeeded, status);
|
||||
Assert.Contains("RealmJoin", reason); // core verdict preserved
|
||||
Assert.Contains($"Agent last reported {lastEvent:yyyy-MM-dd HH:mm} UTC", reason);
|
||||
Assert.Contains("silent ~4h 23m", reason); // 5h - 37m
|
||||
Assert.Contains($"declared this success at {now:yyyy-MM-dd HH:mm} UTC", reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Classify_reconcile_reason_timing_falls_back_to_start_when_last_event_unknown()
|
||||
{
|
||||
// No last-contact time → anchor on StartedAt (same fallback the stalled-marker uses),
|
||||
// and the suffix is still emitted so the badge is never left timestamp-less.
|
||||
var now = Start.AddHours(6);
|
||||
var (_, reason) = Classify(new[] { Esp(DeviceSetup44), Esp(AccountSetup55) }, hoursSinceStart: 6);
|
||||
Assert.Contains($"Agent last reported {Start:yyyy-MM-dd HH:mm} UTC", reason);
|
||||
Assert.Contains("silent ~6h 0m", reason);
|
||||
Assert.Contains($"declared this success at {now:yyyy-MM-dd HH:mm} UTC", reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Classify_desktop_plus_hello_without_realmjoin_reconciles_to_Succeeded()
|
||||
{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
@@ -216,10 +217,18 @@ namespace AutopilotMonitor.Functions.Services
|
||||
/// </summary>
|
||||
/// <param name="rollup">Facts from <see cref="ExtractRollup"/>.</param>
|
||||
/// <param name="startedAtUtc">Session start (grace is measured from here).</param>
|
||||
/// <param name="nowUtc">Sweep timestamp.</param>
|
||||
/// <param name="nowUtc">Sweep timestamp — also the moment the platform declares the verdict.</param>
|
||||
/// <param name="graceHours">Grace window before AwaitingUser graduates to Incomplete.</param>
|
||||
/// <param name="lastEventAtUtc">
|
||||
/// Last event the agent reported (last backend contact). Used only to make the two
|
||||
/// Succeeded-reconcile reasons transparent about the silence: without it operators (and
|
||||
/// customers) cannot tell "user finished and powered the device off" apart from "the
|
||||
/// mechanism declared success too early" — session efbc17ff. Falls back to
|
||||
/// <paramref name="startedAtUtc"/> when unknown (same fallback the stalled-marker uses).
|
||||
/// </param>
|
||||
public static (SessionStatus Status, string Reason) ClassifyTimedOutSession(
|
||||
EspProvisioningRollup rollup, DateTime startedAtUtc, DateTime nowUtc, int graceHours)
|
||||
EspProvisioningRollup rollup, DateTime startedAtUtc, DateTime nowUtc, int graceHours,
|
||||
DateTime? lastEventAtUtc = null)
|
||||
{
|
||||
// 1. An explicit terminal failure event is a real failure (defensive — such a session
|
||||
// would normally already be terminal via ingest and not reach the sweep).
|
||||
@@ -228,8 +237,9 @@ namespace AutopilotMonitor.Functions.Services
|
||||
|
||||
// 2. Account Setup fully succeeded (or a terminal completion event) → reconcile to success.
|
||||
if (rollup.AccountSetupAllSucceeded || rollup.HasTerminalComplete)
|
||||
return (SessionStatus.Succeeded,
|
||||
"Reconciled at timeout: Account Setup completed (all subcategories succeeded / enrollment_complete observed)");
|
||||
return (SessionStatus.Succeeded, AppendReconcileTiming(
|
||||
"Reconciled at timeout: Account Setup completed (all subcategories succeeded / enrollment_complete observed)",
|
||||
startedAtUtc, nowUtc, lastEventAtUtc));
|
||||
|
||||
// 3. The agent's absolute-age emergency break fired — the agent has cleaned up and exited, so
|
||||
// nothing more will ever arrive for this session. Terminalize NOW (skip the AwaitingUser grace):
|
||||
@@ -269,8 +279,9 @@ namespace AutopilotMonitor.Functions.Services
|
||||
var detail = rollup.RealmJoinDetected && !rollup.RealmJoinResolved
|
||||
? "RealmJoin deployment never reported completion before the agent went silent"
|
||||
: "agent went silent before reporting completion";
|
||||
return (SessionStatus.Succeeded,
|
||||
$"Reconciled at timeout: user completed setup ({evidence}) — {detail}");
|
||||
return (SessionStatus.Succeeded, AppendReconcileTiming(
|
||||
$"Reconciled at timeout: user completed setup ({evidence}) — {detail}",
|
||||
startedAtUtc, nowUtc, lastEventAtUtc));
|
||||
}
|
||||
|
||||
// 5. Device Setup fully provisioned (device is AADJ + MDM-enrolled), user phase pending.
|
||||
@@ -296,6 +307,35 @@ namespace AutopilotMonitor.Functions.Services
|
||||
"No Device Setup completion or explicit failure signal observed before timeout");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Append the silence-transparency clause to a Succeeded-reconcile reason. Names the last
|
||||
/// agent contact, how long the session was silent, and the exact moment the platform
|
||||
/// declared the verdict (<paramref name="nowUtc"/> = the sweep timestamp). This is the one
|
||||
/// field that survives the reconcile hygiene (which wipes FailureReason on a Succeeded
|
||||
/// write), so the numbers must live here to reach the badge / MCP / exports — see
|
||||
/// session efbc17ff. Anchors to <paramref name="lastEventAtUtc"/>, falling back to
|
||||
/// <paramref name="startedAtUtc"/> when the last-contact time is unknown.
|
||||
/// </summary>
|
||||
private static string AppendReconcileTiming(
|
||||
string reason, DateTime startedAtUtc, DateTime nowUtc, DateTime? lastEventAtUtc)
|
||||
{
|
||||
var lastSeen = lastEventAtUtc ?? startedAtUtc;
|
||||
var silence = HumanizeDuration(nowUtc - lastSeen);
|
||||
return $"{reason}. Agent last reported {lastSeen.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture)} UTC; " +
|
||||
$"silent ~{silence} before the platform declared this success at " +
|
||||
$"{nowUtc.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture)} UTC.";
|
||||
}
|
||||
|
||||
/// <summary>Compact human duration ("5h 6m", "45m", "2d 3h"). Negative/zero → "0m".</summary>
|
||||
private static string HumanizeDuration(TimeSpan d)
|
||||
{
|
||||
if (d <= TimeSpan.Zero) return "0m";
|
||||
int days = d.Days, hours = d.Hours, minutes = d.Minutes;
|
||||
if (days > 0) return $"{days}d {hours}h";
|
||||
if (hours > 0) return $"{hours}h {minutes}m";
|
||||
return $"{minutes}m";
|
||||
}
|
||||
|
||||
private static bool Eq(string a, string b) =>
|
||||
string.Equals(a, b, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
|
||||
@@ -375,7 +375,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
// Device Setup → Incomplete. Never Failed without an explicit failure signal.
|
||||
var rollup = EnrollmentTimeoutClassifier.ExtractRollup(sessionEvents);
|
||||
var (targetStatus, reason) = EnrollmentTimeoutClassifier.ClassifyTimedOutSession(
|
||||
rollup, session.StartedAt, now, graceHours);
|
||||
rollup, session.StartedAt, now, graceHours, session.LastEventAt);
|
||||
|
||||
// No-op if the verdict equals the current (non-terminal) state.
|
||||
if (targetStatus == session.Status)
|
||||
|
||||
+57
-4
@@ -68,10 +68,13 @@ export default function SessionInfoCard({ session, enrollmentDuration, displaySt
|
||||
<svg className="w-4 h-4 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<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>
|
||||
<span>
|
||||
<strong>Backend reconciled:</strong> {session.reconcileReason} — this success was declared
|
||||
by the platform, not reported by the agent on the device.
|
||||
</span>
|
||||
<div>
|
||||
<span>
|
||||
<strong>Backend reconciled:</strong> {session.reconcileReason} — this success was declared
|
||||
by the platform, not reported by the agent on the device.
|
||||
</span>
|
||||
<ReconcileTimingFacts session={session} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{ntpOffset && Math.abs(ntpOffset.offsetSeconds) > 30 && (
|
||||
@@ -171,6 +174,56 @@ function InfoItem({ label, value, copyText, tooltip }: { label: string; value: R
|
||||
);
|
||||
}
|
||||
|
||||
// Compact human silence duration ("5h 6m", "45m", "2d 3h") from milliseconds.
|
||||
function formatSilence(ms: number): string {
|
||||
const totalMin = Math.max(0, Math.round(ms / 60000));
|
||||
const d = Math.floor(totalMin / 1440);
|
||||
const h = Math.floor((totalMin % 1440) / 60);
|
||||
const m = totalMin % 60;
|
||||
if (d > 0) return `${d}d ${h}h`;
|
||||
if (h > 0) return `${h}h ${m}m`;
|
||||
return `${m}m`;
|
||||
}
|
||||
|
||||
// Structured, scannable timing facts under the "Backend reconciled" banner: the reconcile
|
||||
// reason names the full silence-to-declaration window in prose; these chips give the customer
|
||||
// the raw UTC anchors — last agent contact and when the sweep first flagged the silence — so
|
||||
// "user finished and powered off" is distinguishable from "declared too early" (session efbc17ff).
|
||||
function ReconcileTimingFacts({ session }: { session: Session }) {
|
||||
const fmtUtc = (iso?: string) =>
|
||||
iso
|
||||
? `${new Date(iso).toLocaleString([], { dateStyle: "short", timeStyle: "short", timeZone: "UTC" })} UTC`
|
||||
: null;
|
||||
const lastContact = fmtUtc(session.lastEventAt);
|
||||
const flaggedSilent = fmtUtc(session.stalledAt);
|
||||
const silenceGap =
|
||||
session.lastEventAt && session.stalledAt
|
||||
? formatSilence(new Date(session.stalledAt).getTime() - new Date(session.lastEventAt).getTime())
|
||||
: null;
|
||||
|
||||
if (!lastContact && !flaggedSilent) return null;
|
||||
|
||||
return (
|
||||
<dl className="mt-2 flex flex-wrap gap-x-5 gap-y-1 text-xs text-sky-700 dark:text-sky-300">
|
||||
{lastContact && (
|
||||
<div className="flex gap-1.5">
|
||||
<dt className="font-medium">Last agent contact:</dt>
|
||||
<dd>{lastContact}</dd>
|
||||
</div>
|
||||
)}
|
||||
{flaggedSilent && (
|
||||
<div className="flex gap-1.5">
|
||||
<dt className="font-medium">First flagged silent:</dt>
|
||||
<dd>
|
||||
{flaggedSilent}
|
||||
{silenceGap && <span className="opacity-80"> ({silenceGap} of silence)</span>}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status, failureReason, failureSource, adminMarkedAction, reconcileReason }: { status: string; failureReason?: string; failureSource?: string; adminMarkedAction?: string; reconcileReason?: string }) {
|
||||
// Delegate the status pill (+ timeout affordance + "manual"/"reconciled" badges) to the shared,
|
||||
// canonical SessionStatusBadge so this page picks up every status — incl. the AwaitingUser/Incomplete
|
||||
|
||||
@@ -23,6 +23,8 @@ export interface Session {
|
||||
enrollmentType?: string; // "v1" | "v2" — absent for sessions before this feature
|
||||
diagnosticsBlobName?: string;
|
||||
lastEventAt?: string;
|
||||
/** Set when the maintenance sweep first observed the agent had gone silent (2h+ no events). Surfaced in the reconcile banner so the silence window is transparent. */
|
||||
stalledAt?: string;
|
||||
isPreProvisioned?: boolean;
|
||||
isHybridJoin?: boolean;
|
||||
isUserDriven?: boolean;
|
||||
|
||||
Reference in New Issue
Block a user