mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 20:53:06 +00:00
Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1257e0d195 | |||
| 98f8114d90 | |||
| 7b3230d507 | |||
| 87b7a20223 | |||
| 4c19be1de4 | |||
| 93de130cd1 | |||
| b8b95c7e76 | |||
| e57e081cec | |||
| 3a60186824 | |||
| c35595503f | |||
| 838da4cc8a | |||
| a9937e0f5d | |||
| 1dc2cc9a54 | |||
| dd7ae299a8 | |||
| d96d10ccec | |||
| 457fff65f5 | |||
| dc45694611 | |||
| 7569061983 | |||
| 0f5e36db94 | |||
| e1de5a8971 | |||
| a2d93e884c | |||
| 425229deee | |||
| 8675e7ee06 | |||
| e857e99436 | |||
| 6435d5f290 | |||
| 445ce4e75b | |||
| 4e54ffe4b4 | |||
| 65d45a616e | |||
| 4aa1f07fbb | |||
| ede616444a | |||
| 61a359ee54 | |||
| babc89c776 | |||
| a2f448cad0 | |||
| f15e115dea |
@@ -0,0 +1,147 @@
|
||||
---
|
||||
type: concept
|
||||
title: Diagnostics Package
|
||||
description: What the agent's diagnostics ZIP contains and why — the built-in section catalog shared with the portal, the RealmJoin and Device Preparation collection gates, the configured-path guards, caps, and the package manifest.
|
||||
resource: agent
|
||||
tags: [diagnostics, realmjoin, device-preparation, guardrails, portal]
|
||||
timestamp: 2026-08-23
|
||||
---
|
||||
|
||||
# Schema
|
||||
|
||||
The diagnostics package is the agent's last-resort support artifact: a ZIP built in memory
|
||||
by `DiagnosticsPackageService.BuildArchiveBytes` and uploaded through a short-lived SAS
|
||||
URL at the end of a session, at the WhiteGlove seal (`Always` mode) or on a server-requested
|
||||
on-demand collection ([server actions](../backend/server-actions-on-demand-diagnostics.md)).
|
||||
|
||||
## Archive layout
|
||||
|
||||
```
|
||||
sessioninfo.txt session/tenant/device/outcome, hardware via WMI
|
||||
<built-in sections> catalog order, see below
|
||||
AdditionalLogs/<last folder>/ admin-configured paths (global ∪ tenant)
|
||||
package-manifest.txt every packaging decision (always written)
|
||||
_TRUNCATED.txt only when a cap skipped something
|
||||
```
|
||||
|
||||
## The built-in catalog is the single source of truth
|
||||
|
||||
Everything the package collects **before** any configured path is data, not code paths:
|
||||
`DiagnosticsBuiltInSections.All` in Shared (`Models/Diagnostics/DiagnosticsBuiltInSections.cs`).
|
||||
The agent iterates it in order; the backend serves the same list to the portal
|
||||
(`GET /api/diagnostics/paths`, MemberRead) so what administrators see is exactly what the
|
||||
agent does. There is no second copy to drift.
|
||||
|
||||
| Id | ZIP folder | Source (unexpanded) | Patterns | Recursive | Condition |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| AgentLogs | `AgentLogs` | `%ProgramData%\AutopilotMonitor\Logs` | log patterns | no | Always |
|
||||
| ImeLogs | `ImeLogs` | `%ProgramData%\Microsoft\IntuneManagementExtension\Logs` | log patterns | no | Always |
|
||||
| ImeBootstrapperEventLog | `ImeLogs` | `C:\Windows\System32\winevt\Logs` | `BootstrapperAgentServiceLogProvider.evtx` | no | DevicePreparation |
|
||||
| AgentState | `AgentState` | `%ProgramData%\AutopilotMonitor\State` | log patterns + `*.complete`, `*.marker` | yes | Always |
|
||||
| AgentSpool | `AgentSpool` | `%ProgramData%\AutopilotMonitor\Spool` | `*.jsonl`, `*.json` | no | Always |
|
||||
| AgentMarkers | `AgentMarkers` | `%ProgramData%\AutopilotMonitor` | `*.complete`, `*.marker` | no | Always |
|
||||
| RealmJoinWindows | `RealmJoinLogs/Windows` | `C:\Windows\Logs` | `realmjoin*.log` | no | RealmJoinWatcher |
|
||||
| RealmJoinPackages | `RealmJoinLogs/Windows/RealmJoin` | `C:\Windows\Logs\RealmJoin` | `*.log` | yes | RealmJoinWatcher |
|
||||
| RealmJoinChoco | `RealmJoinLogs/Choco` | `%ProgramData%\RealmJoin\choco\logs` | `*.log` | yes | RealmJoinWatcher |
|
||||
| RealmJoinUserTray | `RealmJoinLogs/User` | `%LOGGED_ON_USER_PROFILE%\AppData\Local\RealmJoin` | `tray*.log` | no | RealmJoinWatcher |
|
||||
| RealmJoinUserLogs | `RealmJoinLogs/User/Logs` | `%LOGGED_ON_USER_PROFILE%\AppData\Local\RealmJoin\Logs` | `*.log` | yes | RealmJoinWatcher |
|
||||
|
||||
"Log patterns" = `*.log *.txt *.json *.jsonl *.etl *.evtx *.xml *.csv *.cab`. The five
|
||||
`Always` sections are the historical layout (PR1-B forensics) and are lock-tested in order;
|
||||
the RealmJoin ZIP layout mirrors the disk (`C:\Windows\Logs\{realmjoin*.log, RealmJoin\…}` →
|
||||
`RealmJoinLogs/Windows/…`, `%LOCALAPPDATA%\RealmJoin\{tray*.log, Logs\…}` →
|
||||
`RealmJoinLogs/User/…`), so a flat section and a tree section never produce the same entry
|
||||
name. Two sections may share a ZIP folder only when they read different source folders
|
||||
(`ImeLogs` + the bootstrapper evtx) — `(ZipFolder, SourceFolder)` pairs are unique.
|
||||
|
||||
### Conditions
|
||||
|
||||
`DiagnosticsSectionCondition` is evaluated by the agent (`IsSectionActive`, pure) against its
|
||||
effective configuration and the enrollment scenario; unknown values fail closed.
|
||||
|
||||
* **RealmJoinWatcher** — the tenant's RealmJoin Watcher toggle
|
||||
(`AnalyzerConfiguration.EnableRealmJoinWatcher`). The package service only sees
|
||||
`AgentConfiguration`, so `RemoteConfigMerger` mirrors that one Analyzers knob onto
|
||||
`AgentConfiguration.EnableRealmJoinWatcher` (the RealmJoin host keeps reading the remote
|
||||
block). Deliberately NOT additionally gated on the compile-time `RealmJoinTrackingEnabled`
|
||||
kill switch: if the live watcher is ever disabled, the logs become more useful, not less.
|
||||
No ConfigVersion bump — no new wire field.
|
||||
* **DevicePreparation** — `EnrollmentRegistryDetector.IsDeterministicDevicePreparation()`,
|
||||
the same deterministic `DevicePreparation\BootstrapperAgent\ExecutionContext` marker every
|
||||
other WDP gate keys on ([WDP scenario gates](wdp-scenario-gates.md)); CloudAssigned*
|
||||
fallback rules count as Classic. The probe is a registry read, evaluated once per build
|
||||
(`SCENARIO: devicePreparation=… realmJoinWatcher=…` in the manifest); a probe failure is
|
||||
treated as Classic and logged — the evtx export is skipped, never the package. The channel
|
||||
is exclusively locked while active, so the existing `wevtutil epl` export path
|
||||
(`ResolveChannelForEvtxFile`, field case 2026-08-17) handles it.
|
||||
|
||||
Inactive sections are never silent: `BUILT-IN SKIPPED (RealmJoin Watcher disabled): <Id>`,
|
||||
`BUILT-IN SKIPPED (not a Device Preparation enrollment): <Id>`,
|
||||
`BUILT-IN SKIPPED (no user session for token): <Id> '<source>'` (the
|
||||
`%LOGGED_ON_USER_PROFILE%` token resolves via `UserProfileResolver` only once an
|
||||
interactive user exists — identical to configured paths). Active ones log
|
||||
`BUILT-IN: <Id> -> folder='…' zip='…' recursive=…` followed by the per-pattern
|
||||
`ADDED / NO MATCH / FOLDER MISSING` lines.
|
||||
|
||||
## Built-ins bypass the configured-path guards — on purpose
|
||||
|
||||
`DiagnosticsPathGuards` ([gather-rule guardrails](../rules/gather-rule-guardrails.md))
|
||||
validates **admin-typed** paths: allowlisted prefixes, wildcard only in the last segment,
|
||||
`C:\Users` blocked except the token's `AppData\Local|Roaming`. The catalog is reviewed code,
|
||||
exactly like the five sections that never went through the guard before — and
|
||||
`%ProgramData%\RealmJoin` is not on the allowlist at all. What does apply to every section:
|
||||
the reparse-point skip (files and directories), the `.evtx` channel export, and the caps
|
||||
(100 MB per file, 500 MB total, 5000 files) tracked globally across sections by
|
||||
`BudgetTracker`, with every skip recorded in the manifest and `_TRUNCATED.txt`.
|
||||
|
||||
## Configured paths: global ∪ tenant
|
||||
|
||||
`GetAgentConfigFunction.MergeDiagnosticsLogPaths` sends global paths (Global Admin,
|
||||
`AdminConfiguration.DiagnosticsGlobalLogPathsJson`) first, then tenant paths
|
||||
(`TenantConfiguration.DiagnosticsLogPathsJson`), dropping blank entries and collapsing
|
||||
duplicates (trimmed, case-insensitive) onto the first occurrence — a path present in both
|
||||
lists used to produce duplicate ZIP entry names. The agent keys each entry's ZIP folder on
|
||||
the last directory name (`AdditionalLogs/<folder>`), so two configured paths ending in a
|
||||
same-named folder share one ZIP folder.
|
||||
|
||||
## Portal surfaces
|
||||
|
||||
* Global Admin → Settings → Diagnostics Log Paths: the built-in catalog (collapsed, read-only,
|
||||
neutral condition pills) above the editable global list; add-row first.
|
||||
* Tenant → Settings → Agent → Diagnostics Package: built-in catalog (header carries the
|
||||
tenant's persisted RealmJoin Watcher state, RealmJoin rows show on/off), the global paths
|
||||
read-only, then the tenant's own entries; every role with a tenant membership can read the
|
||||
catalog (the route is MemberRead, JWT-scoped). One line per entry (`DiagnosticsPathRow`),
|
||||
full values in tooltips.
|
||||
|
||||
## Test seams
|
||||
|
||||
* `DiagnosticsPackageService` internal ctor: the five historical folder overrides map onto
|
||||
catalog ids; `sectionFolderOverrides` covers any other section; `devicePreparationProbe`
|
||||
replaces the registry read (seam callers default to "not WDP", so existing rigs never touch
|
||||
the registry). `UserProfileResolver.SetForTesting(path|null)` / `Reset()` drive the token.
|
||||
* Catalog invariants live in the backend test project (`DiagnosticsBuiltInSectionsTests`),
|
||||
because Shared has no test project.
|
||||
|
||||
# Examples
|
||||
|
||||
A RealmJoin tenant (watcher on) with a signed-in user: `RealmJoinLogs/Windows/realmjoin.log`,
|
||||
`RealmJoinLogs/Windows/RealmJoin/Packages/<pkg>/<timestamp>_install.log`,
|
||||
`RealmJoinLogs/Choco/<pkg>/<timestamp>_install.log`, `RealmJoinLogs/User/tray.log`,
|
||||
`RealmJoinLogs/User/Logs/RjImeHost.log`. The same tenant's package collected before the
|
||||
desktop: the two `User` sections appear as `BUILT-IN SKIPPED (no user session for token)`.
|
||||
A Classic enrollment: `BUILT-IN SKIPPED (not a Device Preparation enrollment):
|
||||
ImeBootstrapperEventLog`. A tenant that had added
|
||||
`%LOGGED_ON_USER_PROFILE%\AppData\Local\RealmJoin\Logs\*.log` manually keeps working — the
|
||||
files simply appear twice (`AdditionalLogs/Logs/…` and `RealmJoinLogs/User/Logs/…`) until the
|
||||
entry is removed.
|
||||
|
||||
# Citations
|
||||
|
||||
* `src/Shared/AutopilotMonitor.Shared/Models/Diagnostics/DiagnosticsBuiltInSections.cs` — the catalog
|
||||
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/DiagnosticsPackageService.cs` — `BuildArchiveBytes`, `AddBuiltInSection`, `IsSectionActive`, caps, evtx export
|
||||
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Configuration/RemoteConfigMerger.cs` — `EnableRealmJoinWatcher` mirror
|
||||
* `src/Backend/AutopilotMonitor.Functions/Functions/Diagnostics/GetDiagnosticsPathsFunction.cs` — `GET diagnostics/paths`
|
||||
* `src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs` — `MergeDiagnosticsLogPaths`
|
||||
* `src/Web/autopilot-monitor-web/components/diagnostics/` — `DiagnosticsPathRow`, `BuiltInSectionsList`, `builtInSectionDisplay`
|
||||
* Tests: `DiagnosticsPackageServiceTests` (RealmJoin on/off, no user session, bootstrapper evtx, probe failure), `DiagnosticsBuiltInSectionsTests`, `GetDiagnosticsPathsPayloadTests`, `GetAgentConfigFunctionTests.MergeDiagnosticsLogPaths_*`
|
||||
@@ -5,6 +5,7 @@ How the on-device agent works:
|
||||
* [Runtime Overview](overview.md) - lifecycle (install → boot guards → runtime → termination), collector hosts, the single-rail event pipeline to the backend, config & kill switch, on-disk layout.
|
||||
* [Decision Engine](decision-engine.md) - the pure-reducer state machine (DecisionCore): signals, state, effects, the A/B/C completion arms, invariants, threading.
|
||||
* [Logs, Persistence & Crash Recovery](logs-and-persistence.md) - every persisted file and why it exists; signal log vs spool; snapshot/journal; the replay-based recovery flow and quarantine.
|
||||
* [Diagnostics Package](diagnostics-package.md) - what the diagnostics ZIP contains: the built-in section catalog shared with the portal (Shared `DiagnosticsBuiltInSections`, served by GET diagnostics/paths), the RealmJoin Watcher and Device Preparation collection gates, why built-ins bypass the configured-path guards, global ∪ tenant path dedup, caps and the package manifest.
|
||||
* [Hello Wizard Un-Skip](hello-wizard-unskip.md) - why a single "WHfB disabled" policy read never decides completion: the HelloWizardStarted signal (prevention + cure) and the HelloTracker confirmation second read.
|
||||
* [Autopilot ZTD Diagnostics](autopilot-ztd-diagnostics.md) - Windows' own diagnostic surfaces for the profile-download flow: ModernDeployment event IDs (807/815/164/...), the Diagnostics\Autopilot registry key, deployment-service endpoints, and the known-issue error-code map.
|
||||
* [MDM Reboot Coalescing](mdm-reboot-coalescing.md) - attributing the mid-ESP coalesced reboot ("second sign-in") to the device-assigned policy URIs that forced it: aggregated 2800 observations (neutral, flush-time watermark) + ANALYZE-ESP-005 gated on an actually-observed reboot.
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
---
|
||||
type: concept
|
||||
title: Plan Downgrade Policy & the Retention Grace Period
|
||||
description: What happens when a tenant loses Pro (explicit downgrade or trial expiry) — every entitlement gates read-time and reversibly, except retention, which gets a 30-day grace before the Community cap starts hard-deleting; the anchor model, the ops events, and the deliberate non-grace of everything else.
|
||||
resource: backend
|
||||
tags: [plan, edition, downgrade, retention, grace-period, trial, entitlements]
|
||||
timestamp: 2026-08-22
|
||||
---
|
||||
|
||||
# Schema
|
||||
|
||||
## The downgrade model: gate, don't destroy
|
||||
|
||||
Edition is resolved at READ time (`FeatureEntitlementCatalog.ResolveEdition`): Pro ⇔
|
||||
permanent tier `pro`/legacy `enterprise` OR an active trial. A plan mutation is a single
|
||||
field write (`PATCH config/{tenantId}/plan`, GA-only) — there is no downgrade event
|
||||
handler, no cascade. On Pro → Community every entitlement simply gates on the next read
|
||||
(≤ ~5 min config cache):
|
||||
|
||||
* **Unrestricted Mode** — effective value flips false; the GA on-request flag and the
|
||||
tenant toggle survive untouched (inert). Re-upgrade restores it verbatim.
|
||||
* **Delegated (MSP) admin** — scope suppressed at the home-tenant gate
|
||||
(`DelegatedAdminService.ApplyHomeTenantGateAsync`); grant rows / tenant groups /
|
||||
assignments are never touched. Resurrects on re-upgrade.
|
||||
* **OOBE bootstrap** — endpoints 403/404 unless the additive GA per-tenant flag
|
||||
(`BootstrapTokenEnabled`) is set; `BootstrapSessions` rows are kept.
|
||||
* **Rate limits / MCP quota** — pure computed-at-read floors/plan names.
|
||||
|
||||
**Retention is the one exception**: the retention sweep (12-hourly
|
||||
`SessionDeletionMaintenance` → `SessionRetentionFanoutService`) hard-deletes sessions
|
||||
older than `GetEffectiveRetentionDays` = `min(stored DataRetentionDays, edition cap)`.
|
||||
Without protection, a downgrade (or trial expiry) with stored retention 365 would start
|
||||
irreversibly cascade-deleting the 90–365-day band on the next tick.
|
||||
|
||||
## The retention grace period (30 days)
|
||||
|
||||
`FeatureEntitlementCatalog.RetentionDowngradeGraceDays = 30`. For 30 days after losing
|
||||
Pro, `GetEffectiveRetentionDays` keeps clamping to the **Pro** cap (365); only after the
|
||||
grace does the Community cap (90) bite. The stored `DataRetentionDays` is never rewritten.
|
||||
`days <= 0` (GA-only "infinite") short-circuits before any cap — those tenants are never
|
||||
swept, grace or not.
|
||||
|
||||
**Anchor** — "when was Pro lost" (`TenantEntitlementService.GetRetentionGraceEndUtc`,
|
||||
returns null while effectively Pro; grace end = latest anchor + 30d):
|
||||
|
||||
* **Explicit downgrade**: `TenantConfiguration.ProDowngradedUtc` (backend-only field, no
|
||||
agent ConfigVersion impact), maintained exclusively by the plan endpoint's pure mutation
|
||||
core `PlanManagementFunction.ApplyPlanChanges`. Stamped when the EFFECTIVE edition
|
||||
(before vs. after, same `nowUtc`) transitions Pro → Community — this covers both a
|
||||
planTier downgrade and a GA explicitly ending a trial (`trialExpiresUtc: null`, where
|
||||
the trial timestamp disappears and could not anchor). Cleared on any transition back to
|
||||
effectively Pro (PATCH pro, trial grant, self-service trial start), so a re-upgrade also
|
||||
resets the grace clock.
|
||||
* **Trial expiry**: no write happens — the stored `TrialExpiresUtc` itself is the anchor
|
||||
(read-time, like edition resolution). A planTier downgrade under a still-active trial
|
||||
deliberately does NOT stamp: the edition is still Pro, and the later trial expiry
|
||||
anchors the grace then.
|
||||
|
||||
The field is on the patch-endpoint `BaseDeniedFields` and `RevertProtectedFields` lists
|
||||
(a config revert must not time-travel the anchor) and roundtrips the table-serialization
|
||||
contract (Store+Map + roundtrip tests). Legacy rows read null → no anchor → no grace,
|
||||
which is correct: no tenant had been downgraded before the feature existed.
|
||||
|
||||
## Ops visibility (dual-registered event types)
|
||||
|
||||
* `TenantPlanDowngraded` (Warning) — emitted by the PATCH handler on an effective
|
||||
Pro→Community transition; carries `retentionGraceEndsUtc` + stored retention days. The
|
||||
response body also returns `retentionGraceEndsUtc`.
|
||||
* `TenantRetentionGraceExpiring` / `TenantRetentionGraceEnded` (Warning) — emitted by the
|
||||
daily `TrialExpirySweepFunction` (03:30 UTC) with the same stateless window mechanics as
|
||||
the trial events (≤3-day heads-up re-fired daily; ended once via the 24h look-back), and
|
||||
ONLY when data is actually at risk: stored `DataRetentionDays` above the Community cap
|
||||
(0 = infinite ⇒ never). Re-upgraded tenants go silent automatically (grace resolves null).
|
||||
|
||||
All three are registered in the web `OPS_EVENT_TYPES` catalog (OpsAlertRulesSection.tsx)
|
||||
so Telegram alert rules can route them.
|
||||
|
||||
## Deliberate non-goals
|
||||
|
||||
* No grace for Unrestricted/MSP/bootstrap/limits — reversible gates need none.
|
||||
* The retention WRITE cap (`TenantConfigValidation`) does not honor the grace: during the
|
||||
grace a tenant admin cannot SET a value above the Community cap. The grace protects
|
||||
existing data; new writes follow the new plan.
|
||||
* The feature-flags endpoint keeps publishing the plain edition cap
|
||||
(`retentionCapDays`) — it feeds the settings input maximum, which is the write cap.
|
||||
|
||||
# Citations
|
||||
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/TenantEntitlementService.cs` — grace + effective retention
|
||||
* `src/Backend/AutopilotMonitor.Functions/Functions/Config/PlanManagementFunction.cs` — `ApplyPlanChanges` anchor lifecycle
|
||||
* `src/Backend/AutopilotMonitor.Functions/Functions/Maintenance/TrialExpirySweepFunction.cs` — grace event windows
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/Deletion/SessionRetentionFanoutService.cs` — enforcement point
|
||||
* Tests: `TenantEntitlementServiceTests`, `PlanManagementTransitionTests`, `TrialExpirySweepFunctionTests`, `SessionRetentionFanoutServiceTests`, `TenantConfigTableSerializationTests`
|
||||
@@ -0,0 +1,110 @@
|
||||
---
|
||||
type: Concept
|
||||
title: Silent-Session Classification (Sweep, Max-Lifetime, Retro-Reconcile)
|
||||
description: How a session whose agent went silent gets its honest status — the two-stage maintenance sweep (2h Stalled marker, 5h timeout classification), the EnrollmentTimeoutClassifier rule order incl. the WhiteGlove Part-2 and self-deploying gates, which ingest paths share the classifier, and the admin retro-reconcile modes.
|
||||
resource: src/Backend/AutopilotMonitor.Functions/Services/EnrollmentTimeoutClassifier.cs
|
||||
tags:
|
||||
- backend
|
||||
- maintenance
|
||||
- session-status
|
||||
- classification
|
||||
- whiteglove
|
||||
- self-deploying
|
||||
timestamp: 2026-08-23T18:00:00+02:00
|
||||
---
|
||||
|
||||
# Silent-Session Classification
|
||||
|
||||
A session only ever becomes terminal through evidence. When the agent stops reporting
|
||||
(power-off, reboot without relaunch, Wi-Fi never re-associating, max-lifetime watchdog),
|
||||
the backend must decide the status from what it last saw — it never hard-fails a session
|
||||
for being silent. One pure function holds that decision,
|
||||
`EnrollmentTimeoutClassifier.ClassifyTimedOutSession`, and every silent-session path calls it
|
||||
so a session gets the same verdict regardless of which path noticed the silence first.
|
||||
|
||||
# Schema
|
||||
|
||||
## Inputs (`EspProvisioningRollup` + session flags)
|
||||
|
||||
`ExtractRollup(events)` distills the event stream into facts: Device/Account Setup
|
||||
subcategory counts (incl. the 30s fallback-confirmed messages), `HasExplicitFailure`,
|
||||
`HasEnrollmentComplete` vs. `HasTerminalComplete` (the Part-1 `whiteglove_complete`),
|
||||
`HasAgentEmergencyBreak`, `DesktopArrived`, `HelloResolved`, `HelloPolicyDisabled`,
|
||||
`SkipUserEsp`, RealmJoin detection/resolution. Session-row flags are passed alongside:
|
||||
`IsPreProvisioned` + `ResumedAt` (WhiteGlove Part 2) and `IsSelfDeployingProfile`
|
||||
(registry-confirmed `CloudAssignedOobeConfig 0x20|0x40`, set at registration).
|
||||
|
||||
## Rule order (first match wins)
|
||||
|
||||
| # | Condition | Verdict |
|
||||
| --- | --- | --- |
|
||||
| 1 | `HasExplicitFailure` | Failed |
|
||||
| 1b | `IsWhiteGloveAwaitingUser`: pre-provisioned + resumed + Device Setup done + **no** user evidence since resume + no explicit failure/enrollment_complete | AwaitingUser within grace; Succeeded (honest WG reason) past grace |
|
||||
| 1c | `IsSelfDeployingProvisioned`: self-deploying profile + Device Setup done + no explicit failure | **Succeeded** (reconcile) — no grace, no AwaitingUser: the profile has no user phase |
|
||||
| 2 | Account Setup all-succeeded, or `enrollment_complete`, or `whiteglove_complete` on a **non-resumed** session | Succeeded |
|
||||
| 3 | `HasAgentEmergencyBreak` | Incomplete immediately (agent self-destructed, nothing can arrive) |
|
||||
| 4 | `DesktopArrived` + (Hello terminal, or Hello disabled + SkipUserEsp) | Succeeded (user provably finished) |
|
||||
| 5 | Device Setup done | AwaitingUser within grace, Incomplete past grace |
|
||||
| 6 | otherwise | Incomplete |
|
||||
|
||||
Grace = `ResolveGraceHours(SessionGraceHours, AbsoluteMaxSessionHours)` (~51h default),
|
||||
measured from `ResumedAt ?? StartedAt`. Every Succeeded-reconcile reason carries the
|
||||
silence-transparency clause (`AppendReconcileTiming`: last agent contact, silence, verdict time).
|
||||
|
||||
## Why rule 1c exists (kiosk tenant audit 2026-08-23)
|
||||
|
||||
On a self-deploying profile Device ESP all-succeeded IS the enrollment's end; the agent's
|
||||
own SelfDeploying terminal only adds a 5-min confirmation window
|
||||
(`DecisionEngine.SelfDeploying.s_deviceOnlyEspDetectionWindow`). Tenant `aebdce78` (school,
|
||||
Wi-Fi ThinkBooks, a language-change app forcing a reboot right after Device ESP) lost that
|
||||
window on 836 of ~970 sessions in one week: the agent died 0–4 min after
|
||||
`DeviceSetup fallback_confirmed`, the reboot relaunch never registered (30s registration
|
||||
budget on a Wi-Fi link still associating), and rule 5 labeled finished kiosks
|
||||
"awaiting user / Account Setup 0/5" → Incomplete 51h later. Nobody signs in on these devices
|
||||
(prepared during the holidays, boxed, in service) — AwaitingUser was factually wrong and
|
||||
Incomplete turned a working rollout into a red wall. Other self-deploying tenants completed
|
||||
normally in the same window, so this is a classification gap, not a profile-wide agent bug.
|
||||
Agent-side companion fix: `SessionRegistrationHelper` now waits up to 15s for a network link
|
||||
and retries 6× (~62s backoff) instead of 5× (~30s).
|
||||
|
||||
## Callers (all pass both gate inputs)
|
||||
|
||||
| Path | File | Notes |
|
||||
| --- | --- | --- |
|
||||
| Sweep stage 1 (2h agent-silent marker) | `MaintenanceService.MarkStalledSessionsAsTimedOutAsync` | Default: InProgress → Stalled. Gates run BEFORE the Stalled write: `TryMarkWhiteGloveAwaitingUserAsync` (AwaitingUser) and `TryReconcileSelfDeployingAsync` (Succeeded). Only flagged sessions pay the event read. |
|
||||
| Sweep stage 2 (SessionTimeoutHours, 5h default) | same | InProgress/Stalled/AwaitingUser past the window and past the 2h silence cutoff → full classification; AwaitingUser within grace is skipped without an event read. |
|
||||
| Agent max-lifetime (`enrollment_failed`/max_lifetime, `agent_shutting_down`/max_lifetime) | `EventIngestProcessor.ApplyMaxLifetimeVerdictAsync` | Watchdog = "agent stopped waiting", not a verdict; classified from evidence. Pending sessions exempt. |
|
||||
| Late-telemetry reconcile | `EventIngestProcessor.TryLateTelemetryReconcileAsync` | Heal-only: applies a Succeeded verdict to Incomplete/AwaitingUser rows when straggler events arrive. |
|
||||
| Agent stall probe (`session_stalled`) | `EventIngestProcessor.TryMapStallToWhiteGloveAwaitingUserAsync` | Only the WG gate (1b); a stall probe on a self-deploying device with Device Setup done means the agent is alive and something else blocks — stays Stalled. |
|
||||
| Admin retro-reconcile | `LegacyReclassificationService` | Modes `legacy_timeouts`, `pending_orphans`, `self_deploying_silent` (Incomplete/AwaitingUser/Stalled rows with the profile flag, heal-only to Succeeded, admin-marked rows skipped). `POST /api/maintenance/reclassify-legacy?mode=…&dryRun=false&tenantId=…&maxSessions=…`, dry-run by default. |
|
||||
|
||||
## Known gap (decided, not built)
|
||||
|
||||
After max-lifetime the agent writes `enrollment-complete.marker` and self-destructs, so an
|
||||
AwaitingUser verdict from that path can never heal — it is a 51h countdown to Incomplete.
|
||||
The emergency-break path (rule 3) already terminalizes immediately; max-lifetime still goes
|
||||
through the grace window by design ("user simply absent"). Revisit if customers read
|
||||
AwaitingUser as "still possible".
|
||||
|
||||
# Examples
|
||||
|
||||
* Session `195593e2` (tenant aebdce78): `DeviceSetup 4/4` + `fallback_confirmed`, `AccountSetup 0/5`
|
||||
(IME false positive), Hello disabled, SkipUser=True, silent after 13:01 → rule 1c →
|
||||
Succeeded "self-deploying profile — Device Setup fully provisioned … agent went silent
|
||||
before confirming completion. Agent last reported 2026-08-21 13:01 UTC …".
|
||||
* Same event shape on a user-driven profile → rule 5 → AwaitingUser (within grace) /
|
||||
Incomplete (past grace). Guarded by
|
||||
`Classify_same_stream_without_self_deploying_flag_keeps_user_phase_verdicts`.
|
||||
* fairstone WG Part 2 reseal-reboot, technician powers off at logon screen → rule 1b →
|
||||
AwaitingUser; user unboxes days later → ingest completion heals to Succeeded.
|
||||
|
||||
# Citations
|
||||
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/EnrollmentTimeoutClassifier.cs` — rollup + rule order, `IsWhiteGloveAwaitingUser`, `IsSelfDeployingProvisioned`, reason helpers.
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.cs` — `MarkStalledSessionsAsTimedOutAsync`, `TryMarkWhiteGloveAwaitingUserAsync`, `TryReconcileSelfDeployingAsync`.
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.Classification.cs` — max-lifetime verdict, late-telemetry reconcile, stall mapping.
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/LegacyReclassificationService.cs`, `Functions/Admin/ReclassifyLegacySessionsFunction.cs` — retro modes.
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Maintenance.cs` — `GetAgentSilentSessionsAsync`, `GetStalledSessionsAsync`, `GetSelfDeployingSilentSessionsAsync`.
|
||||
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Security/SessionRegistrationHelper.cs` — link wait + 6-attempt registration budget.
|
||||
* `src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.SelfDeploying.cs` — the agent-side 5-min SelfDeploying terminal window.
|
||||
* `src/Backend/AutopilotMonitor.Functions.Tests/EnrollmentTimeoutClassifierTests.cs` — rule coverage incl. the 2026-08-23 self-deploying block.
|
||||
@@ -4,7 +4,7 @@ title: Tenant Activation & the Once-Only Welcome Mail
|
||||
description: How a signup becomes an activated tenant (manual approve or auto-approve queue), and how the welcome mail is sent exactly once even though activation and the user-provided address race each other.
|
||||
resource: backend
|
||||
tags: [activation, auto-approve, welcome-email, preview-whitelist, race]
|
||||
timestamp: 2026-08-02
|
||||
timestamp: 2026-08-23
|
||||
---
|
||||
|
||||
# Schema
|
||||
@@ -58,6 +58,46 @@ The Global-Admin resend endpoint (`POST /api/preview/send-welcome-email/{tenantI
|
||||
always sends (explicit intent) and consumes the marker best-effort so the automatic
|
||||
paths never duplicate afterwards.
|
||||
|
||||
## Mail transport
|
||||
|
||||
Both transactional mails (welcome, post-offboarding farewell) go through the
|
||||
provider-neutral `EmailService` (`IEmailService` for the welcome path,
|
||||
`IOffboardFarewellEmailSender` for the farewell path). The provider is an implementation
|
||||
detail confined to one private transport method plus the `Email:*` configuration
|
||||
section — a provider swap must not rename classes, DI registrations or callers.
|
||||
|
||||
* Current provider: **Mailchimp Transactional (Mandrill)**, `POST {Email:Endpoint}`
|
||||
(`messages/send`), API key in the JSON body, typed `HttpClient` with the shared
|
||||
`ResiliencePolicies.Notification` retry policy (15 s timeout).
|
||||
* Settings (App Settings use `Email__Key`): `Email:ApiKey` (required — empty means every
|
||||
send is a logged no-op, never an error), `Email:Endpoint`, `Email:FromAddress`
|
||||
(default `noreply@autopilotmonitor.com`), `Email:FromName` (default `Autopilot Monitor`).
|
||||
* Result handling: per-recipient status `sent`/`queued`/`scheduled` = success;
|
||||
`rejected`/`invalid` (with `reject_reason`), non-2xx or malformed bodies = warning.
|
||||
Never throws — both callers rely on that fail-soft contract.
|
||||
* `track_opens`/`track_clicks` are explicitly `false` and `auto_text` is `true`: the provider
|
||||
receives only the recipient address and the tenant domain, which is exactly the claim on
|
||||
the privacy page and the trust data-flows page. Changing tracking = changing those pages.
|
||||
|
||||
## Template overrides (operator-editable)
|
||||
|
||||
Global Admins can replace either HTML body without a deployment (portal: Settings →
|
||||
Email Templates). `EmailTemplateService` resolves the body for every send: the stored
|
||||
override wins, otherwise the built-in `EmailTemplates.*`. Overrides live in the
|
||||
`PreviewConfig` table (partition `EmailTemplates`, row key `welcome` | `farewell`,
|
||||
properties `Html`, `UpdatedBy`, `UpdatedUtc`), cached 5 minutes, invalidated on save/reset.
|
||||
|
||||
* The only placeholder is `{{domainName}}`; an empty domain renders as "your organization",
|
||||
matching the built-ins. Subjects are not editable (pinned constants).
|
||||
* Limit 30 000 characters (Table string property cap is 32K UTF-16 chars).
|
||||
* The send path is fail-soft: a storage error falls back to the built-in body. The admin
|
||||
read path is not — a broken store surfaces as an error instead of silently showing the
|
||||
built-in.
|
||||
* Endpoints (all GlobalAdminOnly): `GET|PUT|DELETE /api/global/email-templates/{kind}` and
|
||||
`POST /api/global/email-templates/{kind}/test`. The test send goes to the caller's tenant
|
||||
contact address (activation notification email → `ContactEmail` → caller UPN) with the
|
||||
caller's tenant domain, optionally with an unsaved draft body; provider tag `test`.
|
||||
|
||||
# Examples
|
||||
|
||||
* Auto-approve wins the race: approve at T+64 s finds no address → logs "deferred to the
|
||||
@@ -73,3 +113,8 @@ paths never duplicate afterwards.
|
||||
* `src/Backend/AutopilotMonitor.Functions/Functions/Rules/PreviewWhitelistFunction.cs` — save-path trigger, GA resend
|
||||
* `src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs` — conditional inserts (approved / welcome-email-sent)
|
||||
* `src/Backend/AutopilotMonitor.Functions.Tests/TenantApprovalServiceTests.cs` — ordering + dedup pins
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/EmailService.cs` — provider-neutral transport (Mandrill wire format, result handling, tracking off)
|
||||
* `src/Backend/AutopilotMonitor.Functions.Tests/EmailServiceTests.cs` — wire-format, fail-soft and gate-ordering pins
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/EmailTemplateService.cs` — override resolution, placeholder rendering, cache
|
||||
* `src/Backend/AutopilotMonitor.Functions/Functions/Admin/EmailTemplatesFunction.cs` — GA endpoints (read / override / reset / test send)
|
||||
* `src/Web/autopilot-monitor-web/app/admin/settings/sections/SectionEmailTemplates.tsx` — portal editor, preview frame, test send
|
||||
|
||||
+3
-1
@@ -19,7 +19,7 @@ https://docs.autopilotmonitor.com (separate repository).
|
||||
|
||||
# Agent
|
||||
|
||||
* [V2 Agent](agent/index.md) - How the on-device agent works: [runtime overview](agent/overview.md), [decision engine](agent/decision-engine.md) (reducer, signals, completion arms), [logs & persistence](agent/logs-and-persistence.md) (signal log, snapshot, crash recovery), [Hello wizard un-skip](agent/hello-wizard-unskip.md) (why a single "WHfB disabled" read never decides completion), [Autopilot ZTD diagnostics](agent/autopilot-ztd-diagnostics.md) (event IDs, diagnostic registry, endpoints, error-code map).
|
||||
* [V2 Agent](agent/index.md) - How the on-device agent works: [runtime overview](agent/overview.md), [decision engine](agent/decision-engine.md) (reducer, signals, completion arms), [logs & persistence](agent/logs-and-persistence.md) (signal log, snapshot, crash recovery), [diagnostics package](agent/diagnostics-package.md) (built-in section catalog shared with the portal, RealmJoin/Device Preparation collection gates, caps, manifest), [Hello wizard un-skip](agent/hello-wizard-unskip.md) (why a single "WHfB disabled" read never decides completion), [Autopilot ZTD diagnostics](agent/autopilot-ztd-diagnostics.md) (event IDs, diagnostic registry, endpoints, error-code map).
|
||||
* [Build Counter Blob](agent/build-counter-blob.md) - How agent build numbers are minted: shared counter blob with ETag-CAS so local and CI builds never collide; CI additionally attests build provenance (Sigstore).
|
||||
* [Agent Endpoint Migration](agent/endpoint-migration.md) - How the backend re-homes agents to a new API base URL via the config channel (backend move or per-tenant region move): live-fetch-only, allowlist-validated on both sides, one hop, kill wins.
|
||||
* [Gather Rule Debug Log](agent/gather-rule-debug-log.md) - EnableGatherRuleDebugLog (ConfigVersion 36): the per-tenant local trace file that explains every gather-rule evaluation decision — scope skips, on_change suppression, empty collector results, logparser position/parse-mode outcomes — for rules that produce no timeline events.
|
||||
@@ -48,11 +48,13 @@ https://docs.autopilotmonitor.com (separate repository).
|
||||
* [Business Timestamps](backend/business-timestamps.md) - Why the Azure Tables system Timestamp is never authoritative (migrations reset it), and the OccurredUtc + RowKey-decode compensation for AuditLogs/OpsEvents/Events after the 2026-07-18 migration.
|
||||
* [Send-Time Frame Separation](backend/send-time-frame-separation.md) - One device-clock send timestamp per upload batch (X-Send-Time-Utc → SentAt on Events) splits ReceivedAt − OccurredUtc into pure spool delay and pure device-vs-server clock offset; the clock_skew condition measures SentAt batches directly (spool-immune), the CMTrace tripwire deliberately stays on its differential math.
|
||||
* [OOBE Bootstrap go-Code Flow](backend/oobe-bootstrap-go-flow.md) - The bootstrap short-code lifecycle (portal-created session → `irm go.autopilotmonitor.com/CODE | iex` at Shift+F10 → GetBootstrapScriptFunction), the always-HTTP-200 text/plain contract, the two-line injection defense (GUID/URL/expiry validator + single-quoted PS literals, golden-pinned against the legacy TS template), and the per-IP rate-limit story behind Front Door.
|
||||
* [Silent-Session Classification](backend/silent-session-classification.md) - How a session whose agent went silent gets its honest status: the two-stage maintenance sweep (2h Stalled marker with the WhiteGlove Part-2 AwaitingUser gate and the self-deploying Succeeded gate, 5h timeout classification), the EnrollmentTimeoutClassifier rule order (explicit failure → WG Part 2 → self-deploying → completion evidence → emergency break → desktop+Hello → Device Setup/grace), the ingest paths sharing it (max-lifetime, late-telemetry heal, stall probe) and the admin retro-reconcile modes incl. self_deploying_silent.
|
||||
* [Device Journeys & First-Time-Right](backend/device-journeys.md) - How a device's terminal enrollment attempts are grouped into journeys (trim+casefold device key with junk-serial exclusion, first-success end, 30-day gap, WhiteGlove = one attempt), the DeviceHistories chain with its tombstone-driven deleted-session pruning, the additive daily FTR aggregates, and the banner/fleet/MCP surfaces.
|
||||
* [Enrollment Time Attribution](backend/time-attribution.md) - How a terminal session's authoritative wall clock (DurationSeconds, WhiteGlove pause excluded) is partitioned into named segments with an explicit unattributed remainder, ESP-blocking apps as positive evidence with "up to" what-if bounds, reboot spans from event gaps, and the breakdown/aggregate tables the sweep maintains.
|
||||
* [Attempt-Scoped App Install Durations](backend/app-install-duration-attempt.md) - AppInstallSummary.DurationSeconds = status-defining (last) install attempt via the LastAttemptStartedAt max-fold anchor instead of the first-observation→last-terminal span that billed multi-pass IME gaps to trivial apps; InstallPassCount, the weaker-terminal guard, the radar's cutover gate, and time attribution's union-of-attempt-segments occupancy.
|
||||
* [Rule Regression Radar](backend/rule-regression-radar.md) - How analyze-rule hit-rate regressions are detected (7-day window ≥2× the 28-day baseline with disjoint Wilson intervals), which suppressions keep the radar quiet (grace period, edit-in-window, kill switch), how alert episodes live in the ruleregression tracker keyspace with re-arm semantics, and the on-fire dimension correlation with its "correlated, not necessarily causal" wording contract.
|
||||
* [App Version Duration Regression Radar](backend/app-version-regression-radar.md) - How app versions whose median install duration regressed vs. the previous version are detected (first-seen-ordered versions, median lift ≥2× plus a 5-minute absolute floor over measured installs only), how alert episodes live in the appversionregression tracker keyspace with drain/median re-arm semantics, and the bell/ops-event/analytics surfaces.
|
||||
* [Plan Downgrade Policy & the Retention Grace Period](backend/plan-downgrade-retention-grace.md) - What happens when a tenant loses Pro: every entitlement gates read-time and reversibly ("gate, don't destroy"), except retention — a 30-day grace keeps the Pro cap before the Community cap starts hard-deleting; the ProDowngradedUtc/trial-expiry anchor model, the TenantPlanDowngraded/RetentionGrace* ops events, and the deliberate non-grace of everything else.
|
||||
* [Tenant Activation & the Once-Only Welcome Mail](backend/tenant-activation-welcome-mail.md) - How a signup becomes an activated tenant (manual approve or the auto-approve queue), and the write-then-read + conditional-marker arbitration that sends the welcome mail exactly once even though activation and the user-typed notification address race each other.
|
||||
* [W365 Cloud PC Device Validation](backend/cloudpc-device-validation.md) - Why Cloud PCs can never pass the Autopilot serial lookup, and the cert-CN-bound CloudPc fallback validator: Subject CN (= Intune device id) matched against the tenant's Graph virtualEndpoint/cloudPCs inventory — admits exactly the service-provisioned Cloud PCs, nothing else.
|
||||
* [ServerActions & On-Demand Diagnostics](backend/server-actions-on-demand-diagnostics.md) - The queue-on-session, deliver-on-ingest action channel, the TenantAdminOrOperator tier with in-function per-type re-gating, the portal's Collect Logs flow (quick-config orders rotate_config before request_diagnostics), and the hosted-prefix sweep in the offboarding cascade.
|
||||
|
||||
+13
@@ -1,5 +1,18 @@
|
||||
# Log
|
||||
|
||||
## 2026-08-23
|
||||
|
||||
* **New**: `backend/silent-session-classification.md` — one place for the silent-session verdict: `EnrollmentTimeoutClassifier` rule order incl. the new rule 1c `IsSelfDeployingProvisioned` (self-deploying profile + Device Setup all-succeeded + no explicit failure ⇒ Succeeded reconcile, no grace/AwaitingUser — kiosk tenant aebdce78 audit: 836 finished devices parked AwaitingUser/Incomplete), the sweep stage-1 gates (`TryMarkWhiteGloveAwaitingUserAsync`, new `TryReconcileSelfDeployingAsync`), the ingest callers (max-lifetime, late-telemetry heal, stall probe) and the new admin retro mode `self_deploying_silent` (`GetSelfDeployingSilentSessionsAsync`). Agent companion: `SessionRegistrationHelper` waits up to 15s for a network link and retries 6× (~62s) instead of 5× (~30s) so the reboot relaunch on Wi-Fi no longer exits 7 before the link is back.
|
||||
* **New**: `agent/diagnostics-package.md` — the diagnostics ZIP's built-in collection is now ONE catalog in Shared (`DiagnosticsBuiltInSections.All`): the agent iterates it in order and the backend serves the same list to the portal (`GET diagnostics/paths`, MemberRead), so what administrators see is exactly what the agent collects. New conditional sections: RealmJoin client/package/Chocolatey/per-user logs behind the tenant's RealmJoin Watcher toggle (mirrored onto `AgentConfiguration.EnableRealmJoinWatcher` by the merger) and the Device Preparation bootstrapper event log behind the deterministic WDP marker (probe once per build, fail toward Classic); every skip is a manifest line. Built-ins bypass `DiagnosticsPathGuards` by design (reviewed code, `%ProgramData%\RealmJoin` is not allowlisted); caps/reparse/evtx export still apply. `MergeDiagnosticsLogPaths` dedups global ∪ tenant paths. Portal: collapsed read-only catalog + one-line rows in both cards, tenant context dropped the GA-only global fetch.
|
||||
* **Update**: `rules/gather-rule-guardrails.md` — `DiagnosticsPathGuards` scope clarified: configured diagnostics paths only, never the built-in catalog.
|
||||
* **Update**: `backend/tenant-activation-welcome-mail.md` — new "Template overrides" section: Global Admins can replace the welcome/farewell HTML in the portal (Settings → Email Templates); `EmailTemplateService` resolves override-or-built-in per send (PreviewConfig table, partition `EmailTemplates`, 5-min cache, `{{domainName}}` placeholder, 30k limit, fail-soft send path), GA endpoints `global/email-templates/{kind}` (+`/test` to the caller's tenant contact address). Also: the mail header is image-free (CSS-drawn brand mark, gradient stripes and clip-path edge as progressive enhancement).
|
||||
|
||||
## 2026-08-22
|
||||
|
||||
* **Update**: `backend/tenant-activation-welcome-mail.md` — new "Mail transport" section: transactional mail now goes through the provider-neutral `EmailService` (`IEmailService` + `IOffboardFarewellEmailSender`) with the provider confined to one private transport method and the `Email:*` settings (`ApiKey`, `Endpoint`, `FromAddress`, `FromName`); current provider Mailchimp Transactional (Mandrill) replacing Resend (NuGet dropped, typed `HttpClient` + `ResiliencePolicies.Notification`). Result-array semantics (`sent`/`queued`/`scheduled` ok, `rejected`/`invalid` warn), open/click tracking explicitly off because the privacy/trust claim depends on it.
|
||||
|
||||
* **New**: `backend/plan-downgrade-retention-grace.md` — plan downgrade policy: every Pro entitlement gates read-time and reversibly on downgrade ("gate, don't destroy"), except retention, which now gets a 30-day grace (`FeatureEntitlementCatalog.RetentionDowngradeGraceDays`) before the Community cap starts hard-deleting the 90–365-day band. Anchor model: `ProDowngradedUtc` stamped/cleared by the plan endpoint's pure `ApplyPlanChanges` core on EFFECTIVE-edition transitions (covers explicitly ended trials; planTier downgrade under an active trial deliberately doesn't stamp), trial expiry anchors read-time via `TrialExpiresUtc`. New Warning ops events `TenantPlanDowngraded` (PATCH handler) and `TenantRetentionGraceExpiring`/`TenantRetentionGraceEnded` (TrialExpirySweep window mechanics, only when stored retention exceeds the Community cap). Field is patch-denied + revert-protected; retention WRITE cap deliberately ignores the grace.
|
||||
|
||||
## 2026-08-21
|
||||
|
||||
* **New**: `agent/do-group-id-auto-set.md` — optional DO peering-group seed (`EnableDoGroupIdAutoSet`, default off, ConfigVersion 40): `DoGroupIdService` derives a deterministic GUID from the default gateway's IPv4 + SendARP MAC in RealmJoin's exact byte layout (cross-product peering) and writes `DOGroupId` in the 64-bit GPO Policies store with readback verification. Conservative by design: no random-GUID fallback (skip on missing gateway/MAC), `DOGroupIdSource`/MDM-managed values/foreign GPO values always win (skip reasons in the `do_group_id_auto_set` event), own stale values are recognized via the `written:<guid>` StartupEventGate fingerprint and updated after a gateway change. Gateway heuristic extracted into the shared `NetworkInterfaceLocator` (dedup of NetworkChangeDetector + DeviceInfoCollector copies).
|
||||
|
||||
@@ -31,6 +31,12 @@ item in `AutopilotMonitor.Agent.V2.Core.csproj`) and regenerated into the portal
|
||||
| `IsCommandAllowed` | `allowedCommands` | **exact** match, trimmed — never prefix |
|
||||
| `IsEventLogChannelAllowed` | `eventLogChannels` | prefix, boundary `/` or end |
|
||||
|
||||
`DiagnosticsPathGuards.IsDiagnosticsPathAllowed` (allowlist `diagnosticsPathPrefixes`, a C#
|
||||
literal mirrored in the JSON) applies the same hard blocks and the same user-profile
|
||||
exception to **admin-configured diagnostics paths** only. The diagnostics package's built-in
|
||||
sections are reviewed code and never pass through it — see
|
||||
[Diagnostics Package](../agent/diagnostics-package.md).
|
||||
|
||||
**Hard blocks live in code, not in the JSON**, so a parse error cannot lift them — on a
|
||||
parse failure the allowlists fall back to empty and everything is blocked (fail-closed),
|
||||
but the hard blocks must hold regardless of configuration:
|
||||
|
||||
+3
-2
@@ -53,6 +53,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
|
||||
// Merge reads two Collectors knobs (AgentMaxLifetimeMinutes, HelloWaitTimeoutSeconds);
|
||||
// the remaining CollectorConfiguration flags flow untouched to DefaultComponentFactory.
|
||||
nameof(AgentConfigResponse.Collectors),
|
||||
// Merge reads one Analyzers knob (EnableRealmJoinWatcher → diagnostics RealmJoin sections);
|
||||
// the remaining flags flow untouched to DefaultComponentFactory + AgentAnalyzerManager.
|
||||
nameof(AgentConfigResponse.Analyzers),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
@@ -70,8 +73,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
|
||||
nameof(AgentConfigResponse.ImeLogPatterns),
|
||||
// Passed by AgentRuntimeHost into IComponentFactory.CreateCollectorHosts → ImeLogHost sealing re-emit.
|
||||
nameof(AgentConfigResponse.WhiteGloveSealingPatternIds),
|
||||
// Consumed by DefaultComponentFactory (RealmJoin/KeepAwake/ConsoleBypass host gates) + AgentAnalyzerManager.
|
||||
nameof(AgentConfigResponse.Analyzers),
|
||||
// Self-update ZIP integrity second trust channel — BinaryIntegrityVerifier / Program update path.
|
||||
nameof(AgentConfigResponse.LatestAgentSha256),
|
||||
// Running-EXE integrity verification — BinaryIntegrityVerifier post-config check.
|
||||
|
||||
+27
@@ -368,6 +368,33 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
|
||||
Assert.Equal(30, agent.HelloWaitTimeoutSeconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Merge_mirrors_analyzers_realmjoin_watcher_onto_agent_config()
|
||||
{
|
||||
// The diagnostics package gates its RealmJoin sections on this flag but never
|
||||
// sees the remote response — the merger carries the one Analyzers knob over.
|
||||
var agent = NewAgentConfig();
|
||||
var remote = FullRemote();
|
||||
remote.Analyzers = new AnalyzerConfiguration { EnableRealmJoinWatcher = true };
|
||||
|
||||
RemoteConfigMerger.Merge(agent, remote);
|
||||
|
||||
Assert.True(agent.EnableRealmJoinWatcher);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Merge_clears_realmjoin_watcher_when_remote_analyzers_missing()
|
||||
{
|
||||
var agent = NewAgentConfig();
|
||||
agent.EnableRealmJoinWatcher = true; // stale cached true must not survive
|
||||
var remote = FullRemote();
|
||||
remote.Analyzers = null!;
|
||||
|
||||
RemoteConfigMerger.Merge(agent, remote);
|
||||
|
||||
Assert.False(agent.EnableRealmJoinWatcher);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Merge_replaces_diagnostics_log_paths_with_empty_list_when_remote_null()
|
||||
{
|
||||
|
||||
+188
-2
@@ -1,9 +1,11 @@
|
||||
#nullable enable
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using AutopilotMonitor.Agent.V2.Core.Configuration;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
|
||||
@@ -46,7 +48,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Runtime
|
||||
Directory.CreateDirectory(SpoolFolder);
|
||||
}
|
||||
|
||||
public DiagnosticsPackageService Build(System.Action<AgentConfiguration>? mutateConfig = null)
|
||||
public DiagnosticsPackageService Build(
|
||||
System.Action<AgentConfiguration>? mutateConfig = null,
|
||||
IReadOnlyDictionary<string, string>? sectionFolderOverrides = null,
|
||||
System.Func<bool>? devicePreparationProbe = null)
|
||||
{
|
||||
// BackendApiClient is required by the public ctor but BuildArchiveBytes
|
||||
// never touches it — construct with a throwaway HttpClient. Tests only
|
||||
@@ -71,7 +76,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Runtime
|
||||
imeLogFolderOverride: ImeTmp.Path,
|
||||
agentStateFolderOverride: StateFolder,
|
||||
agentSpoolFolderOverride: SpoolFolder,
|
||||
agentDataFolderOverride: DataFolder);
|
||||
agentDataFolderOverride: DataFolder,
|
||||
sectionFolderOverrides: sectionFolderOverrides,
|
||||
devicePreparationProbe: devicePreparationProbe);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
@@ -287,6 +294,185 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Runtime
|
||||
Assert.DoesNotContain(entries, e => e.StartsWith("AgentSpool/"));
|
||||
}
|
||||
|
||||
// ===================================================== built-in catalog gates ====
|
||||
// The RealmJoin sections ride behind the tenant's RealmJoin Watcher toggle, the
|
||||
// Device Preparation bootstrapper event log behind the deterministic WDP marker.
|
||||
// Every conditional section is skipped WITH a manifest line, never silently.
|
||||
|
||||
/// <summary>RealmJoin system-side folders redirected to temp (user-side goes via UserProfileResolver).</summary>
|
||||
private sealed class RealmJoinRig : System.IDisposable
|
||||
{
|
||||
public TempDirectory WindowsLogs { get; } = new TempDirectory();
|
||||
public TempDirectory Choco { get; } = new TempDirectory();
|
||||
public TempDirectory UserProfile { get; } = new TempDirectory();
|
||||
public string PackagesFolder => Path.Combine(WindowsLogs.Path, "RealmJoin");
|
||||
|
||||
public RealmJoinRig()
|
||||
{
|
||||
File.WriteAllText(Path.Combine(WindowsLogs.Path, "realmjoin.log"), "rj");
|
||||
File.WriteAllText(Path.Combine(WindowsLogs.Path, "realmjoin2.log"), "rj2");
|
||||
var pkg = Path.Combine(PackagesFolder, "Packages", "generic-7zip");
|
||||
Directory.CreateDirectory(pkg);
|
||||
File.WriteAllText(Path.Combine(pkg, "install.log"), "pkg");
|
||||
var choco = Path.Combine(Choco.Path, "generic-7zip");
|
||||
Directory.CreateDirectory(choco);
|
||||
File.WriteAllText(Path.Combine(choco, "2026-06-30_install.log"), "choco");
|
||||
var userRj = Path.Combine(UserProfile.Path, "AppData", "Local", "RealmJoin");
|
||||
Directory.CreateDirectory(Path.Combine(userRj, "Logs", "generic-office"));
|
||||
File.WriteAllText(Path.Combine(userRj, "tray.log"), "tray");
|
||||
File.WriteAllText(Path.Combine(userRj, "config.pjson"), "not a log");
|
||||
File.WriteAllText(Path.Combine(userRj, "Logs", "RjImeHost.log"), "host");
|
||||
File.WriteAllText(Path.Combine(userRj, "Logs", "generic-office", "usersettings.log"), "pkg-user");
|
||||
}
|
||||
|
||||
public IReadOnlyDictionary<string, string> Overrides => new Dictionary<string, string>
|
||||
{
|
||||
["RealmJoinWindows"] = WindowsLogs.Path,
|
||||
["RealmJoinPackages"] = PackagesFolder,
|
||||
["RealmJoinChoco"] = Choco.Path,
|
||||
};
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
WindowsLogs.Dispose();
|
||||
Choco.Dispose();
|
||||
UserProfile.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildArchiveBytes_skips_realmjoin_sections_when_watcher_disabled()
|
||||
{
|
||||
using var rig = new Rig();
|
||||
using var rj = new RealmJoinRig();
|
||||
UserProfileResolver.SetForTesting(rj.UserProfile.Path);
|
||||
try
|
||||
{
|
||||
var bytes = rig.Build(sectionFolderOverrides: rj.Overrides).BuildArchiveBytes(enrollmentSucceeded: true);
|
||||
|
||||
var entries = ZipEntryNames(bytes);
|
||||
Assert.DoesNotContain(entries, e => e.StartsWith("RealmJoinLogs/"));
|
||||
var manifest = ReadZipEntryText(bytes, "package-manifest.txt")!;
|
||||
Assert.Contains("SCENARIO: devicePreparation=False realmJoinWatcher=False", manifest);
|
||||
Assert.Contains("BUILT-IN SKIPPED (RealmJoin Watcher disabled): RealmJoinWindows", manifest);
|
||||
Assert.Contains("BUILT-IN SKIPPED (RealmJoin Watcher disabled): RealmJoinUserLogs", manifest);
|
||||
}
|
||||
finally
|
||||
{
|
||||
UserProfileResolver.Reset();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildArchiveBytes_includes_realmjoin_sections_when_watcher_enabled()
|
||||
{
|
||||
using var rig = new Rig();
|
||||
using var rj = new RealmJoinRig();
|
||||
UserProfileResolver.SetForTesting(rj.UserProfile.Path);
|
||||
try
|
||||
{
|
||||
var bytes = rig.Build(cfg => cfg.EnableRealmJoinWatcher = true, rj.Overrides)
|
||||
.BuildArchiveBytes(enrollmentSucceeded: false);
|
||||
|
||||
var entries = ZipEntryNames(bytes);
|
||||
// ZIP layout mirrors the disk layout: flat client logs + package tree under
|
||||
// Windows/, tray logs + per-user Logs tree under User/.
|
||||
Assert.Contains("RealmJoinLogs/Windows/realmjoin.log", entries);
|
||||
Assert.Contains("RealmJoinLogs/Windows/realmjoin2.log", entries);
|
||||
Assert.Contains("RealmJoinLogs/Windows/RealmJoin/Packages/generic-7zip/install.log", entries);
|
||||
Assert.Contains("RealmJoinLogs/Choco/generic-7zip/2026-06-30_install.log", entries);
|
||||
Assert.Contains("RealmJoinLogs/User/tray.log", entries);
|
||||
Assert.Contains("RealmJoinLogs/User/Logs/RjImeHost.log", entries);
|
||||
Assert.Contains("RealmJoinLogs/User/Logs/generic-office/usersettings.log", entries);
|
||||
// Only tray*.log is collected from the user RealmJoin root — config stays out.
|
||||
Assert.DoesNotContain(entries, e => e.EndsWith("config.pjson"));
|
||||
var manifest = ReadZipEntryText(bytes, "package-manifest.txt")!;
|
||||
Assert.Contains("BUILT-IN: RealmJoinUserLogs -> folder=", manifest);
|
||||
Assert.DoesNotContain("BUILT-IN SKIPPED (RealmJoin Watcher disabled)", manifest);
|
||||
}
|
||||
finally
|
||||
{
|
||||
UserProfileResolver.Reset();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildArchiveBytes_skips_realmjoin_user_sections_without_user_session()
|
||||
{
|
||||
using var rig = new Rig();
|
||||
using var rj = new RealmJoinRig();
|
||||
// Token present, no interactive user detected → user-profile sections skip with a
|
||||
// manifest line; the system-side sections are unaffected.
|
||||
UserProfileResolver.SetForTesting(null!);
|
||||
try
|
||||
{
|
||||
var bytes = rig.Build(cfg => cfg.EnableRealmJoinWatcher = true, rj.Overrides)
|
||||
.BuildArchiveBytes(enrollmentSucceeded: true);
|
||||
|
||||
var entries = ZipEntryNames(bytes);
|
||||
Assert.Contains("RealmJoinLogs/Windows/realmjoin.log", entries);
|
||||
Assert.DoesNotContain(entries, e => e.StartsWith("RealmJoinLogs/User"));
|
||||
var manifest = ReadZipEntryText(bytes, "package-manifest.txt")!;
|
||||
Assert.Contains("BUILT-IN SKIPPED (no user session for token): RealmJoinUserTray", manifest);
|
||||
Assert.Contains("BUILT-IN SKIPPED (no user session for token): RealmJoinUserLogs", manifest);
|
||||
}
|
||||
finally
|
||||
{
|
||||
UserProfileResolver.Reset();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildArchiveBytes_collects_bootstrapper_event_log_only_on_device_preparation()
|
||||
{
|
||||
using var rig = new Rig();
|
||||
using var winevt = new TempDirectory();
|
||||
// Synthetic evtx: no registered channel claims the file, so the export falls
|
||||
// back to a raw copy — the point here is the scenario gate, not wevtutil.
|
||||
File.WriteAllBytes(Path.Combine(winevt.Path, "BootstrapperAgentServiceLogProvider.evtx"), new byte[] { 1, 2, 3 });
|
||||
var overrides = new Dictionary<string, string> { ["ImeBootstrapperEventLog"] = winevt.Path };
|
||||
|
||||
var classic = rig.Build(sectionFolderOverrides: overrides, devicePreparationProbe: () => false)
|
||||
.BuildArchiveBytes(enrollmentSucceeded: true);
|
||||
Assert.DoesNotContain("ImeLogs/BootstrapperAgentServiceLogProvider.evtx", ZipEntryNames(classic));
|
||||
Assert.Contains("BUILT-IN SKIPPED (not a Device Preparation enrollment): ImeBootstrapperEventLog",
|
||||
ReadZipEntryText(classic, "package-manifest.txt"));
|
||||
|
||||
var wdp = rig.Build(sectionFolderOverrides: overrides, devicePreparationProbe: () => true)
|
||||
.BuildArchiveBytes(enrollmentSucceeded: true);
|
||||
Assert.Contains("ImeLogs/BootstrapperAgentServiceLogProvider.evtx", ZipEntryNames(wdp));
|
||||
Assert.Contains("SCENARIO: devicePreparation=True", ReadZipEntryText(wdp, "package-manifest.txt"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildArchiveBytes_treats_failing_device_preparation_probe_as_classic()
|
||||
{
|
||||
using var rig = new Rig();
|
||||
var bytes = rig.Build(devicePreparationProbe: () => throw new System.InvalidOperationException("registry boom"))
|
||||
.BuildArchiveBytes(enrollmentSucceeded: true);
|
||||
|
||||
var manifest = ReadZipEntryText(bytes, "package-manifest.txt")!;
|
||||
Assert.Contains("SCENARIO PROBE FAILED (treated as Classic): registry boom", manifest);
|
||||
Assert.Contains("BUILT-IN SKIPPED (not a Device Preparation enrollment): ImeBootstrapperEventLog", manifest);
|
||||
Assert.Contains("sessioninfo.txt", ZipEntryNames(bytes)); // archive still built
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(DiagnosticsSectionCondition.Always, false, false, true)]
|
||||
[InlineData(DiagnosticsSectionCondition.RealmJoinWatcher, false, true, false)]
|
||||
[InlineData(DiagnosticsSectionCondition.RealmJoinWatcher, true, false, true)]
|
||||
[InlineData(DiagnosticsSectionCondition.DevicePreparation, true, false, false)]
|
||||
[InlineData(DiagnosticsSectionCondition.DevicePreparation, false, true, true)]
|
||||
[InlineData((DiagnosticsSectionCondition)999, true, true, false)]
|
||||
public void IsSectionActive_gates_on_condition(DiagnosticsSectionCondition condition, bool watcher, bool wdp, bool expected)
|
||||
{
|
||||
var section = new DiagnosticsBuiltInSection("X", "X", @"C:\X", new[] { "*.log" }, false, "x", condition);
|
||||
var cfg = Cfg();
|
||||
cfg.EnableRealmJoinWatcher = watcher;
|
||||
|
||||
Assert.Equal(expected, DiagnosticsPackageService.IsSectionActive(section, cfg, wdp));
|
||||
}
|
||||
|
||||
private static string[] ZipEntryNames(byte[] zipBytes)
|
||||
{
|
||||
using var ms = new MemoryStream(zipBytes);
|
||||
|
||||
+45
-3
@@ -97,7 +97,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Fails_after_five_exceptions_without_auth_error()
|
||||
public async Task Fails_after_six_exceptions_without_auth_error()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = NewLogger(tmp);
|
||||
@@ -111,8 +111,50 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
|
||||
backoffDelay: _ => Task.CompletedTask);
|
||||
|
||||
Assert.Equal(SessionRegistrationOutcome.Failed, result.Outcome);
|
||||
Assert.Equal(5, apiClient.CallCount);
|
||||
Assert.Contains("transient attempt 5", result.ErrorMessage);
|
||||
Assert.Equal(6, apiClient.CallCount);
|
||||
Assert.Contains("transient attempt 6", result.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Waits_for_network_link_before_first_attempt()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = NewLogger(tmp);
|
||||
var config = NewConfig();
|
||||
|
||||
var order = new System.Collections.Generic.List<string>();
|
||||
var apiClient = new FakeApiClient(_ =>
|
||||
{
|
||||
order.Add("register");
|
||||
return new RegisterSessionResponse { Success = true, SessionId = config.SessionId };
|
||||
});
|
||||
|
||||
var result = await SessionRegistrationHelper.RegisterWithRetryAsync(
|
||||
apiClient, config, agentVersion: "0.0.0", logger: logger,
|
||||
backoffDelay: _ => Task.CompletedTask,
|
||||
networkLinkWait: _ => { order.Add("link-wait"); return Task.CompletedTask; });
|
||||
|
||||
Assert.Equal(SessionRegistrationOutcome.Succeeded, result.Outcome);
|
||||
Assert.Equal(new[] { "link-wait", "register" }, order);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Network_link_wait_failure_does_not_block_registration()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = NewLogger(tmp);
|
||||
var config = NewConfig();
|
||||
|
||||
var apiClient = new FakeApiClient(_ =>
|
||||
new RegisterSessionResponse { Success = true, SessionId = config.SessionId });
|
||||
|
||||
var result = await SessionRegistrationHelper.RegisterWithRetryAsync(
|
||||
apiClient, config, agentVersion: "0.0.0", logger: logger,
|
||||
backoffDelay: _ => Task.CompletedTask,
|
||||
networkLinkWait: _ => throw new InvalidOperationException("probe broken"));
|
||||
|
||||
Assert.Equal(SessionRegistrationOutcome.Succeeded, result.Outcome);
|
||||
Assert.Equal(1, apiClient.CallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -262,6 +262,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
|
||||
/// </summary>
|
||||
public bool UnrestrictedMode { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Mirror of <c>AnalyzerConfiguration.EnableRealmJoinWatcher</c> (remote, per tenant).
|
||||
/// Gates the RealmJoin built-in sections of the diagnostics package — the package
|
||||
/// service only sees this configuration, not the remote response. The RealmJoin host
|
||||
/// itself keeps reading the remote Analyzers block. Default: false.
|
||||
/// </summary>
|
||||
public bool EnableRealmJoinWatcher { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Sanitized command-line arguments for the agent_started event.
|
||||
/// Secrets (e.g. bootstrap token) are redacted before assignment.
|
||||
|
||||
@@ -101,6 +101,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
|
||||
agentConfig.DiagnosticsUploadEnabled = remoteConfig.DiagnosticsUploadEnabled;
|
||||
agentConfig.DiagnosticsUploadMode = remoteConfig.DiagnosticsUploadMode;
|
||||
agentConfig.DiagnosticsLogPaths = remoteConfig.DiagnosticsLogPaths ?? new List<DiagnosticsLogPath>();
|
||||
// The diagnostics package gates its RealmJoin sections on the tenant's watcher
|
||||
// toggle but never sees the remote response — mirror the one Analyzers knob here.
|
||||
// The remaining Analyzers flags flow untouched to DefaultComponentFactory.
|
||||
agentConfig.EnableRealmJoinWatcher = remoteConfig.Analyzers?.EnableRealmJoinWatcher ?? false;
|
||||
|
||||
agentConfig.SendTraceEvents = remoteConfig.SendTraceEvents;
|
||||
|
||||
|
||||
+125
-88
@@ -10,7 +10,9 @@ using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
|
||||
using AutopilotMonitor.Agent.V2.Core.Security;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
@@ -124,62 +126,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
}
|
||||
}
|
||||
|
||||
private readonly string _imeLogFolder;
|
||||
private readonly string _agentLogFolder;
|
||||
private readonly string _agentStateFolder;
|
||||
private readonly string _agentSpoolFolder;
|
||||
private readonly string _agentDataFolder;
|
||||
// Built-in sections come from the Shared catalog (DiagnosticsBuiltInSections.All) — the
|
||||
// same list the backend serves to the portal, so what administrators see is exactly
|
||||
// what this service collects. Test seam: section id → folder; production leaves the
|
||||
// map empty and every section resolves from the catalog's (unexpanded) source folder.
|
||||
private readonly IReadOnlyDictionary<string, string> _sectionFolderOverrides;
|
||||
|
||||
private static readonly string DefaultImeLogFolder =
|
||||
Environment.ExpandEnvironmentVariables(@"%ProgramData%\Microsoft\IntuneManagementExtension\Logs");
|
||||
|
||||
private static readonly string DefaultAgentLogFolder =
|
||||
Environment.ExpandEnvironmentVariables(Constants.LogDirectory);
|
||||
|
||||
private static readonly string DefaultAgentStateFolder =
|
||||
Environment.ExpandEnvironmentVariables(Constants.StateDirectory);
|
||||
|
||||
private static readonly string DefaultAgentSpoolFolder =
|
||||
Environment.ExpandEnvironmentVariables(Constants.SpoolDirectory);
|
||||
|
||||
private static readonly string DefaultAgentDataFolder =
|
||||
Environment.ExpandEnvironmentVariables(@"%ProgramData%\AutopilotMonitor");
|
||||
|
||||
/// <summary>
|
||||
/// File extensions collected from built-in log folders (Agent + IME).
|
||||
/// Covers standard logs, structured data, traces, event logs, and diagnostics archives.
|
||||
/// </summary>
|
||||
private static readonly string[] LogFilePatterns = new[]
|
||||
{
|
||||
"*.log", "*.txt", "*.json", "*.jsonl",
|
||||
"*.etl", "*.evtx", "*.xml", "*.csv", "*.cab"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Patterns collected from the agent state folder. Adds completion markers on top of
|
||||
/// the standard log patterns so files like <c>enrollment-complete.marker</c> and
|
||||
/// <c>whiteglove-backfill-state.json</c> reach the diagnostics archive.
|
||||
/// </summary>
|
||||
private static readonly string[] StateFilePatterns = new[]
|
||||
{
|
||||
"*.log", "*.txt", "*.json", "*.jsonl",
|
||||
"*.etl", "*.evtx", "*.xml", "*.csv", "*.cab",
|
||||
"*.complete", "*.marker"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Marker patterns from the top-level data directory. Kept tight on purpose: only
|
||||
/// completion/exit markers, no session.id / bootstrap.json (those are config, not
|
||||
/// forensic state).
|
||||
/// </summary>
|
||||
private static readonly string[] RootMarkerPatterns = new[] { "*.complete", "*.marker" };
|
||||
|
||||
/// <summary>
|
||||
/// Telemetry spool: only the JSON-shaped files. The spool folder may also contain
|
||||
/// quarantine subfolders, so subfolders are excluded to keep the archive focused
|
||||
/// on what is actually pending upload.
|
||||
/// </summary>
|
||||
private static readonly string[] SpoolFilePatterns = new[] { "*.jsonl", "*.json" };
|
||||
// Scenario oracle for DiagnosticsSectionCondition.DevicePreparation. Production reads
|
||||
// the deterministic WDP registry marker (the same gate every other WDP switch uses);
|
||||
// tests inject a constant so they never touch the registry.
|
||||
private readonly Func<bool> _devicePreparationProbe;
|
||||
|
||||
public DiagnosticsPackageService(AgentConfiguration configuration, AgentLogger logger, BackendApiClient apiClient)
|
||||
: this(configuration, logger, apiClient, null, null, null, null, null)
|
||||
@@ -188,7 +144,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
|
||||
// Test seam: allows xUnit fixtures to redirect log/state/spool/data folders to a
|
||||
// temp dir without touching real %ProgramData% paths. Production callers always go
|
||||
// through the public ctor.
|
||||
// through the public ctor. The five named overrides map onto the catalog's section
|
||||
// ids; sectionFolderOverrides covers every other section (RealmJoin*, ImeBootstrapper…).
|
||||
internal DiagnosticsPackageService(
|
||||
AgentConfiguration configuration,
|
||||
AgentLogger logger,
|
||||
@@ -197,18 +154,38 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
string imeLogFolderOverride,
|
||||
string agentStateFolderOverride,
|
||||
string agentSpoolFolderOverride,
|
||||
string agentDataFolderOverride)
|
||||
string agentDataFolderOverride,
|
||||
IReadOnlyDictionary<string, string> sectionFolderOverrides = null,
|
||||
Func<bool> devicePreparationProbe = null)
|
||||
{
|
||||
_configuration = configuration;
|
||||
_logger = logger;
|
||||
_apiClient = apiClient;
|
||||
_httpClient = new HttpClient { Timeout = TimeSpan.FromMinutes(5) };
|
||||
|
||||
_agentLogFolder = agentLogFolderOverride ?? DefaultAgentLogFolder;
|
||||
_imeLogFolder = imeLogFolderOverride ?? DefaultImeLogFolder;
|
||||
_agentStateFolder = agentStateFolderOverride ?? DefaultAgentStateFolder;
|
||||
_agentSpoolFolder = agentSpoolFolderOverride ?? DefaultAgentSpoolFolder;
|
||||
_agentDataFolder = agentDataFolderOverride ?? DefaultAgentDataFolder;
|
||||
var overrides = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
void Map(string sectionId, string folder)
|
||||
{
|
||||
if (folder != null) overrides[sectionId] = folder;
|
||||
}
|
||||
Map("AgentLogs", agentLogFolderOverride);
|
||||
Map("ImeLogs", imeLogFolderOverride);
|
||||
Map("AgentState", agentStateFolderOverride);
|
||||
Map("AgentSpool", agentSpoolFolderOverride);
|
||||
Map("AgentMarkers", agentDataFolderOverride);
|
||||
if (sectionFolderOverrides != null)
|
||||
{
|
||||
foreach (var kv in sectionFolderOverrides)
|
||||
overrides[kv.Key] = kv.Value;
|
||||
}
|
||||
_sectionFolderOverrides = overrides;
|
||||
|
||||
// Tests that pass folder overrides but no probe must stay registry-free: a seam
|
||||
// caller gets "not WDP" by default, the production ctor gets the real marker.
|
||||
_devicePreparationProbe = devicePreparationProbe
|
||||
?? (overrides.Count == 0
|
||||
? (Func<bool>)EnrollmentRegistryDetector.IsDeterministicDevicePreparation
|
||||
: () => false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -370,33 +347,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
// 1. sessioninfo.txt — informational metadata, not subject to caps.
|
||||
AddSessionInfo(archive, enrollmentSucceeded);
|
||||
|
||||
// 2. Agent logs
|
||||
foreach (var pattern in LogFilePatterns)
|
||||
AddLogFiles(archive, _agentLogFolder, "AgentLogs", pattern, tracker);
|
||||
// 2. Built-in sections — ONE catalog shared with the backend (the portal
|
||||
// shows exactly this list): agent logs, IME logs (+ the Device Preparation
|
||||
// bootstrapper event log on WDP), agent state (recursive: `.quarantine`
|
||||
// and `.part1-<utc>/` buckets ride along), telemetry spool, top-level
|
||||
// markers, and the RealmJoin logs behind the tenant's watcher toggle.
|
||||
// The scenario probe is a registry read — evaluate it once per build.
|
||||
var isDevicePreparation = ProbeDevicePreparation();
|
||||
ManifestLine($"SCENARIO: devicePreparation={isDevicePreparation} realmJoinWatcher={_configuration.EnableRealmJoinWatcher}");
|
||||
foreach (var section in DiagnosticsBuiltInSections.All)
|
||||
AddBuiltInSection(archive, section, tracker, isDevicePreparation);
|
||||
|
||||
// 3. IME logs (all relevant files in the IME log folder)
|
||||
foreach (var pattern in LogFilePatterns)
|
||||
AddLogFiles(archive, _imeLogFolder, "ImeLogs", pattern, tracker);
|
||||
|
||||
// 4. Agent state: snapshot, journal, signal-log, ime-tracker-state,
|
||||
// enrollment-complete.marker, final-status.json, whiteglove-backfill-state.json.
|
||||
// Recursive to include `.quarantine` subfolders AND `.part1-<utc>/` buckets
|
||||
// that StateArchiver creates on WG Part-2 boot — both ride along under
|
||||
// AgentState/ via the same recursive walk.
|
||||
foreach (var pattern in StateFilePatterns)
|
||||
AddLogFiles(archive, _agentStateFolder, "AgentState", pattern, tracker, includeSubfolders: true);
|
||||
|
||||
// 5. Telemetry spool — pending uploads + upload cursor (forensic: what didn't ship?)
|
||||
foreach (var pattern in SpoolFilePatterns)
|
||||
AddLogFiles(archive, _agentSpoolFolder, "AgentSpool", pattern, tracker, includeSubfolders: false);
|
||||
|
||||
// 6. Top-level markers (whiteglove.complete, clean-exit marker) — Part-1/Part-2
|
||||
// bridge + ghost-restart guard. Top-directory only to avoid re-zipping
|
||||
// State/Spool/Logs/Agent which already have their own sections.
|
||||
foreach (var pattern in RootMarkerPatterns)
|
||||
AddLogFiles(archive, _agentDataFolder, "AgentMarkers", pattern, tracker, includeSubfolders: false);
|
||||
|
||||
// 7. Configured additional log paths (global + tenant, validated by guards)
|
||||
// 3. Configured additional log paths (global + tenant, validated by guards)
|
||||
foreach (var entry in _configuration.DiagnosticsLogPaths ?? new System.Collections.Generic.List<Shared.Models.DiagnosticsLogPath>())
|
||||
{
|
||||
// Resolve %LOGGED_ON_USER_PROFILE% token and get profile path for guard exception
|
||||
@@ -447,6 +409,81 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
}
|
||||
}
|
||||
|
||||
private bool ProbeDevicePreparation()
|
||||
{
|
||||
try
|
||||
{
|
||||
return _devicePreparationProbe();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Fail toward Classic like every other WDP gate: the evtx export is skipped,
|
||||
// never the whole package.
|
||||
_logger.Warning($"Device Preparation probe failed — treating as Classic: {ex.Message}");
|
||||
ManifestLine($"SCENARIO PROBE FAILED (treated as Classic): {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure gate: is this built-in section collected under the given configuration and
|
||||
/// enrollment scenario? Unknown conditions (a newer Shared catalog than this agent)
|
||||
/// fail closed.
|
||||
/// </summary>
|
||||
internal static bool IsSectionActive(DiagnosticsBuiltInSection section, AgentConfiguration configuration, bool isDevicePreparation)
|
||||
{
|
||||
switch (section.Condition)
|
||||
{
|
||||
case DiagnosticsSectionCondition.Always:
|
||||
return true;
|
||||
case DiagnosticsSectionCondition.RealmJoinWatcher:
|
||||
return configuration.EnableRealmJoinWatcher;
|
||||
case DiagnosticsSectionCondition.DevicePreparation:
|
||||
return isDevicePreparation;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string DescribeInactiveCondition(DiagnosticsSectionCondition condition)
|
||||
{
|
||||
switch (condition)
|
||||
{
|
||||
case DiagnosticsSectionCondition.RealmJoinWatcher:
|
||||
return "RealmJoin Watcher disabled";
|
||||
case DiagnosticsSectionCondition.DevicePreparation:
|
||||
return "not a Device Preparation enrollment";
|
||||
default:
|
||||
return $"unknown condition {condition}";
|
||||
}
|
||||
}
|
||||
|
||||
private void AddBuiltInSection(ZipArchive archive, DiagnosticsBuiltInSection section, BudgetTracker tracker, bool isDevicePreparation)
|
||||
{
|
||||
if (!IsSectionActive(section, _configuration, isDevicePreparation))
|
||||
{
|
||||
ManifestLine($"BUILT-IN SKIPPED ({DescribeInactiveCondition(section.Condition)}): {section.Id}");
|
||||
return;
|
||||
}
|
||||
|
||||
// Override (test seam) or catalog source folder with %ProgramData% /
|
||||
// %LOGGED_ON_USER_PROFILE% expanded; null means the token is present but no
|
||||
// interactive user has been detected yet — same skip as configured paths.
|
||||
var folder = _sectionFolderOverrides.TryGetValue(section.Id, out var overrideFolder)
|
||||
? overrideFolder
|
||||
: UserProfileResolver.ExpandCustomTokens(section.SourceFolder);
|
||||
if (folder == null)
|
||||
{
|
||||
_logger.Warning($"Built-in diagnostics section skipped (no user session for token): {section.Id}");
|
||||
ManifestLine($"BUILT-IN SKIPPED (no user session for token): {section.Id} '{section.SourceFolder}'");
|
||||
return;
|
||||
}
|
||||
|
||||
ManifestLine($"BUILT-IN: {section.Id} -> folder='{folder}' zip='{section.ZipFolder}' recursive={section.IncludeSubfolders}");
|
||||
foreach (var pattern in section.Patterns)
|
||||
AddLogFiles(archive, folder, section.ZipFolder, pattern, tracker, section.IncludeSubfolders);
|
||||
}
|
||||
|
||||
private void AddSessionInfo(ZipArchive archive, bool? enrollmentSucceeded)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
|
||||
@@ -20,7 +20,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
/// </summary>
|
||||
public static class UserProfileResolver
|
||||
{
|
||||
public const string Token = "%LOGGED_ON_USER_PROFILE%";
|
||||
// Spelled once, in Shared: the built-in diagnostics catalog uses the same token.
|
||||
public const string Token = Shared.Models.DiagnosticsBuiltInSections.UserProfileToken;
|
||||
|
||||
private static readonly object Lock = new object();
|
||||
private static bool _resolved;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Net.NetworkInformation;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Configuration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
@@ -19,7 +20,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
/// <b>Retry contract (V1 <c>MonitoringService.RegisterSessionAsync</c>):</b>
|
||||
/// </para>
|
||||
/// <list type="bullet">
|
||||
/// <item>5 attempts total; between attempts <c>2^attempt</c> seconds (2s, 4s, 8s, 16s).</item>
|
||||
/// <item>Boot-time NIC grace before the first attempt: when no network link is up yet
|
||||
/// (BootTrigger relaunch after a mid-enrollment reboot, Wi-Fi still associating) wait
|
||||
/// up to <see cref="NetworkLinkWaitMax"/> for the link-level signal. Best-effort and
|
||||
/// free on the normal path — a live link returns immediately.</item>
|
||||
/// <item>6 attempts total; between attempts <c>2^attempt</c> seconds (2s, 4s, 8s, 16s, 32s —
|
||||
/// ~62 s of backoff). The original V1 budget of 5 attempts / ~30 s was too short for the
|
||||
/// reboot-relaunch on Wi-Fi kiosks: the relaunched agent gave up and exited 7 before
|
||||
/// the network came back, leaving the session silent forever (tenant aebdce78,
|
||||
/// 2026-08-23 audit). A relaunch that misses here is lost — nothing retries later.</item>
|
||||
/// <item>On <c>response.Success == true</c> → stop immediately.</item>
|
||||
/// <item>On <see cref="BackendAuthException"/> (401/403) → report to the
|
||||
/// <see cref="AuthFailureTracker"/> (which fires the first-failure distress + may trip
|
||||
@@ -37,16 +46,23 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
/// </summary>
|
||||
public static class SessionRegistrationHelper
|
||||
{
|
||||
private const int MaxAttempts = 5;
|
||||
private const int MaxAttempts = 6;
|
||||
|
||||
/// <summary>Upper bound for the pre-registration link wait (see class remarks).</summary>
|
||||
internal static readonly TimeSpan NetworkLinkWaitMax = TimeSpan.FromSeconds(15);
|
||||
|
||||
/// <summary>
|
||||
/// Executes the 5-retry register-session handshake against the backend. Returns the
|
||||
/// Executes the retrying register-session handshake against the backend. Returns the
|
||||
/// response + classified outcome. Never throws.
|
||||
/// </summary>
|
||||
/// <param name="backoffDelay">
|
||||
/// Optional delay-provider used by tests to avoid real-time waits. Production callers
|
||||
/// pass <c>null</c> → V1 parity <c>Task.Delay(2^attempt * 1000)</c> between attempts.
|
||||
/// </param>
|
||||
/// <param name="networkLinkWait">
|
||||
/// Optional pre-registration link wait used by tests. Production callers pass
|
||||
/// <c>null</c> → <see cref="WaitForNetworkLinkAsync"/> (bounded by <see cref="NetworkLinkWaitMax"/>).
|
||||
/// </param>
|
||||
public static async Task<SessionRegistrationResult> RegisterWithRetryAsync(
|
||||
BackendApiClient apiClient,
|
||||
AgentConfiguration agentConfig,
|
||||
@@ -56,14 +72,19 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
EmergencyReporter emergencyReporter = null,
|
||||
Func<int, Task> backoffDelay = null,
|
||||
Func<Exception, Task> onTerminalTransportFailure = null,
|
||||
(string Manufacturer, string Model, string SerialNumber)? deviceHardware = null)
|
||||
(string Manufacturer, string Model, string SerialNumber)? deviceHardware = null,
|
||||
Func<AgentLogger, Task> networkLinkWait = null)
|
||||
{
|
||||
if (apiClient == null) throw new ArgumentNullException(nameof(apiClient));
|
||||
if (agentConfig == null) throw new ArgumentNullException(nameof(agentConfig));
|
||||
if (logger == null) throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
backoffDelay ??= DefaultBackoffDelay;
|
||||
networkLinkWait ??= WaitForNetworkLinkAsync;
|
||||
var registration = BuildRegistration(agentConfig, agentVersion, deviceHardware);
|
||||
|
||||
try { await networkLinkWait(logger).ConfigureAwait(false); }
|
||||
catch (Exception ex) { logger.Debug($"Pre-registration network wait failed ({ex.Message}) — registering anyway."); }
|
||||
string lastError = null;
|
||||
Exception lastException = null;
|
||||
|
||||
@@ -113,7 +134,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
|
||||
if (attempt < MaxAttempts)
|
||||
{
|
||||
var delaySeconds = (int)Math.Pow(2, attempt); // 2, 4, 8, 16
|
||||
var delaySeconds = (int)Math.Pow(2, attempt); // 2, 4, 8, 16, 32
|
||||
logger.Info($"Retrying session registration in {delaySeconds}s");
|
||||
await backoffDelay(attempt).ConfigureAwait(false);
|
||||
}
|
||||
@@ -143,6 +164,31 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
private static Task DefaultBackoffDelay(int attempt)
|
||||
=> Task.Delay(((int)Math.Pow(2, attempt)) * 1000);
|
||||
|
||||
/// <summary>
|
||||
/// Boot-time NIC grace (same pattern as the emergency-break reporter): polls the cheap
|
||||
/// link-level signal once per second for at most <see cref="NetworkLinkWaitMax"/>. It
|
||||
/// cannot prove backend reachability — the retry loop handles that — it only keeps the
|
||||
/// first attempts from being burned into a link that is provably still down. Probe
|
||||
/// errors end the wait, never the registration.
|
||||
/// </summary>
|
||||
internal static async Task WaitForNetworkLinkAsync(AgentLogger logger)
|
||||
{
|
||||
if (NetworkInterface.GetIsNetworkAvailable()) return;
|
||||
|
||||
logger?.Info($"Session registration: no network link yet — waiting up to {NetworkLinkWaitMax.TotalSeconds:F0}s.");
|
||||
var deadline = DateTime.UtcNow + NetworkLinkWaitMax;
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
await Task.Delay(1000).ConfigureAwait(false);
|
||||
if (NetworkInterface.GetIsNetworkAvailable())
|
||||
{
|
||||
logger?.Info("Session registration: network link is up.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
logger?.Warning($"Session registration: still no network link after {NetworkLinkWaitMax.TotalSeconds:F0}s — attempting anyway.");
|
||||
}
|
||||
|
||||
private static SessionRegistration BuildRegistration(
|
||||
AgentConfiguration agentConfig,
|
||||
string agentVersion,
|
||||
@@ -194,7 +240,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
/// <summary>401/403 from the backend — cert/token rejected. No retry was attempted.</summary>
|
||||
AuthFailed = 1,
|
||||
|
||||
/// <summary>All 5 attempts failed with non-auth errors (network, 5xx, malformed response).</summary>
|
||||
/// <summary>All attempts failed with non-auth errors (network, 5xx, malformed response).</summary>
|
||||
Failed = 2,
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
{
|
||||
/// <summary>
|
||||
/// Phase 6 of <see cref="Program"/>'s <c>RunAgent</c>: blocking
|
||||
/// <c>POST /api/agent/register-session</c> with 5-retry exponential backoff before the
|
||||
/// <c>POST /api/agent/register-session</c> with link-wait + 6-retry exponential backoff before the
|
||||
/// orchestrator starts. V1 parity (MonitoringService.RegisterSessionAsync) — without
|
||||
/// this call the backend's Sessions table never gets a row for this session, so
|
||||
/// <c>IncrementSessionEventCountAsync</c> / <c>UpdateSessionStatusAsync</c> silently
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Invariants of the built-in diagnostics catalog (<see cref="DiagnosticsBuiltInSections"/>).
|
||||
/// Shared has no test project, so the catalog is pinned here: the agent iterates it to build the
|
||||
/// archive and the backend serves it to the portal, so a malformed entry would either break
|
||||
/// collection on every device or mislead every administrator reading the list.
|
||||
/// </summary>
|
||||
public class DiagnosticsBuiltInSectionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void SectionIds_AreUnique()
|
||||
{
|
||||
var ids = DiagnosticsBuiltInSections.All.Select(s => s.Id).ToArray();
|
||||
Assert.Equal(ids.Length, ids.Distinct(StringComparer.Ordinal).Count());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ZipFolderAndSourceFolderPairs_AreUnique()
|
||||
{
|
||||
// Two sections may share a ZIP folder (ImeLogs + the bootstrapper evtx) only when they
|
||||
// read from different source folders — the same (zip, source) pair would double-add
|
||||
// every file under duplicate entry names.
|
||||
var pairs = DiagnosticsBuiltInSections.All
|
||||
.Select(s => $"{s.ZipFolder}|{s.SourceFolder}".ToLowerInvariant())
|
||||
.ToArray();
|
||||
Assert.Equal(pairs.Length, pairs.Distinct().Count());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlwaysSections_KeepHistoricalArchiveLayoutAndOrder()
|
||||
{
|
||||
// Archive-layout lock: these five sections (and their order) are what every diagnostics
|
||||
// ZIP has carried since the PR1-B forensics work; tooling and support habits rely on it.
|
||||
var always = DiagnosticsBuiltInSections.All
|
||||
.Where(s => s.Condition == DiagnosticsSectionCondition.Always)
|
||||
.Select(s => s.Id)
|
||||
.ToArray();
|
||||
Assert.Equal(new[] { "AgentLogs", "ImeLogs", "AgentState", "AgentSpool", "AgentMarkers" }, always);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConditionalSections_CarryTheExpectedGate()
|
||||
{
|
||||
foreach (var section in DiagnosticsBuiltInSections.All)
|
||||
{
|
||||
if (section.Id.StartsWith("RealmJoin", StringComparison.Ordinal))
|
||||
Assert.Equal(DiagnosticsSectionCondition.RealmJoinWatcher, section.Condition);
|
||||
if (section.Id == "ImeBootstrapperEventLog")
|
||||
Assert.Equal(DiagnosticsSectionCondition.DevicePreparation, section.Condition);
|
||||
}
|
||||
Assert.Contains(DiagnosticsBuiltInSections.All, s => s.Id == "ImeBootstrapperEventLog");
|
||||
Assert.Equal(5, DiagnosticsBuiltInSections.All.Count(s => s.Condition == DiagnosticsSectionCondition.RealmJoinWatcher));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UserProfileTokenSections_AreNeverAlwaysOn()
|
||||
{
|
||||
// The token resolves only once an interactive user exists; an Always section reading
|
||||
// from the user profile would be skipped on every pre-desktop package and mislead the
|
||||
// portal's "collected by every agent" wording.
|
||||
var tokenSections = DiagnosticsBuiltInSections.All
|
||||
.Where(s => s.SourceFolder.Contains(DiagnosticsBuiltInSections.UserProfileToken, StringComparison.OrdinalIgnoreCase))
|
||||
.ToArray();
|
||||
Assert.NotEmpty(tokenSections);
|
||||
Assert.All(tokenSections, s => Assert.NotEqual(DiagnosticsSectionCondition.Always, s.Condition));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Patterns_AreFileNamesWithoutPathSeparators()
|
||||
{
|
||||
Assert.All(DiagnosticsBuiltInSections.All, s =>
|
||||
{
|
||||
Assert.NotEmpty(s.Patterns);
|
||||
Assert.All(s.Patterns, p =>
|
||||
{
|
||||
Assert.False(string.IsNullOrWhiteSpace(p));
|
||||
Assert.DoesNotContain('\\', p);
|
||||
Assert.DoesNotContain('/', p);
|
||||
});
|
||||
Assert.False(string.IsNullOrWhiteSpace(s.Description));
|
||||
Assert.False(s.ZipFolder.StartsWith("/") || s.ZipFolder.EndsWith("/"));
|
||||
Assert.DoesNotContain('\\', s.ZipFolder);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NoBuiltInSection_UsesTheConfiguredPathsZipFolder()
|
||||
{
|
||||
// AdditionalLogs/<folder> is reserved for admin-configured entries.
|
||||
Assert.All(DiagnosticsBuiltInSections.All,
|
||||
s => Assert.False(s.ZipFolder.StartsWith("AdditionalLogs", StringComparison.OrdinalIgnoreCase)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Functions.Services.Notifications;
|
||||
using AutopilotMonitor.Shared.Models.Notifications;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Discord embed rendering: payload shape, color mapping, and the Discord embed limits
|
||||
/// (25 fields, 256/1024 name/value, 4096 description, 6000 total).
|
||||
/// </summary>
|
||||
public class DiscordRendererTests
|
||||
{
|
||||
private static JsonElement RenderEmbed(NotificationAlert alert)
|
||||
{
|
||||
var json = new DiscordRenderer().RenderToJson(alert);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
var embeds = doc.RootElement.GetProperty("embeds");
|
||||
Assert.Equal(1, embeds.GetArrayLength());
|
||||
return embeds[0];
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RendersSingleEmbed_WithTitleColorFieldsAndLinks()
|
||||
{
|
||||
var alert = new NotificationAlert
|
||||
{
|
||||
Title = "Enrollment Succeeded",
|
||||
Summary = "Enrollment Succeeded: DESK-001",
|
||||
Severity = NotificationSeverity.Success,
|
||||
ThemeColor = "00B050",
|
||||
Facts = { new NotificationFact { Name = "Device", Value = "DESK-001" } },
|
||||
Sections = { new NotificationSection { Title = "Details", Text = "All apps installed." } },
|
||||
Actions = { new NotificationAction { Type = "openUrl", Title = "Open session", Url = "https://portal.example.com/sessions/abc" } },
|
||||
};
|
||||
|
||||
var embed = RenderEmbed(alert);
|
||||
|
||||
Assert.Equal("✅ Enrollment Succeeded", embed.GetProperty("title").GetString());
|
||||
Assert.Equal(0x00B050, embed.GetProperty("color").GetInt32());
|
||||
|
||||
var description = embed.GetProperty("description").GetString()!;
|
||||
Assert.StartsWith("Enrollment Succeeded: DESK-001", description);
|
||||
Assert.Contains("**Details**\nAll apps installed.", description);
|
||||
// Webhooks cannot post buttons — actions become markdown links.
|
||||
Assert.Contains("[Open session](https://portal.example.com/sessions/abc)", description);
|
||||
|
||||
var fields = embed.GetProperty("fields");
|
||||
Assert.Equal(1, fields.GetArrayLength());
|
||||
Assert.Equal("Device", fields[0].GetProperty("name").GetString());
|
||||
Assert.Equal("DESK-001", fields[0].GetProperty("value").GetString());
|
||||
Assert.True(fields[0].GetProperty("inline").GetBoolean());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(NotificationSeverity.Success, 0x2ECC71)]
|
||||
[InlineData(NotificationSeverity.Error, 0xE74C3C)]
|
||||
[InlineData(NotificationSeverity.Warning, 0xF1C40F)]
|
||||
[InlineData(NotificationSeverity.Info, 0x3498DB)]
|
||||
public void Color_FallsBackToSeverity_WhenThemeColorMissingOrInvalid(NotificationSeverity severity, int expected)
|
||||
{
|
||||
foreach (var themeColor in new[] { "", "not-hex" })
|
||||
{
|
||||
var embed = RenderEmbed(new NotificationAlert { Title = "T", Severity = severity, ThemeColor = themeColor });
|
||||
Assert.Equal(expected, embed.GetProperty("color").GetInt32());
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fields_CappedAt25_AndEmptyFactsSkipped()
|
||||
{
|
||||
var alert = new NotificationAlert { Title = "T", Summary = "S" };
|
||||
alert.Facts.Add(new NotificationFact { Name = "", Value = "dropped" });
|
||||
alert.Facts.Add(new NotificationFact { Name = "dropped", Value = " " });
|
||||
for (var i = 0; i < 30; i++)
|
||||
alert.Facts.Add(new NotificationFact { Name = $"f{i}", Value = "v" });
|
||||
|
||||
var fields = RenderEmbed(alert).GetProperty("fields");
|
||||
|
||||
Assert.Equal(25, fields.GetArrayLength());
|
||||
Assert.Equal("f0", fields[0].GetProperty("name").GetString()); // empty facts skipped, not counted
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FieldValue_TruncatedTo1024_WithEllipsis()
|
||||
{
|
||||
var alert = new NotificationAlert { Title = "T" };
|
||||
alert.Facts.Add(new NotificationFact { Name = "long", Value = new string('x', 2000) });
|
||||
|
||||
var value = RenderEmbed(alert).GetProperty("fields")[0].GetProperty("value").GetString()!;
|
||||
|
||||
Assert.Equal(1024, value.Length);
|
||||
Assert.EndsWith("…", value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TotalEmbedLength_CappedAt6000_ByDroppingTrailingFields()
|
||||
{
|
||||
var alert = new NotificationAlert { Title = "T", Summary = "S" };
|
||||
for (var i = 0; i < 25; i++)
|
||||
alert.Facts.Add(new NotificationFact { Name = $"f{i}", Value = new string('v', 1000) });
|
||||
|
||||
var embed = RenderEmbed(alert);
|
||||
var fields = embed.GetProperty("fields");
|
||||
|
||||
var total = embed.GetProperty("title").GetString()!.Length
|
||||
+ embed.GetProperty("description").GetString()!.Length;
|
||||
for (var i = 0; i < fields.GetArrayLength(); i++)
|
||||
total += fields[i].GetProperty("name").GetString()!.Length + fields[i].GetProperty("value").GetString()!.Length;
|
||||
|
||||
Assert.True(fields.GetArrayLength() < 25);
|
||||
Assert.True(total <= 6000);
|
||||
Assert.Equal("f0", fields[0].GetProperty("name").GetString()); // leading fields kept
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmptyDescription_Omitted_AndProviderTypeIsDiscord()
|
||||
{
|
||||
var embed = RenderEmbed(new NotificationAlert { Title = "T", Severity = NotificationSeverity.Info });
|
||||
|
||||
Assert.False(embed.TryGetProperty("description", out _));
|
||||
Assert.False(embed.TryGetProperty("fields", out _));
|
||||
Assert.Equal(WebhookProviderType.Discord, new DiscordRenderer().ProviderType);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Coverage for the provider-neutral <see cref="EmailService"/> (welcome + farewell mails).
|
||||
/// The provider HTTP call is driven through a stub handler, so the Mandrill wire format,
|
||||
/// the result-array interpretation and the fail-soft contract are pinned without network.
|
||||
/// <list type="bullet">
|
||||
/// <item>Neither send method ever throws — not on a missing key, missing recipient,
|
||||
/// HTTP 500, a rejected recipient, nor a malformed body.</item>
|
||||
/// <item>Gate ordering: missing-key short-circuits before missing-recipient.</item>
|
||||
/// <item>Tracking is off on the wire (trust-page claim: provider receives only the
|
||||
/// recipient address and tenant domain).</item>
|
||||
/// <item>The farewell template is final: no [DRAFT]/placeholder residue, domain is
|
||||
/// interpolated, empty domain falls back to "your organization".</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public sealed class EmailServiceTests
|
||||
{
|
||||
private const string TenantId = "88888888-8888-8888-8888-888888888888";
|
||||
|
||||
// ----- configuration gates -----
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_EmptyApiKey_NoOps_AndLogsMissingKeyDebugLine()
|
||||
{
|
||||
var (sut, handler, logger) = Build(apiKey: "");
|
||||
|
||||
await sut.SendAsync("ops@contoso.invalid", "contoso.invalid", TenantId, CancellationToken.None);
|
||||
|
||||
Assert.Equal(0, handler.CallCount);
|
||||
Assert.Contains(logger.Entries,
|
||||
e => e.Level == LogLevel.Debug && e.Message.Contains("Email:ApiKey not configured"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_EmptyRecipient_NoOps_AndLogsMissingEmailDebugLine()
|
||||
{
|
||||
var (sut, handler, logger) = Build(apiKey: "any-non-empty-key");
|
||||
|
||||
await sut.SendAsync("", "contoso.invalid", TenantId);
|
||||
|
||||
Assert.Equal(0, handler.CallCount);
|
||||
Assert.Contains(logger.Entries,
|
||||
e => e.Level == LogLevel.Debug && e.Message.Contains("No notification email captured"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_EmptyApiKey_ShortCircuitsBeforeRecipientCheck()
|
||||
{
|
||||
// Pins the gate ordering: missing-key > missing-email. Both are empty here; only
|
||||
// the key line must show up.
|
||||
var (sut, _, logger) = Build(apiKey: "");
|
||||
|
||||
await sut.SendAsync("", "contoso.invalid", TenantId);
|
||||
|
||||
Assert.Contains(logger.Entries,
|
||||
e => e.Level == LogLevel.Debug && e.Message.Contains("Email:ApiKey not configured"));
|
||||
Assert.DoesNotContain(logger.Entries,
|
||||
e => e.Message.Contains("No notification email captured"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendPreviewApprovedEmailAsync_EmptyApiKey_NoOps()
|
||||
{
|
||||
var (sut, handler, logger) = Build(apiKey: "");
|
||||
|
||||
await sut.SendPreviewApprovedEmailAsync("it@contoso.invalid", "contoso.invalid");
|
||||
|
||||
Assert.Equal(0, handler.CallCount);
|
||||
Assert.Contains(logger.Entries,
|
||||
e => e.Level == LogLevel.Debug && e.Message.Contains("Email:ApiKey not configured"));
|
||||
}
|
||||
|
||||
// ----- wire format -----
|
||||
|
||||
[Fact]
|
||||
public async Task SendPreviewApprovedEmailAsync_PostsMandrillPayload_WithTrackingOff()
|
||||
{
|
||||
var (sut, handler, logger) = Build(apiKey: "md-test-key");
|
||||
|
||||
await sut.SendPreviewApprovedEmailAsync("it@contoso.invalid", "contoso.invalid");
|
||||
|
||||
Assert.Equal(1, handler.CallCount);
|
||||
Assert.Equal(HttpMethod.Post, handler.LastRequest!.Method);
|
||||
Assert.Equal(EmailService.DefaultEndpoint, handler.LastRequest.RequestUri!.ToString());
|
||||
|
||||
using var doc = JsonDocument.Parse(handler.LastBody!);
|
||||
var root = doc.RootElement;
|
||||
Assert.Equal("md-test-key", root.GetProperty("key").GetString());
|
||||
|
||||
var message = root.GetProperty("message");
|
||||
Assert.Equal(EmailService.DefaultFromAddress, message.GetProperty("from_email").GetString());
|
||||
Assert.Equal(EmailService.DefaultFromName, message.GetProperty("from_name").GetString());
|
||||
Assert.Equal("it@contoso.invalid", message.GetProperty("to")[0].GetProperty("email").GetString());
|
||||
Assert.Equal("to", message.GetProperty("to")[0].GetProperty("type").GetString());
|
||||
Assert.Equal(EmailTemplates.PreviewApprovedSubject, message.GetProperty("subject").GetString());
|
||||
Assert.Contains("contoso.invalid", message.GetProperty("html").GetString());
|
||||
Assert.True(message.GetProperty("auto_text").GetBoolean());
|
||||
Assert.False(message.GetProperty("track_opens").GetBoolean());
|
||||
Assert.False(message.GetProperty("track_clicks").GetBoolean());
|
||||
Assert.Equal("welcome", message.GetProperty("tags")[0].GetString());
|
||||
|
||||
Assert.Contains(logger.Entries,
|
||||
e => e.Level == LogLevel.Information && e.Message.Contains("Preview approval email sent"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_UsesFarewellTemplate_AndFarewellTag()
|
||||
{
|
||||
var (sut, handler, logger) = Build(apiKey: "md-test-key");
|
||||
|
||||
await sut.SendAsync("ops@contoso.invalid", "contoso.invalid", TenantId);
|
||||
|
||||
using var doc = JsonDocument.Parse(handler.LastBody!);
|
||||
var message = doc.RootElement.GetProperty("message");
|
||||
Assert.Equal(EmailTemplates.OffboardingFarewellSubject, message.GetProperty("subject").GetString());
|
||||
Assert.Equal("offboarding-farewell", message.GetProperty("tags")[0].GetString());
|
||||
Assert.Contains(logger.Entries,
|
||||
e => e.Level == LogLevel.Information && e.Message.Contains("Offboard farewell email sent"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConfiguredEndpointAndSender_OverrideDefaults()
|
||||
{
|
||||
var (sut, handler, _) = Build(apiKey: "md-test-key", extra: new Dictionary<string, string?>
|
||||
{
|
||||
["Email:Endpoint"] = "https://mail.example.invalid/send",
|
||||
["Email:FromAddress"] = "hello@example.invalid",
|
||||
["Email:FromName"] = "Example Sender",
|
||||
});
|
||||
|
||||
await sut.SendPreviewApprovedEmailAsync("it@contoso.invalid", "contoso.invalid");
|
||||
|
||||
Assert.Equal("https://mail.example.invalid/send", handler.LastRequest!.RequestUri!.ToString());
|
||||
using var doc = JsonDocument.Parse(handler.LastBody!);
|
||||
var message = doc.RootElement.GetProperty("message");
|
||||
Assert.Equal("hello@example.invalid", message.GetProperty("from_email").GetString());
|
||||
Assert.Equal("Example Sender", message.GetProperty("from_name").GetString());
|
||||
}
|
||||
|
||||
// ----- provider responses / fail-soft -----
|
||||
|
||||
[Fact]
|
||||
public async Task ProviderHttpError_DoesNotThrow_AndLogsWarning_WithoutSuccessLine()
|
||||
{
|
||||
var (sut, _, logger) = Build(apiKey: "md-test-key",
|
||||
responder: _ => Json(HttpStatusCode.InternalServerError,
|
||||
"{\"status\":\"error\",\"code\":-1,\"name\":\"Invalid_Key\",\"message\":\"Invalid API key\"}"));
|
||||
|
||||
await sut.SendAsync("ops@contoso.invalid", "contoso.invalid", TenantId);
|
||||
|
||||
Assert.Contains(logger.Entries,
|
||||
e => e.Level == LogLevel.Warning && e.Message.Contains("500") && e.Message.Contains("Invalid_Key"));
|
||||
Assert.DoesNotContain(logger.Entries, e => e.Level == LogLevel.Information);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProviderRejectsRecipient_LogsWarningWithReason_WithoutSuccessLine()
|
||||
{
|
||||
var (sut, _, logger) = Build(apiKey: "md-test-key",
|
||||
responder: _ => Json(HttpStatusCode.OK,
|
||||
"[{\"email\":\"ops@contoso.invalid\",\"status\":\"rejected\",\"reject_reason\":\"hard-bounce\",\"_id\":\"abc\"}]"));
|
||||
|
||||
await sut.SendAsync("ops@contoso.invalid", "contoso.invalid", TenantId);
|
||||
|
||||
Assert.Contains(logger.Entries,
|
||||
e => e.Level == LogLevel.Warning && e.Message.Contains("rejected") && e.Message.Contains("hard-bounce"));
|
||||
Assert.DoesNotContain(logger.Entries, e => e.Level == LogLevel.Information);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("sent")]
|
||||
[InlineData("queued")]
|
||||
[InlineData("scheduled")]
|
||||
public async Task ProviderAcceptedStatuses_CountAsSuccess(string status)
|
||||
{
|
||||
var (sut, _, logger) = Build(apiKey: "md-test-key",
|
||||
responder: _ => Json(HttpStatusCode.OK,
|
||||
$"[{{\"email\":\"ops@contoso.invalid\",\"status\":\"{status}\",\"_id\":\"abc\"}}]"));
|
||||
|
||||
await sut.SendAsync("ops@contoso.invalid", "contoso.invalid", TenantId);
|
||||
|
||||
Assert.Contains(logger.Entries, e => e.Level == LogLevel.Information);
|
||||
Assert.DoesNotContain(logger.Entries, e => e.Level == LogLevel.Warning);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MalformedProviderBody_DoesNotThrow_AndLogsWarning()
|
||||
{
|
||||
var (sut, _, logger) = Build(apiKey: "md-test-key",
|
||||
responder: _ => Json(HttpStatusCode.OK, "not json"));
|
||||
|
||||
await sut.SendAsync("ops@contoso.invalid", "contoso.invalid", TenantId);
|
||||
|
||||
Assert.Contains(logger.Entries, e => e.Level == LogLevel.Warning);
|
||||
Assert.DoesNotContain(logger.Entries, e => e.Level == LogLevel.Information);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TransportException_DoesNotThrow_AndLogsWarning()
|
||||
{
|
||||
var (sut, _, logger) = Build(apiKey: "md-test-key",
|
||||
responder: _ => throw new HttpRequestException("simulated provider outage"));
|
||||
|
||||
await sut.SendPreviewApprovedEmailAsync("it@contoso.invalid", "contoso.invalid");
|
||||
|
||||
Assert.Contains(logger.Entries,
|
||||
e => e.Level == LogLevel.Warning && e.Message.Contains("Failed to send welcome mail"));
|
||||
}
|
||||
|
||||
// ----- templates -----
|
||||
|
||||
[Fact]
|
||||
public void FarewellTemplate_IsFinal_NoDraftResidue()
|
||||
{
|
||||
Assert.DoesNotContain("DRAFT", EmailTemplates.OffboardingFarewellSubject);
|
||||
|
||||
var html = EmailTemplates.GetOffboardingFarewellHtml("contoso.invalid");
|
||||
Assert.DoesNotContain("DRAFT", html);
|
||||
Assert.DoesNotContain("TEMPLATE NOT FINALISED", html);
|
||||
Assert.DoesNotContain("TODO", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FarewellTemplate_InterpolatesDomain_AndLinksFeedbackChannels()
|
||||
{
|
||||
var html = EmailTemplates.GetOffboardingFarewellHtml("contoso.invalid");
|
||||
|
||||
Assert.Contains("contoso.invalid", html);
|
||||
// The mail is sent from a noreply address after portal access is gone, so the
|
||||
// feedback pointers must be external channels the recipient can actually reach.
|
||||
Assert.Contains("https://github.com/okieselbach/AutopilotMonitor/issues", html);
|
||||
Assert.Contains("linkedin.com", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FarewellTemplate_EmptyDomain_FallsBackToGenericLabel()
|
||||
{
|
||||
var html = EmailTemplates.GetOffboardingFarewellHtml("");
|
||||
Assert.Contains("your organization", html);
|
||||
}
|
||||
|
||||
// ----- harness -----
|
||||
|
||||
private static HttpResponseMessage Json(HttpStatusCode status, string body)
|
||||
=> new(status) { Content = new StringContent(body, System.Text.Encoding.UTF8, "application/json") };
|
||||
|
||||
private static (EmailService sut, StubHandler handler, CapturingLogger<EmailService> logger) Build(
|
||||
string apiKey,
|
||||
Dictionary<string, string?>? extra = null,
|
||||
System.Func<HttpRequestMessage, HttpResponseMessage>? responder = null)
|
||||
{
|
||||
var settings = new Dictionary<string, string?> { ["Email:ApiKey"] = apiKey };
|
||||
if (extra is not null)
|
||||
foreach (var kv in extra) settings[kv.Key] = kv.Value;
|
||||
|
||||
var config = new ConfigurationBuilder().AddInMemoryCollection(settings).Build();
|
||||
var handler = new StubHandler();
|
||||
if (responder is not null) handler.Responder = responder;
|
||||
var logger = new CapturingLogger<EmailService>();
|
||||
var templates = new EmailTemplateService(
|
||||
Moq.Mock.Of<AutopilotMonitor.Shared.DataAccess.IConfigRepository>(),
|
||||
new Microsoft.Extensions.Caching.Memory.MemoryCache(new Microsoft.Extensions.Caching.Memory.MemoryCacheOptions()),
|
||||
Microsoft.Extensions.Logging.Abstractions.NullLogger<EmailTemplateService>.Instance);
|
||||
var sut = new EmailService(new HttpClient(handler, disposeHandler: false), config, templates, logger);
|
||||
return (sut, handler, logger);
|
||||
}
|
||||
|
||||
private sealed class StubHandler : HttpMessageHandler
|
||||
{
|
||||
public int CallCount { get; private set; }
|
||||
public HttpRequestMessage? LastRequest { get; private set; }
|
||||
public string? LastBody { get; private set; }
|
||||
public System.Func<HttpRequestMessage, HttpResponseMessage> Responder { get; set; } = _ =>
|
||||
Json(HttpStatusCode.OK, "[{\"email\":\"x@y.invalid\",\"status\":\"sent\",\"_id\":\"abc\"}]");
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
CallCount++;
|
||||
LastRequest = request;
|
||||
LastBody = request.Content is null ? null : await request.Content.ReadAsStringAsync(cancellationToken);
|
||||
return Responder(request);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CapturingLogger<T> : ILogger<T>
|
||||
{
|
||||
public readonly List<(LogLevel Level, string Message)> Entries = new();
|
||||
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
public void Log<TState>(
|
||||
LogLevel logLevel, EventId eventId, TState state, System.Exception? exception,
|
||||
System.Func<TState, System.Exception?, string> formatter)
|
||||
{
|
||||
Entries.Add((logLevel, formatter(state, exception)));
|
||||
}
|
||||
|
||||
private sealed class NullScope : IDisposable
|
||||
{
|
||||
public static readonly NullScope Instance = new();
|
||||
public void Dispose() { }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins the operator-override layer over the built-in transactional templates:
|
||||
/// override wins when stored, built-in otherwise; placeholder rendering matches the
|
||||
/// built-ins' empty-domain fallback; the send path is fail-soft on storage errors while the
|
||||
/// admin path is not; save validates and invalidates the cache; reset is idempotent.
|
||||
/// </summary>
|
||||
public sealed class EmailTemplateServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task NoOverride_ReturnsBuiltIn_Rendered()
|
||||
{
|
||||
var (sut, _) = Build();
|
||||
|
||||
var html = await sut.GetHtmlAsync(EmailTemplateKind.Welcome, "contoso.invalid");
|
||||
|
||||
Assert.Equal(EmailTemplates.GetPreviewApprovedHtml("contoso.invalid"), html);
|
||||
Assert.DoesNotContain(EmailTemplateService.DomainPlaceholder, html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Override_WinsAndRendersPlaceholder()
|
||||
{
|
||||
var (sut, repo) = Build();
|
||||
repo.Setup(r => r.GetEmailTemplateOverrideAsync("farewell"))
|
||||
.ReturnsAsync(new EmailTemplateOverride { Kind = "farewell", Html = "<p>Bye {{domainName}}</p>" });
|
||||
|
||||
var html = await sut.GetHtmlAsync(EmailTemplateKind.Farewell, "contoso.invalid");
|
||||
|
||||
Assert.Equal("<p>Bye contoso.invalid</p>", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Override_EmptyDomain_FallsBackToGenericLabel_LikeBuiltIn()
|
||||
{
|
||||
var (sut, repo) = Build();
|
||||
repo.Setup(r => r.GetEmailTemplateOverrideAsync("welcome"))
|
||||
.ReturnsAsync(new EmailTemplateOverride { Kind = "welcome", Html = "Hi {{domainName}}" });
|
||||
|
||||
Assert.Equal("Hi your organization", await sut.GetHtmlAsync(EmailTemplateKind.Welcome, ""));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StorageError_SendPath_FallsBackToBuiltIn_AdminPath_Throws()
|
||||
{
|
||||
var (sut, repo) = Build();
|
||||
repo.Setup(r => r.GetEmailTemplateOverrideAsync(It.IsAny<string>()))
|
||||
.ThrowsAsync(new System.InvalidOperationException("storage down"));
|
||||
|
||||
var html = await sut.GetHtmlAsync(EmailTemplateKind.Welcome, "contoso.invalid");
|
||||
Assert.Contains("contoso.invalid", html);
|
||||
|
||||
await Assert.ThrowsAsync<System.InvalidOperationException>(() => sut.GetOverrideAsync(EmailTemplateKind.Welcome));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Save_Validates_ThenInvalidatesCache()
|
||||
{
|
||||
var (sut, repo) = Build();
|
||||
repo.Setup(r => r.GetEmailTemplateOverrideAsync("welcome")).ReturnsAsync((EmailTemplateOverride?)null);
|
||||
|
||||
// warm the cache with "no override"
|
||||
Assert.Null(await sut.GetOverrideAsync(EmailTemplateKind.Welcome));
|
||||
|
||||
await Assert.ThrowsAsync<System.ArgumentException>(() => sut.SaveOverrideAsync(EmailTemplateKind.Welcome, " ", "ga@contoso.invalid"));
|
||||
await Assert.ThrowsAsync<System.ArgumentException>(() =>
|
||||
sut.SaveOverrideAsync(EmailTemplateKind.Welcome, new string('x', EmailTemplateService.MaxHtmlLength + 1), "ga@contoso.invalid"));
|
||||
repo.Verify(r => r.SaveEmailTemplateOverrideAsync(It.IsAny<EmailTemplateOverride>()), Times.Never);
|
||||
|
||||
EmailTemplateOverride? stored = null;
|
||||
repo.Setup(r => r.SaveEmailTemplateOverrideAsync(It.IsAny<EmailTemplateOverride>()))
|
||||
.Callback<EmailTemplateOverride>(e => stored = e).Returns(Task.CompletedTask);
|
||||
repo.Setup(r => r.GetEmailTemplateOverrideAsync("welcome")).ReturnsAsync(() => stored);
|
||||
|
||||
var saved = await sut.SaveOverrideAsync(EmailTemplateKind.Welcome, "<p>{{domainName}}</p>", "ga@contoso.invalid");
|
||||
|
||||
Assert.Equal("welcome", saved.Kind);
|
||||
Assert.Equal("ga@contoso.invalid", saved.UpdatedBy);
|
||||
// cache was invalidated: the next read sees the stored override, not the cached null
|
||||
Assert.NotNull(await sut.GetOverrideAsync(EmailTemplateKind.Welcome));
|
||||
Assert.Equal("<p>contoso.invalid</p>", await sut.GetHtmlAsync(EmailTemplateKind.Welcome, "contoso.invalid"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Delete_ClearsCache_ReturnsToBuiltIn()
|
||||
{
|
||||
var (sut, repo) = Build();
|
||||
var stored = new EmailTemplateOverride { Kind = "farewell", Html = "custom {{domainName}}" };
|
||||
repo.Setup(r => r.GetEmailTemplateOverrideAsync("farewell")).ReturnsAsync(() => stored);
|
||||
Assert.Equal("custom contoso.invalid", await sut.GetHtmlAsync(EmailTemplateKind.Farewell, "contoso.invalid"));
|
||||
|
||||
repo.Setup(r => r.DeleteEmailTemplateOverrideAsync("farewell")).Callback(() => stored = null!).Returns(Task.CompletedTask);
|
||||
await sut.DeleteOverrideAsync(EmailTemplateKind.Farewell, "ga@contoso.invalid");
|
||||
|
||||
Assert.Equal(EmailTemplates.GetOffboardingFarewellHtml("contoso.invalid"),
|
||||
await sut.GetHtmlAsync(EmailTemplateKind.Farewell, "contoso.invalid"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("welcome", true)]
|
||||
[InlineData("Farewell", true)]
|
||||
[InlineData("invoice", false)]
|
||||
[InlineData(null, false)]
|
||||
public void TryParseKind_AcceptsOnlyKnownKinds(string? value, bool expected)
|
||||
=> Assert.Equal(expected, EmailTemplateService.TryParseKind(value, out _));
|
||||
|
||||
[Fact]
|
||||
public void BuiltInRaw_ContainsPlaceholder_ForBothKinds()
|
||||
{
|
||||
Assert.Contains(EmailTemplateService.DomainPlaceholder, EmailTemplateService.BuiltInRaw(EmailTemplateKind.Welcome));
|
||||
Assert.Contains(EmailTemplateService.DomainPlaceholder, EmailTemplateService.BuiltInRaw(EmailTemplateKind.Farewell));
|
||||
}
|
||||
|
||||
private static (EmailTemplateService sut, Mock<IConfigRepository> repo) Build()
|
||||
{
|
||||
var repo = new Mock<IConfigRepository>(MockBehavior.Loose);
|
||||
var sut = new EmailTemplateService(repo.Object, new MemoryCache(new MemoryCacheOptions()), NullLogger<EmailTemplateService>.Instance);
|
||||
return (sut, repo);
|
||||
}
|
||||
}
|
||||
+15
@@ -191,6 +191,21 @@ public class EndpointPolicyCatalogCompletenessTests
|
||||
Assert.Equal(EndpointPolicy.GlobalAdminOnly, reseed.Policy);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The built-in diagnostics catalog + global paths are platform-wide, secret-free data that
|
||||
/// every settings viewer needs (what is collected from the tenant's devices). Member tier,
|
||||
/// JWT-scoped — an upgrade to a global tier would hide it from tenant admins again.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void DiagnosticsPaths_IsMemberReadJwtScoped()
|
||||
{
|
||||
var entry = EndpointAccessPolicyCatalog.FindPolicy("GET", "/api/diagnostics/paths");
|
||||
|
||||
Assert.NotNull(entry);
|
||||
Assert.Equal(EndpointPolicy.MemberRead, entry.Policy);
|
||||
Assert.Equal(TenantScoping.Jwt, entry.TenantScoping);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unregistered routes must return null (fail-closed).
|
||||
/// </summary>
|
||||
|
||||
@@ -471,4 +471,228 @@ public class EnrollmentTimeoutClassifierTests
|
||||
Assert.NotEqual(SessionStatus.Failed, status);
|
||||
}
|
||||
}
|
||||
|
||||
// -------- WhiteGlove Part-2 awaiting-user gate (fairstone.ca analysis 2026-08-21) --------
|
||||
|
||||
private static readonly DateTime Resumed = Start.AddMinutes(20);
|
||||
|
||||
private static (SessionStatus, string) ClassifyWhiteGlove(
|
||||
IReadOnlyList<EnrollmentEvent> events, double hoursSinceStart = 6, int grace = 51)
|
||||
{
|
||||
var rollup = EnrollmentTimeoutClassifier.ExtractRollup(events);
|
||||
var now = Start.AddHours(hoursSinceStart);
|
||||
return EnrollmentTimeoutClassifier.ClassifyTimedOutSession(
|
||||
rollup, Start, now, grace, isPreProvisioned: true, resumedAt: Resumed);
|
||||
}
|
||||
|
||||
/// <summary>The fairstone event shape: Part 1 sealed, Part 2 resumed, nobody signed in.</summary>
|
||||
private static EnrollmentEvent[] WgParkedEvents() => new[]
|
||||
{
|
||||
Esp(DeviceSetup44), Evt("whiteglove_complete"), Esp(AccountSetup05),
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void ExtractRollup_separates_enrollment_complete_from_whiteglove_complete()
|
||||
{
|
||||
var wgOnly = EnrollmentTimeoutClassifier.ExtractRollup(new[] { Evt("whiteglove_complete") });
|
||||
Assert.True(wgOnly.HasTerminalComplete);
|
||||
Assert.False(wgOnly.HasEnrollmentComplete);
|
||||
|
||||
var real = EnrollmentTimeoutClassifier.ExtractRollup(new[] { Evt("enrollment_complete") });
|
||||
Assert.True(real.HasTerminalComplete);
|
||||
Assert.True(real.HasEnrollmentComplete);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WhiteGloveGate_requires_preprovisioned_and_resume_and_device_setup()
|
||||
{
|
||||
var parked = EnrollmentTimeoutClassifier.ExtractRollup(WgParkedEvents());
|
||||
Assert.True(EnrollmentTimeoutClassifier.IsWhiteGloveAwaitingUser(parked, true, Resumed));
|
||||
Assert.False(EnrollmentTimeoutClassifier.IsWhiteGloveAwaitingUser(parked, false, Resumed));
|
||||
Assert.False(EnrollmentTimeoutClassifier.IsWhiteGloveAwaitingUser(parked, true, null));
|
||||
|
||||
// Powered off before Device Setup ever provisioned → not the parking state.
|
||||
var early = EnrollmentTimeoutClassifier.ExtractRollup(new[]
|
||||
{
|
||||
Esp("ESP provisioning status: DeviceSetup — 3 of 4 subcategories completed"),
|
||||
});
|
||||
Assert.False(EnrollmentTimeoutClassifier.IsWhiteGloveAwaitingUser(early, true, Resumed));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WhiteGloveGate_any_user_evidence_or_terminal_disarms_it()
|
||||
{
|
||||
foreach (var evidence in new[]
|
||||
{
|
||||
Esp(AccountSetup15), // Account Setup progressed → user was there
|
||||
Evt("desktop_arrived"), // real-user desktop
|
||||
Evt("hello_provisioning_completed"), // positive Hello terminal
|
||||
Evt("enrollment_complete"), // real completion
|
||||
Evt("enrollment_failed"), // explicit failure
|
||||
})
|
||||
{
|
||||
var rollup = EnrollmentTimeoutClassifier.ExtractRollup(new[]
|
||||
{
|
||||
Esp(DeviceSetup44), Evt("whiteglove_complete"), Esp(AccountSetup05), evidence,
|
||||
});
|
||||
Assert.False(EnrollmentTimeoutClassifier.IsWhiteGloveAwaitingUser(rollup, true, Resumed));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Classify_whiteglove_part2_parked_within_grace_is_AwaitingUser()
|
||||
{
|
||||
// The fairstone shape: technician powers the device off at the logon screen minutes
|
||||
// after the reseal-reboot. Part-1 whiteglove_complete must NOT reconcile to Succeeded.
|
||||
var (status, reason) = ClassifyWhiteGlove(WgParkedEvents(), hoursSinceStart: 6);
|
||||
Assert.Equal(SessionStatus.AwaitingUser, status);
|
||||
Assert.Contains("sealed or powered off awaiting the end user", reason);
|
||||
Assert.Contains("(Account Setup 0/5)", reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Classify_whiteglove_part2_parked_past_grace_reconciles_with_honest_reason()
|
||||
{
|
||||
var (status, reason) = ClassifyWhiteGlove(WgParkedEvents(), hoursSinceStart: 60, grace: 51);
|
||||
Assert.Equal(SessionStatus.Succeeded, status);
|
||||
Assert.Contains("pre-provisioning (WhiteGlove Part 1) completed", reason);
|
||||
Assert.DoesNotContain("Account Setup completed", reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Classify_whiteglove_part2_with_partial_user_progress_is_not_reconciled_on_part1_evidence()
|
||||
{
|
||||
// User signed in and Account Setup progressed to 1/5, then silence. The Part-1
|
||||
// whiteglove_complete previously reconciled this to Succeeded claiming "Account Setup
|
||||
// completed" — on a resumed session only a real enrollment_complete proves the outcome,
|
||||
// so this honest shape falls through to the AwaitingUser/Incomplete rules.
|
||||
var events = new[]
|
||||
{
|
||||
Esp(DeviceSetup44), Evt("whiteglove_complete"), Esp(AccountSetup15),
|
||||
};
|
||||
var (withinGrace, _) = ClassifyWhiteGlove(events, hoursSinceStart: 6);
|
||||
Assert.Equal(SessionStatus.AwaitingUser, withinGrace);
|
||||
|
||||
var (pastGrace, reason) = ClassifyWhiteGlove(events, hoursSinceStart: 60, grace: 51);
|
||||
Assert.Equal(SessionStatus.Incomplete, pastGrace);
|
||||
Assert.Contains("1/5", reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Classify_whiteglove_part2_with_desktop_and_hello_still_reconciles_via_user_evidence()
|
||||
{
|
||||
// Both Classic completion prerequisites in → the user provably finished; the
|
||||
// user-completed reconcile (rule 4) owns this, not the parking gate.
|
||||
var (status, reason) = ClassifyWhiteGlove(new[]
|
||||
{
|
||||
Esp(DeviceSetup44), Evt("whiteglove_complete"), Esp(AccountSetup15),
|
||||
Evt("desktop_arrived"), Evt("hello_provisioning_completed"),
|
||||
}, hoursSinceStart: 6);
|
||||
Assert.Equal(SessionStatus.Succeeded, status);
|
||||
Assert.Contains("user completed setup", reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Classify_whiteglove_part2_real_enrollment_complete_still_reconciles_to_Succeeded()
|
||||
{
|
||||
var (status, _) = ClassifyWhiteGlove(new[]
|
||||
{
|
||||
Esp(DeviceSetup44), Evt("whiteglove_complete"), Esp(AccountSetup05), Evt("enrollment_complete"),
|
||||
}, hoursSinceStart: 6);
|
||||
Assert.Equal(SessionStatus.Succeeded, status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Classify_unresumed_session_keeps_terminal_complete_reconcile()
|
||||
{
|
||||
// No resumedAt (plain sessions, or a sealed Part 1 whose Pending write was lost):
|
||||
// whiteglove_complete keeps reconciling to Succeeded exactly as before.
|
||||
var rollup = EnrollmentTimeoutClassifier.ExtractRollup(new[]
|
||||
{
|
||||
Esp(DeviceSetup44), Evt("whiteglove_complete"), Esp(AccountSetup05),
|
||||
});
|
||||
var (status, _) = EnrollmentTimeoutClassifier.ClassifyTimedOutSession(
|
||||
rollup, Start, Start.AddHours(6), 51, isPreProvisioned: true, resumedAt: null);
|
||||
Assert.Equal(SessionStatus.Succeeded, status);
|
||||
}
|
||||
|
||||
// -------- Self-deploying profile gate (kiosk tenant aebdce78, audit 2026-08-23) --------
|
||||
|
||||
private static (SessionStatus, string) ClassifySelfDeploying(
|
||||
IReadOnlyList<EnrollmentEvent> events, double hoursSinceStart = 3, int grace = 51)
|
||||
{
|
||||
var rollup = EnrollmentTimeoutClassifier.ExtractRollup(events);
|
||||
return EnrollmentTimeoutClassifier.ClassifyTimedOutSession(
|
||||
rollup, Start, Start.AddHours(hoursSinceStart), grace,
|
||||
lastEventAtUtc: Start.AddMinutes(4), isSelfDeployingProfile: true);
|
||||
}
|
||||
|
||||
// Session 195593e2 replay: DeviceSetup 4/4 (fallback-confirmed), AccountSetup registry
|
||||
// at 0/5 (the IME false positive — user ESP never runs on this profile), Hello disabled,
|
||||
// SkipUser=True, then silence after the post-ESP reboot. No user will ever sign in.
|
||||
private static readonly EnrollmentEvent[] KioskSilentStream =
|
||||
{
|
||||
Esp(DeviceSetup44), Esp(AccountSetup05), Esp(DeviceSetupFallback),
|
||||
HelloPolicy(enabled: false), EspConfig(skipUser: true),
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void IsSelfDeployingProvisioned_requires_flag_device_setup_and_no_failure()
|
||||
{
|
||||
var provisioned = EnrollmentTimeoutClassifier.ExtractRollup(KioskSilentStream);
|
||||
Assert.True(EnrollmentTimeoutClassifier.IsSelfDeployingProvisioned(provisioned, isSelfDeployingProfile: true));
|
||||
Assert.False(EnrollmentTimeoutClassifier.IsSelfDeployingProvisioned(provisioned, isSelfDeployingProfile: false));
|
||||
|
||||
var beforeDeviceSetup = EnrollmentTimeoutClassifier.ExtractRollup(new[] { Esp(AccountSetup05) });
|
||||
Assert.False(EnrollmentTimeoutClassifier.IsSelfDeployingProvisioned(beforeDeviceSetup, isSelfDeployingProfile: true));
|
||||
|
||||
var failed = EnrollmentTimeoutClassifier.ExtractRollup(new[] { Esp(DeviceSetup44), Evt("enrollment_failed") });
|
||||
Assert.False(EnrollmentTimeoutClassifier.IsSelfDeployingProvisioned(failed, isSelfDeployingProfile: true));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Classify_self_deploying_silent_after_device_setup_reconciles_to_Succeeded_within_grace()
|
||||
{
|
||||
var (status, reason) = ClassifySelfDeploying(KioskSilentStream, hoursSinceStart: 3);
|
||||
Assert.Equal(SessionStatus.Succeeded, status);
|
||||
Assert.Contains("self-deploying profile", reason);
|
||||
Assert.Contains("no user / Account Setup phase", reason);
|
||||
Assert.Contains("Agent last reported 2026-07-06 15:04 UTC", reason);
|
||||
Assert.DoesNotContain("Account Setup completed", reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Classify_self_deploying_silent_after_device_setup_reconciles_to_Succeeded_past_grace()
|
||||
{
|
||||
// Grace is a user-phase concept; it does not apply when there is no user phase.
|
||||
var (status, _) = ClassifySelfDeploying(KioskSilentStream, hoursSinceStart: 60, grace: 51);
|
||||
Assert.Equal(SessionStatus.Succeeded, status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Classify_same_stream_without_self_deploying_flag_keeps_user_phase_verdicts()
|
||||
{
|
||||
// Guard against the gate leaking into user-driven sessions: the identical event shape
|
||||
// on a user-driven profile is still "awaiting user" within grace / Incomplete past it.
|
||||
var rollup = EnrollmentTimeoutClassifier.ExtractRollup(KioskSilentStream);
|
||||
var (within, _) = EnrollmentTimeoutClassifier.ClassifyTimedOutSession(rollup, Start, Start.AddHours(3), 51);
|
||||
var (past, _) = EnrollmentTimeoutClassifier.ClassifyTimedOutSession(rollup, Start, Start.AddHours(60), 51);
|
||||
Assert.Equal(SessionStatus.AwaitingUser, within);
|
||||
Assert.Equal(SessionStatus.Incomplete, past);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Classify_self_deploying_explicit_failure_stays_Failed()
|
||||
{
|
||||
var (status, _) = ClassifySelfDeploying(new[] { Esp(DeviceSetup44), Evt("enrollment_failed") });
|
||||
Assert.Equal(SessionStatus.Failed, status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Classify_self_deploying_silent_before_device_setup_is_Incomplete()
|
||||
{
|
||||
// The profile flag alone proves nothing — Device ESP must have finished.
|
||||
var (status, _) = ClassifySelfDeploying(new[] { Esp(AccountSetup05), Evt("agent_started") });
|
||||
Assert.Equal(SessionStatus.Incomplete, status);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using AutopilotMonitor.Functions.Functions.Config;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
@@ -32,6 +33,44 @@ public class GetAgentConfigFunctionTests
|
||||
Assert.Equal(expectedMajor, actual);
|
||||
}
|
||||
|
||||
// ── MergeDiagnosticsLogPaths — global ∪ tenant without duplicate ZIP entries ──────────
|
||||
|
||||
private static DiagnosticsLogPath P(string path, string description = "") =>
|
||||
new() { Path = path, Description = description };
|
||||
|
||||
[Fact]
|
||||
public void MergeDiagnosticsLogPaths_GlobalFirst_OrderPreserved_DuplicatesDroppedCaseInsensitively()
|
||||
{
|
||||
var global = new[] { P(@"C:\Windows\Panther\setupact.log", "global"), P(@"C:\Windows\Logs\DISM\dism.log") };
|
||||
var tenant = new[]
|
||||
{
|
||||
P(@"c:\windows\panther\SETUPACT.LOG", "tenant copy"), // same path, different case → dropped
|
||||
P(@" C:\Windows\Logs\DISM\dism.log "), // trimmed duplicate → dropped
|
||||
P(@"C:\Windows\SetupDiag\*.log", "tenant"),
|
||||
};
|
||||
|
||||
var merged = GetAgentConfigFunction.MergeDiagnosticsLogPaths(global, tenant);
|
||||
|
||||
Assert.Equal(new[]
|
||||
{
|
||||
@"C:\Windows\Panther\setupact.log",
|
||||
@"C:\Windows\Logs\DISM\dism.log",
|
||||
@"C:\Windows\SetupDiag\*.log",
|
||||
}, merged.Select(p => p.Path));
|
||||
Assert.Equal("global", merged[0].Description); // first occurrence (global) wins
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MergeDiagnosticsLogPaths_DropsBlankEntries()
|
||||
{
|
||||
var merged = GetAgentConfigFunction.MergeDiagnosticsLogPaths(
|
||||
new[] { P(""), P(" "), P(@"C:\Install\Log\*.log") },
|
||||
new DiagnosticsLogPath[] { null!, P(@"C:\Install\Log\*.log") });
|
||||
|
||||
Assert.Single(merged);
|
||||
Assert.Equal(@"C:\Install\Log\*.log", merged[0].Path);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
// CustomerSas: gated on the per-tenant SAS URL being present.
|
||||
[InlineData("https://acct.blob.core.windows.net/c?sv=...", "CustomerSas", true)]
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Functions.Functions.Diagnostics;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Wire shape of <c>GET /api/diagnostics/paths</c> (<see cref="GetDiagnosticsPathsFunction.BuildPayload"/>).
|
||||
/// The route is MemberRead, so the field set is locked: the payload must stay "what is
|
||||
/// collected" — never grow admin-only config.
|
||||
/// </summary>
|
||||
public class GetDiagnosticsPathsPayloadTests
|
||||
{
|
||||
// Mirrors the worker's serializer (Program.cs: PropertyNamingPolicy = CamelCase) so the
|
||||
// nested DiagnosticsLogPath class serializes the way the portal actually receives it.
|
||||
private static readonly JsonSerializerOptions WorkerJson = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
|
||||
|
||||
private static JsonElement Serialize(AdminConfiguration config)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(GetDiagnosticsPathsFunction.BuildPayload(config), WorkerJson);
|
||||
return JsonDocument.Parse(json).RootElement;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Payload_HasExactlyBuiltInAndGlobalPaths()
|
||||
{
|
||||
var element = Serialize(new AdminConfiguration());
|
||||
|
||||
var fieldNames = element.EnumerateObject().Select(p => p.Name).OrderBy(n => n).ToArray();
|
||||
Assert.Equal(new[] { "builtIn", "globalPaths" }, fieldNames);
|
||||
Assert.Equal(JsonValueKind.Array, element.GetProperty("globalPaths").ValueKind);
|
||||
Assert.Equal(0, element.GetProperty("globalPaths").GetArrayLength());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuiltIn_MirrorsTheCatalog_WithConditionAsName()
|
||||
{
|
||||
var builtIn = Serialize(new AdminConfiguration()).GetProperty("builtIn");
|
||||
|
||||
Assert.Equal(DiagnosticsBuiltInSections.All.Count, builtIn.GetArrayLength());
|
||||
var first = builtIn[0];
|
||||
Assert.Equal("AgentLogs", first.GetProperty("id").GetString());
|
||||
Assert.Equal("AgentLogs", first.GetProperty("zipFolder").GetString());
|
||||
Assert.Equal(AutopilotMonitor.Shared.Constants.LogDirectory, first.GetProperty("sourceFolder").GetString());
|
||||
Assert.Equal(DiagnosticsBuiltInSections.LogFilePatterns.Length, first.GetProperty("patterns").GetArrayLength());
|
||||
Assert.False(first.GetProperty("includeSubfolders").GetBoolean());
|
||||
Assert.Equal("Always", first.GetProperty("condition").GetString());
|
||||
|
||||
var conditions = builtIn.EnumerateArray().Select(s => s.GetProperty("condition").GetString()).ToArray();
|
||||
Assert.Contains("RealmJoinWatcher", conditions);
|
||||
Assert.Contains("DevicePreparation", conditions);
|
||||
Assert.All(conditions, c => Assert.False(int.TryParse(c, out _), "condition must serialize as the enum name"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GlobalPaths_RoundTripTheStoredJson()
|
||||
{
|
||||
var config = new AdminConfiguration
|
||||
{
|
||||
DiagnosticsGlobalLogPathsJson =
|
||||
"[{\"path\":\"C:\\\\Windows\\\\Panther\\\\setupact.log\",\"description\":\"Setup\",\"isBuiltIn\":true,\"includeSubfolders\":false}]",
|
||||
};
|
||||
|
||||
var globalPaths = Serialize(config).GetProperty("globalPaths");
|
||||
|
||||
Assert.Equal(1, globalPaths.GetArrayLength());
|
||||
Assert.Equal(@"C:\Windows\Panther\setupact.log", globalPaths[0].GetProperty("path").GetString());
|
||||
Assert.Equal("Setup", globalPaths[0].GetProperty("description").GetString());
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using AutopilotMonitor.Functions.Functions.Config;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Shared.Models.Notifications;
|
||||
@@ -64,6 +65,17 @@ public class NotificationChannelsTests
|
||||
Assert.Empty(slack.GetCustomHeaders());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSigningSecret_OnlyForGenericJson()
|
||||
{
|
||||
var secret = "0123456789abcdef";
|
||||
Assert.Equal(secret, new NotificationChannel { Id = "g", ProviderType = 20, SigningSecret = secret }.GetSigningSecret());
|
||||
Assert.Null(new NotificationChannel { Id = "s", ProviderType = 10, SigningSecret = secret }.GetSigningSecret());
|
||||
Assert.Null(new NotificationChannel { Id = "d", ProviderType = 30, SigningSecret = secret }.GetSigningSecret());
|
||||
Assert.Null(new NotificationChannel { Id = "w", ProviderType = 20, SigningSecret = " " }.GetSigningSecret());
|
||||
Assert.Null(new NotificationChannel { Id = "n", ProviderType = 20 }.GetSigningSecret());
|
||||
}
|
||||
|
||||
// ── Legacy synthesis (GetNotificationChannels) ────────────────────────
|
||||
|
||||
[Fact]
|
||||
@@ -141,7 +153,7 @@ public class NotificationChannelsTests
|
||||
var cfg = new TenantConfiguration
|
||||
{
|
||||
NotificationChannelsJson = "[" +
|
||||
ChannelJson("ch-1", extra: ",\"customHeadersJson\":\"{\\\"X-Api-Key\\\":\\\"k\\\"}\"") + "," +
|
||||
ChannelJson("ch-1", extra: ",\"customHeadersJson\":\"{\\\"X-Api-Key\\\":\\\"k\\\"}\",\"signingSecret\":\"0123456789abcdef\"") + "," +
|
||||
ChannelJson("ch-2", providerType: 10, url: "https://slack.example/h") + "]",
|
||||
};
|
||||
|
||||
@@ -151,8 +163,10 @@ public class NotificationChannelsTests
|
||||
Assert.Equal("ch-1", redacted[0].Id); // structure visible
|
||||
Assert.Equal(Constants.RedactedSecretPlaceholder, redacted[0].Url); // secrets masked
|
||||
Assert.Equal(Constants.RedactedSecretPlaceholder, redacted[0].CustomHeadersJson);
|
||||
Assert.Equal(Constants.RedactedSecretPlaceholder, redacted[0].SigningSecret);
|
||||
Assert.Equal(Constants.RedactedSecretPlaceholder, redacted[1].Url);
|
||||
Assert.Null(redacted[1].CustomHeadersJson); // unset stays unset
|
||||
Assert.Null(redacted[1].SigningSecret);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -169,7 +183,7 @@ public class NotificationChannelsTests
|
||||
{
|
||||
NotificationChannelsJson = "[" +
|
||||
ChannelJson("ch-1", url: "https://real.example/hook",
|
||||
extra: ",\"customHeadersJson\":\"{\\\"X-Api-Key\\\":\\\"real\\\"}\"") + "]",
|
||||
extra: ",\"customHeadersJson\":\"{\\\"X-Api-Key\\\":\\\"real\\\"}\",\"signingSecret\":\"real-secret-0123\"") + "]",
|
||||
};
|
||||
|
||||
// Round-trip the redacted view back onto a save (defense-in-depth path).
|
||||
@@ -179,6 +193,7 @@ public class NotificationChannelsTests
|
||||
var ch = Assert.Single(NotificationChannel.ParseList(incoming.NotificationChannelsJson));
|
||||
Assert.Equal("https://real.example/hook", ch.Url);
|
||||
Assert.Equal("{\"X-Api-Key\":\"real\"}", ch.CustomHeadersJson);
|
||||
Assert.Equal("real-secret-0123", ch.SigningSecret);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -244,4 +259,40 @@ public class NotificationChannelsTests
|
||||
Assert.NotNull(error);
|
||||
Assert.Contains("headers", error);
|
||||
}
|
||||
|
||||
// ── Signing-secret validation ─────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("0123456789abcdef")] // 16 chars (minimum)
|
||||
public void ValidateWebhookSigningSecret_AcceptsAbsentOrValid(string? secret)
|
||||
{
|
||||
Assert.Null(TenantConfigValidation.ValidateWebhookSigningSecret(secret));
|
||||
// 128 chars (maximum) and a typical generated 64-char hex secret
|
||||
Assert.Null(TenantConfigValidation.ValidateWebhookSigningSecret(new string('a', 128)));
|
||||
Assert.Null(TenantConfigValidation.ValidateWebhookSigningSecret(new string('f', 64)));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("short-15-chars.")] // below minimum
|
||||
[InlineData("has a space in the middle!")] // whitespace
|
||||
[InlineData("line\nbreak-0123456789")] // control char
|
||||
[InlineData("umlauts-äöü-0123456789")] // non-ASCII
|
||||
[InlineData("***REDACTED***")] // unrestored sentinel (too short anyway)
|
||||
public void ValidateWebhookSigningSecret_RejectsInvalid(string secret)
|
||||
{
|
||||
Assert.NotNull(TenantConfigValidation.ValidateWebhookSigningSecret(secret));
|
||||
Assert.NotNull(TenantConfigValidation.ValidateWebhookSigningSecret(new string('a', 129)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateNotificationChannels_RejectsBadPerChannelSigningSecret()
|
||||
{
|
||||
var json = "[" + ChannelJson("ch-1", extra: ",\"signingSecret\":\"too-short\"") + "]";
|
||||
var error = UpdateTenantConfigurationFunction.ValidateNotificationChannels(json);
|
||||
Assert.NotNull(error);
|
||||
Assert.Contains("signing secret", error);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ namespace AutopilotMonitor.Functions.Tests.Offboarding;
|
||||
/// <summary>
|
||||
/// Test double for <see cref="IOffboardFarewellEmailSender"/>. Records every invocation so
|
||||
/// the handler-side tests can assert "did the farewell send fire after History → Completed?"
|
||||
/// without booting the real Resend client. Default = silent noop; set <see cref="ThrowOnSend"/>
|
||||
/// without booting the real mail provider client. Default = silent noop; set <see cref="ThrowOnSend"/>
|
||||
/// to simulate downstream failures and prove the handler's fail-soft contract.
|
||||
/// </summary>
|
||||
internal sealed class FakeOffboardFarewellEmailSender : IOffboardFarewellEmailSender
|
||||
|
||||
+4
-4
@@ -221,7 +221,7 @@ public class TenantOffboardingHandlerOrderingTests
|
||||
public async Task PostDrain_FarewellEmail_Skipped_WhenNotificationEmailNull()
|
||||
{
|
||||
// Tenants that never set a preview-notification email get null capture; the handler
|
||||
// skip-gates the send so no Resend call is even attempted.
|
||||
// skip-gates the send so no provider call is even attempted.
|
||||
var harness = Harness.New();
|
||||
Assert.Null(harness.Repo.History[HistoryRowKey].NotificationEmail);
|
||||
|
||||
@@ -235,7 +235,7 @@ public class TenantOffboardingHandlerOrderingTests
|
||||
public async Task PostDrain_FarewellEmail_Skipped_WhenNotificationEmailWhitespace()
|
||||
{
|
||||
// Defensive: legacy History rows may have a non-null empty/whitespace value. Treat
|
||||
// these as "no email captured" so we never call Resend with a useless string.
|
||||
// these as "no email captured" so we never call the mail provider with a useless string.
|
||||
var harness = Harness.New();
|
||||
harness.Repo.History[HistoryRowKey].NotificationEmail = " ";
|
||||
|
||||
@@ -248,11 +248,11 @@ public class TenantOffboardingHandlerOrderingTests
|
||||
[Fact]
|
||||
public async Task PostDrain_FarewellEmail_SenderThrows_HandlerSurvives_HistoryStillCompleted()
|
||||
{
|
||||
// The send is best-effort. A Resend outage / template bug / serializer crash must
|
||||
// The send is best-effort. A mail-provider outage / template bug / serializer crash must
|
||||
// NOT propagate to the queue worker — that would re-poison a Completed offboard.
|
||||
var harness = Harness.New();
|
||||
harness.Repo.History[HistoryRowKey].NotificationEmail = "ops@contoso.invalid";
|
||||
harness.FarewellEmail.ThrowOnSend = new InvalidOperationException("simulated Resend outage");
|
||||
harness.FarewellEmail.ThrowOnSend = new InvalidOperationException("simulated mail-provider outage");
|
||||
|
||||
await harness.Sut.HandleAsync(harness.Envelope());
|
||||
|
||||
|
||||
@@ -115,6 +115,7 @@ public class OffboardingSessionEnumeratorTests
|
||||
public Task<List<SessionSummary>> GetGeoWindowSessionsAsync(DateTime startDate, DateTime endDate, string? tenantId = null) => throw new NotImplementedException();
|
||||
public Task<List<SessionSummary>> GetStalledSessionsAsync(string tenantId, DateTime cutoffTime) => throw new NotImplementedException();
|
||||
public Task<List<SessionSummary>> GetLegacyTimeoutFailedSessionsAsync(string tenantId, int maxResults) => throw new NotImplementedException();
|
||||
public Task<List<SessionSummary>> GetSelfDeployingSilentSessionsAsync(string tenantId, int maxResults) => throw new NotImplementedException();
|
||||
public Task<List<SessionSummary>> GetSessionsLeanAsync(string tenantId) => throw new NotImplementedException();
|
||||
public Task<List<SessionSummary>> GetAgentSilentSessionsAsync(string tenantId, DateTime silenceCutoff, DateTime hardCutoff) => throw new NotImplementedException();
|
||||
public Task<List<string>> GetAllTenantIdsAsync() => throw new NotImplementedException();
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
using AutopilotMonitor.Functions.Functions.Config;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="PlanManagementFunction.ApplyPlanChanges"/> — the pure mutation core
|
||||
/// of the PATCH plan endpoint. Pins the retention-grace anchor lifecycle: an EFFECTIVE
|
||||
/// Pro→Community transition stamps <see cref="TenantConfiguration.ProDowngradedUtc"/>, any
|
||||
/// effectively-Pro outcome clears it, and a planTier downgrade under an active trial does
|
||||
/// NOT stamp (the edition is still Pro — the later trial expiry is the anchor then).
|
||||
/// </summary>
|
||||
public class PlanManagementTransitionTests
|
||||
{
|
||||
private static readonly DateTime Now = new(2026, 8, 22, 12, 0, 0, DateTimeKind.Utc);
|
||||
private const string Caller = "ga@operator.example";
|
||||
|
||||
private static (TenantEdition Before, TenantEdition After, Dictionary<string, string> Changes) Apply(
|
||||
TenantConfiguration config, string? planTier = null, bool trialProvided = false, DateTime? trialExpiresUtc = null)
|
||||
{
|
||||
var changes = new Dictionary<string, string>();
|
||||
var (before, after) = PlanManagementFunction.ApplyPlanChanges(
|
||||
config, planTier, trialProvided, trialExpiresUtc, Caller, Now, changes);
|
||||
return (before, after, changes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Downgrade_ProToCommunity_StampsAnchorAndRecordsChange()
|
||||
{
|
||||
var config = new TenantConfiguration { TenantId = "t1", PlanTier = "pro" };
|
||||
|
||||
var (before, after, changes) = Apply(config, planTier: "community");
|
||||
|
||||
Assert.Equal(TenantEdition.Pro, before);
|
||||
Assert.Equal(TenantEdition.Community, after);
|
||||
Assert.Equal(Now, config.ProDowngradedUtc);
|
||||
Assert.True(changes.ContainsKey("ProDowngradedUtc"));
|
||||
Assert.True(changes.ContainsKey("PlanTier"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Upgrade_CommunityToPro_ClearsAnchor()
|
||||
{
|
||||
var config = new TenantConfiguration
|
||||
{
|
||||
TenantId = "t1",
|
||||
PlanTier = "community",
|
||||
ProDowngradedUtc = Now.AddDays(-10)
|
||||
};
|
||||
|
||||
var (_, after, changes) = Apply(config, planTier: "pro");
|
||||
|
||||
Assert.Equal(TenantEdition.Pro, after);
|
||||
Assert.Null(config.ProDowngradedUtc);
|
||||
Assert.True(changes.ContainsKey("ProDowngradedUtc"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Downgrade_WithActiveTrial_DoesNotStamp_EditionStaysPro()
|
||||
{
|
||||
// planTier drops but the trial keeps the EFFECTIVE edition Pro: no anchor now —
|
||||
// the trial's own expiry timestamp anchors the grace later, read-time.
|
||||
var config = new TenantConfiguration
|
||||
{
|
||||
TenantId = "t1",
|
||||
PlanTier = "pro",
|
||||
TrialExpiresUtc = Now.AddDays(5)
|
||||
};
|
||||
|
||||
var (before, after, changes) = Apply(config, planTier: "community");
|
||||
|
||||
Assert.Equal(TenantEdition.Pro, before);
|
||||
Assert.Equal(TenantEdition.Pro, after);
|
||||
Assert.Null(config.ProDowngradedUtc);
|
||||
Assert.False(changes.ContainsKey("ProDowngradedUtc"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EndingTrialExplicitly_StampsAnchor()
|
||||
{
|
||||
// GA sets trialExpiresUtc: null on a trial-Pro tenant — TrialExpiresUtc is gone, so
|
||||
// WITHOUT the explicit stamp the grace would have no anchor at all.
|
||||
var config = new TenantConfiguration
|
||||
{
|
||||
TenantId = "t1",
|
||||
PlanTier = "free",
|
||||
TrialExpiresUtc = Now.AddDays(5)
|
||||
};
|
||||
|
||||
var (before, after, _) = Apply(config, trialProvided: true, trialExpiresUtc: null);
|
||||
|
||||
Assert.Equal(TenantEdition.Pro, before);
|
||||
Assert.Equal(TenantEdition.Community, after);
|
||||
Assert.Equal(Now, config.ProDowngradedUtc);
|
||||
Assert.Null(config.TrialExpiresUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GrantingTrial_ToDowngradedTenant_ClearsAnchor()
|
||||
{
|
||||
var config = new TenantConfiguration
|
||||
{
|
||||
TenantId = "t1",
|
||||
PlanTier = "community",
|
||||
ProDowngradedUtc = Now.AddDays(-10)
|
||||
};
|
||||
|
||||
var (_, after, _) = Apply(config, trialProvided: true, trialExpiresUtc: Now.AddDays(30));
|
||||
|
||||
Assert.Equal(TenantEdition.Pro, after);
|
||||
Assert.Null(config.ProDowngradedUtc);
|
||||
Assert.Equal(Caller, config.TrialGrantedBy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NoOpPatch_SameTier_RecordsNothing()
|
||||
{
|
||||
var config = new TenantConfiguration { TenantId = "t1", PlanTier = "community" };
|
||||
|
||||
var (before, after, changes) = Apply(config, planTier: "community");
|
||||
|
||||
Assert.Equal(TenantEdition.Community, before);
|
||||
Assert.Equal(TenantEdition.Community, after);
|
||||
Assert.Empty(changes);
|
||||
Assert.Null(config.ProDowngradedUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RepeatedDowngradeAfterReUpgrade_RestampsWithNewTimestamp()
|
||||
{
|
||||
var config = new TenantConfiguration { TenantId = "t1", PlanTier = "pro" };
|
||||
|
||||
Apply(config, planTier: "community");
|
||||
Assert.Equal(Now, config.ProDowngradedUtc);
|
||||
|
||||
Apply(config, planTier: "pro");
|
||||
Assert.Null(config.ProDowngradedUtc);
|
||||
|
||||
var changes = new Dictionary<string, string>();
|
||||
var later = Now.AddDays(40);
|
||||
PlanManagementFunction.ApplyPlanChanges(config, "community", false, null, Caller, later, changes);
|
||||
Assert.Equal(later, config.ProDowngradedUtc);
|
||||
}
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Coverage for the post-offboarding farewell-email send path. The actual Resend HTTP
|
||||
/// call is not exercised here — every test drives one of the no-op gates. Pins:
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="ResendEmailService.SendAsync"/> never throws — not when the API key
|
||||
/// is empty and not when the recipient is missing. The handler's fail-soft
|
||||
/// try/catch is a belt; this is the suspenders.</item>
|
||||
/// <item>Gate ordering: missing-key short-circuits before missing-recipient.</item>
|
||||
/// <item>The farewell template is final: no [DRAFT]/placeholder residue, domain is
|
||||
/// interpolated, empty domain falls back to "your organization".</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public sealed class ResendEmailServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task SendAsync_EmptyApiKey_NoOps_AndLogsMissingKeyDebugLine()
|
||||
{
|
||||
var logger = new CapturingLogger<ResendEmailService>();
|
||||
var sut = Build(apiKey: "", logger);
|
||||
|
||||
await sut.SendAsync(
|
||||
toEmail: "ops@contoso.invalid",
|
||||
domainName: "contoso.invalid",
|
||||
tenantId: "88888888-8888-8888-8888-888888888888",
|
||||
ct: CancellationToken.None);
|
||||
|
||||
Assert.Contains(logger.Entries,
|
||||
e => e.Level == LogLevel.Debug && e.Message.Contains("RESEND_API_KEY not configured"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_EmptyRecipient_NoOps_AndLogsMissingEmailDebugLine()
|
||||
{
|
||||
var logger = new CapturingLogger<ResendEmailService>();
|
||||
var sut = Build(apiKey: "any-non-empty-key", logger);
|
||||
|
||||
await sut.SendAsync(
|
||||
toEmail: "",
|
||||
domainName: "contoso.invalid",
|
||||
tenantId: "88888888-8888-8888-8888-888888888888");
|
||||
|
||||
Assert.Contains(logger.Entries,
|
||||
e => e.Level == LogLevel.Debug && e.Message.Contains("No notification email captured"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_EmptyApiKey_ShortCircuitsBeforeRecipientCheck()
|
||||
{
|
||||
// Pins the gate ordering: missing-key > missing-email. Both are empty here; only
|
||||
// the key line must show up.
|
||||
var logger = new CapturingLogger<ResendEmailService>();
|
||||
var sut = Build(apiKey: "", logger);
|
||||
|
||||
await sut.SendAsync(
|
||||
toEmail: "",
|
||||
domainName: "contoso.invalid",
|
||||
tenantId: "88888888-8888-8888-8888-888888888888");
|
||||
|
||||
Assert.Contains(logger.Entries,
|
||||
e => e.Level == LogLevel.Debug && e.Message.Contains("RESEND_API_KEY not configured"));
|
||||
Assert.DoesNotContain(logger.Entries,
|
||||
e => e.Message.Contains("No notification email captured"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FarewellTemplate_IsFinal_NoDraftResidue()
|
||||
{
|
||||
Assert.DoesNotContain("DRAFT", EmailTemplates.OffboardingFarewellSubject);
|
||||
|
||||
var html = EmailTemplates.GetOffboardingFarewellHtml("contoso.invalid");
|
||||
Assert.DoesNotContain("DRAFT", html);
|
||||
Assert.DoesNotContain("TEMPLATE NOT FINALISED", html);
|
||||
Assert.DoesNotContain("TODO", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FarewellTemplate_InterpolatesDomain_AndLinksFeedbackChannels()
|
||||
{
|
||||
var html = EmailTemplates.GetOffboardingFarewellHtml("contoso.invalid");
|
||||
|
||||
Assert.Contains("contoso.invalid", html);
|
||||
// The mail is sent from a noreply address after portal access is gone, so the
|
||||
// feedback pointers must be external channels the recipient can actually reach.
|
||||
Assert.Contains("https://github.com/okieselbach/AutopilotMonitor/issues", html);
|
||||
Assert.Contains("linkedin.com", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FarewellTemplate_EmptyDomain_FallsBackToGenericLabel()
|
||||
{
|
||||
var html = EmailTemplates.GetOffboardingFarewellHtml("");
|
||||
Assert.Contains("your organization", html);
|
||||
}
|
||||
|
||||
private static ResendEmailService Build(string apiKey, ILogger<ResendEmailService> logger)
|
||||
{
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["RESEND_API_KEY"] = apiKey,
|
||||
})
|
||||
.Build();
|
||||
return new ResendEmailService(config, logger);
|
||||
}
|
||||
|
||||
private sealed class CapturingLogger<T> : ILogger<T>
|
||||
{
|
||||
public readonly List<(LogLevel Level, string Message)> Entries = new();
|
||||
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
public void Log<TState>(
|
||||
LogLevel logLevel, EventId eventId, TState state, System.Exception? exception,
|
||||
System.Func<TState, System.Exception?, string> formatter)
|
||||
{
|
||||
Entries.Add((logLevel, formatter(state, exception)));
|
||||
}
|
||||
|
||||
private sealed class NullScope : IDisposable
|
||||
{
|
||||
public static readonly NullScope Instance = new();
|
||||
public void Dispose() { }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -331,8 +331,9 @@ public class SessionRetentionFanoutServiceTests
|
||||
var baseTime = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
var deadline = baseTime.AddMinutes(50);
|
||||
var clockCalls = 0;
|
||||
// Call sequence: tenant-A boundary (1), session s0 (2), session s1 (3), session s2 (4 → past deadline).
|
||||
var harness = new Harness(utcNow: () => ++clockCalls >= 4 ? deadline.AddMinutes(1) : baseTime);
|
||||
// Call sequence: tenant-A boundary (1), retention resolution (2), session s0 (3),
|
||||
// session s1 (4), session s2 (5 → past deadline).
|
||||
var harness = new Harness(utcNow: () => ++clockCalls >= 5 ? deadline.AddMinutes(1) : baseTime);
|
||||
|
||||
var sessions = new List<SessionSummary>();
|
||||
for (int i = 0; i < 4; i++) sessions.Add(Summary(TenantA, $"s{i}"));
|
||||
@@ -388,6 +389,38 @@ public class SessionRetentionFanoutServiceTests
|
||||
};
|
||||
}
|
||||
|
||||
// =========================================== Retention downgrade grace ====
|
||||
|
||||
[Fact]
|
||||
public async Task Tenant_InRetentionGrace_KeepsProCutoff()
|
||||
{
|
||||
// Downgraded 10 days ago, stored retention 365: the grace keeps the Pro cap, so the
|
||||
// cutoff must be now-365d — NOT now-90d (which would enqueue the 90–365d band).
|
||||
var now = new DateTime(2026, 8, 22, 12, 0, 0, DateTimeKind.Utc);
|
||||
var harness = new Harness(utcNow: () => now);
|
||||
harness.WithTenant("t1", retentionDays: 365, sessions: Array.Empty<SessionSummary>(),
|
||||
planTier: "community", proDowngradedUtc: now.AddDays(-10));
|
||||
|
||||
await harness.RunAsync();
|
||||
|
||||
harness.MaintenanceRepo.Verify(m => m.GetSessionsOlderThanAsync(
|
||||
"t1", now.AddDays(-365), It.IsAny<int>(), It.IsAny<bool>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tenant_AfterRetentionGrace_CommunityCutoffApplies()
|
||||
{
|
||||
var now = new DateTime(2026, 8, 22, 12, 0, 0, DateTimeKind.Utc);
|
||||
var harness = new Harness(utcNow: () => now);
|
||||
harness.WithTenant("t1", retentionDays: 365, sessions: Array.Empty<SessionSummary>(),
|
||||
planTier: "community", proDowngradedUtc: now.AddDays(-31));
|
||||
|
||||
await harness.RunAsync();
|
||||
|
||||
harness.MaintenanceRepo.Verify(m => m.GetSessionsOlderThanAsync(
|
||||
"t1", now.AddDays(-90), It.IsAny<int>(), It.IsAny<bool>()), Times.Once);
|
||||
}
|
||||
|
||||
// ============================================================ Harness ====
|
||||
|
||||
private sealed class Harness
|
||||
@@ -450,11 +483,18 @@ public class SessionRetentionFanoutServiceTests
|
||||
return result;
|
||||
}
|
||||
|
||||
public void WithTenant(string tenantId, int retentionDays, SessionSummary[] sessions, string planTier = "free")
|
||||
public void WithTenant(string tenantId, int retentionDays, SessionSummary[] sessions, string planTier = "free",
|
||||
DateTime? proDowngradedUtc = null)
|
||||
{
|
||||
_tenantIds.Add(tenantId);
|
||||
TenantConfig.Setup(t => t.GetConfigurationAsync(tenantId))
|
||||
.ReturnsAsync(new TenantConfiguration { TenantId = tenantId, DataRetentionDays = retentionDays, PlanTier = planTier });
|
||||
.ReturnsAsync(new TenantConfiguration
|
||||
{
|
||||
TenantId = tenantId,
|
||||
DataRetentionDays = retentionDays,
|
||||
PlanTier = planTier,
|
||||
ProDowngradedUtc = proDowngradedUtc,
|
||||
});
|
||||
MaintenanceRepo.Setup(m => m.GetSessionsOlderThanAsync(tenantId, It.IsAny<DateTime>(), It.IsAny<int>(), It.IsAny<bool>()))
|
||||
.ReturnsAsync(new List<SessionSummary>(WithTenantId(tenantId, sessions)));
|
||||
}
|
||||
|
||||
+50
-4
@@ -101,6 +101,52 @@ public class SessionStartedAtBackwardShiftGuardTests
|
||||
Assert.Equal((int)(lastEvent - earliest).TotalSeconds, harness.Written!.GetInt32("DurationSeconds"));
|
||||
}
|
||||
|
||||
// =========================== system-timeline rows are not session anchors ====
|
||||
|
||||
[Fact]
|
||||
public void IsSessionAnchorEligible_excludes_system_timeline_watcher_rows()
|
||||
{
|
||||
Assert.False(TableStorageService.IsSessionAnchorEligible(
|
||||
new TableEntity("p", "r") { ["Source"] = "SystemTimelineWatcher" }));
|
||||
Assert.True(TableStorageService.IsSessionAnchorEligible(
|
||||
new TableEntity("p", "r") { ["Source"] = "ImeLogTracker" }));
|
||||
Assert.True(TableStorageService.IsSessionAnchorEligible(
|
||||
new TableEntity("p", "r"))); // legacy row without Source projection
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Pending_duration_skips_backfilled_system_timeline_row_and_anchors_on_activity()
|
||||
{
|
||||
// Field case d0c5b672: a pre-agent system_clock_changed event (backfilled from the
|
||||
// Windows event log, original timestamp ~14min before the first real activity) was
|
||||
// the partition's earliest row and re-anchored StartedAt. The probe must skip
|
||||
// SystemTimelineWatcher rows and anchor on the first activity event instead —
|
||||
// even when the watcher row sits well inside the 2h plausibility window.
|
||||
var clockEventAt = StartedAt.AddMinutes(-90);
|
||||
var firstActivity = StartedAt.AddMinutes(-5);
|
||||
var lastEvent = StartedAt.AddMinutes(70);
|
||||
var harness = new Harness(SessionRow(), eventRows: new[]
|
||||
{
|
||||
EventRow(clockEventAt, sequence: 18, source: "SystemTimelineWatcher"),
|
||||
EventRow(firstActivity, sequence: 2, source: "ImeLogTracker"),
|
||||
});
|
||||
|
||||
var ok = await harness.Sut.UpdateSessionStatusAsync(
|
||||
TenantId, SessionId, SessionStatus.Pending,
|
||||
latestEventTimestamp: lastEvent);
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.NotNull(harness.Written);
|
||||
Assert.Equal((int)(lastEvent - firstActivity).TotalSeconds, harness.Written!.GetInt32("DurationSeconds"));
|
||||
}
|
||||
|
||||
private static TableEntity EventRow(DateTime occurredUtc, int sequence, string source) =>
|
||||
new($"{TenantId}_{SessionId}", $"{occurredUtc:yyyyMMddHHmmssfff}_{sequence:D10}")
|
||||
{
|
||||
["OccurredUtc"] = new DateTimeOffset(occurredUtc),
|
||||
["Source"] = source,
|
||||
};
|
||||
|
||||
// ============================================================ Harness ====
|
||||
|
||||
private static TableEntity SessionRow()
|
||||
@@ -126,7 +172,7 @@ public class SessionStartedAtBackwardShiftGuardTests
|
||||
public TableStorageService Sut { get; }
|
||||
public TableEntity? Written { get; private set; }
|
||||
|
||||
public Harness(TableEntity existing, DateTime? earliestStoredEvent = null)
|
||||
public Harness(TableEntity existing, DateTime? earliestStoredEvent = null, TableEntity[]? eventRows = null)
|
||||
{
|
||||
var sessions = new Mock<TableClient>();
|
||||
|
||||
@@ -150,7 +196,7 @@ public class SessionStartedAtBackwardShiftGuardTests
|
||||
});
|
||||
|
||||
var events = new Mock<TableClient>();
|
||||
var eventRows = earliestStoredEvent.HasValue
|
||||
var servedRows = eventRows ?? (earliestStoredEvent.HasValue
|
||||
? new[]
|
||||
{
|
||||
new TableEntity($"{TenantId}_{SessionId}", $"{earliestStoredEvent.Value:yyyyMMddHHmmssfff}_0000000001")
|
||||
@@ -158,12 +204,12 @@ public class SessionStartedAtBackwardShiftGuardTests
|
||||
["OccurredUtc"] = new DateTimeOffset(earliestStoredEvent.Value),
|
||||
},
|
||||
}
|
||||
: Array.Empty<TableEntity>();
|
||||
: Array.Empty<TableEntity>());
|
||||
events.Setup(t => t.QueryAsync<TableEntity>(
|
||||
It.IsAny<string>(), It.IsAny<int?>(), It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(AsyncPageable<TableEntity>.FromPages(new[]
|
||||
{
|
||||
Page<TableEntity>.FromValues(eventRows, null, new Mock<Response>().Object),
|
||||
Page<TableEntity>.FromValues(servedRows, null, new Mock<Response>().Object),
|
||||
}));
|
||||
|
||||
var serviceClient = new Mock<TableServiceClient>();
|
||||
|
||||
@@ -21,7 +21,7 @@ public class TenantApprovalServiceTests
|
||||
|
||||
private readonly Mock<PreviewWhitelistService> _previewMock;
|
||||
private readonly Mock<TenantConfigurationService> _tenantConfigMock;
|
||||
private readonly Mock<ResendEmailService> _resendMock;
|
||||
private readonly Mock<IEmailService> _emailMock;
|
||||
private readonly TenantApprovalService _sut;
|
||||
|
||||
public TenantApprovalServiceTests()
|
||||
@@ -37,9 +37,10 @@ public class TenantApprovalServiceTests
|
||||
configRepo, cache, NullLogger<PreviewWhitelistService>.Instance, _tenantConfigMock.Object)
|
||||
{ CallBase = false };
|
||||
|
||||
_resendMock = new Mock<ResendEmailService>(
|
||||
Mock.Of<Microsoft.Extensions.Configuration.IConfiguration>(),
|
||||
NullLogger<ResendEmailService>.Instance);
|
||||
_emailMock = new Mock<IEmailService>();
|
||||
_emailMock.Setup(x => x.SendPreviewApprovedEmailAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
_sut = new TenantApprovalService(
|
||||
NullLogger<TenantApprovalService>.Instance,
|
||||
@@ -47,7 +48,7 @@ public class TenantApprovalServiceTests
|
||||
_tenantConfigMock.Object,
|
||||
new Mock<TenantAdminsService>(
|
||||
Mock.Of<IAdminRepository>(), cache, NullLogger<TenantAdminsService>.Instance).Object,
|
||||
_resendMock.Object);
|
||||
_emailMock.Object);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -94,7 +95,7 @@ public class TenantApprovalServiceTests
|
||||
|
||||
Assert.False(sent);
|
||||
_previewMock.Verify(x => x.TryMarkWelcomeEmailSentAsync(It.IsAny<string>()), Times.Never);
|
||||
_resendMock.Verify(x => x.SendPreviewApprovedEmailAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
_emailMock.Verify(x => x.SendPreviewApprovedEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -109,7 +110,7 @@ public class TenantApprovalServiceTests
|
||||
var sent = await _sut.TrySendWelcomeEmailAsync(TenantId);
|
||||
|
||||
Assert.True(sent);
|
||||
_resendMock.Verify(x => x.SendPreviewApprovedEmailAsync("it@contoso.com", "contoso.com"), Times.Once);
|
||||
_emailMock.Verify(x => x.SendPreviewApprovedEmailAsync("it@contoso.com", "contoso.com", It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -121,7 +122,7 @@ public class TenantApprovalServiceTests
|
||||
var sent = await _sut.TrySendWelcomeEmailAsync(TenantId);
|
||||
|
||||
Assert.False(sent);
|
||||
_resendMock.Verify(x => x.SendPreviewApprovedEmailAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
_emailMock.Verify(x => x.SendPreviewApprovedEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -134,7 +135,7 @@ public class TenantApprovalServiceTests
|
||||
var sent = await _sut.TrySendWelcomeEmailAsync(TenantId);
|
||||
|
||||
Assert.False(sent);
|
||||
_resendMock.Verify(x => x.SendPreviewApprovedEmailAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
_emailMock.Verify(x => x.SendPreviewApprovedEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -151,6 +152,6 @@ public class TenantApprovalServiceTests
|
||||
var newlyApproved = await _sut.ApproveWithSideEffectsAsync(TenantId, ApprovedBy);
|
||||
|
||||
Assert.True(newlyApproved);
|
||||
_resendMock.Verify(x => x.SendPreviewApprovedEmailAsync("it@contoso.com", It.IsAny<string>()), Times.Once);
|
||||
_emailMock.Verify(x => x.SendPreviewApprovedEmailAsync("it@contoso.com", It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,9 +50,7 @@ public class TenantAutoApproveHandlerTests
|
||||
_tenantConfigMock.Object,
|
||||
new Mock<TenantAdminsService>(
|
||||
Mock.Of<IAdminRepository>(), cache, NullLogger<TenantAdminsService>.Instance).Object,
|
||||
new Mock<ResendEmailService>(
|
||||
Mock.Of<Microsoft.Extensions.Configuration.IConfiguration>(),
|
||||
NullLogger<ResendEmailService>.Instance).Object)
|
||||
Mock.Of<IEmailService>())
|
||||
{ CallBase = false };
|
||||
|
||||
var opsRepo = new Mock<IOpsEventRepository>();
|
||||
|
||||
@@ -20,6 +20,7 @@ public class TenantConfigTableSerializationTests
|
||||
{
|
||||
var expires = new DateTime(2026, 8, 6, 10, 30, 0, DateTimeKind.Utc);
|
||||
var started = new DateTime(2026, 7, 7, 10, 30, 0, DateTimeKind.Utc);
|
||||
var downgraded = new DateTime(2026, 8, 10, 8, 0, 0, DateTimeKind.Utc);
|
||||
var config = new TenantConfiguration
|
||||
{
|
||||
TenantId = TenantId,
|
||||
@@ -29,7 +30,8 @@ public class TenantConfigTableSerializationTests
|
||||
TrialExpiresUtc = expires,
|
||||
TrialStartedUtc = started,
|
||||
TrialConsumed = true,
|
||||
TrialGrantedBy = "alice@contoso.com"
|
||||
TrialGrantedBy = "alice@contoso.com",
|
||||
ProDowngradedUtc = downgraded
|
||||
};
|
||||
|
||||
var entity = TableConfigRepository.ConvertToTenantTableEntity(config);
|
||||
@@ -40,6 +42,7 @@ public class TenantConfigTableSerializationTests
|
||||
Assert.Equal(started, mapped.TrialStartedUtc);
|
||||
Assert.True(mapped.TrialConsumed);
|
||||
Assert.Equal("alice@contoso.com", mapped.TrialGrantedBy);
|
||||
Assert.Equal(downgraded, mapped.ProDowngradedUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -59,6 +62,7 @@ public class TenantConfigTableSerializationTests
|
||||
Assert.Null(mapped.TrialStartedUtc);
|
||||
Assert.False(mapped.TrialConsumed);
|
||||
Assert.Null(mapped.TrialGrantedBy);
|
||||
Assert.Null(mapped.ProDowngradedUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -77,6 +81,7 @@ public class TenantConfigTableSerializationTests
|
||||
Assert.Null(mapped.TrialExpiresUtc);
|
||||
Assert.False(mapped.TrialConsumed);
|
||||
Assert.Null(mapped.TrialGrantedBy);
|
||||
Assert.Null(mapped.ProDowngradedUtc); // pre-grace row: no anchor, no grace
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -194,8 +194,10 @@ public class TenantEntitlementServiceTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetEffectiveRetentionDays_TrialExpiry_DegradesCapFrom365To90()
|
||||
public void GetEffectiveRetentionDays_TrialExpiry_ProCapHoldsThroughGrace_ThenDegradesTo90()
|
||||
{
|
||||
// Trial expiry is a downgrade: the Pro cap keeps applying for the 30-day retention
|
||||
// grace (anchor = TrialExpiresUtc, no write involved), THEN the Community cap bites.
|
||||
var config = new TenantConfiguration
|
||||
{
|
||||
TenantId = TenantId,
|
||||
@@ -204,6 +206,93 @@ public class TenantEntitlementServiceTests
|
||||
TrialExpiresUtc = Now.AddDays(1)
|
||||
};
|
||||
Assert.Equal(365, TenantEntitlementService.GetEffectiveRetentionDays(config, Now));
|
||||
Assert.Equal(90, TenantEntitlementService.GetEffectiveRetentionDays(config, Now.AddDays(2)));
|
||||
// 1 day after expiry: inside the grace — still the Pro cap.
|
||||
Assert.Equal(365, TenantEntitlementService.GetEffectiveRetentionDays(config, Now.AddDays(2)));
|
||||
// 31 days after expiry: grace over — Community cap enforced.
|
||||
Assert.Equal(90, TenantEntitlementService.GetEffectiveRetentionDays(config, Now.AddDays(32)));
|
||||
}
|
||||
|
||||
// ── Retention downgrade grace (GetRetentionGraceEndUtc + cap interplay) ──────
|
||||
|
||||
[Fact]
|
||||
public void GetRetentionGraceEndUtc_ProTenant_ReturnsNull()
|
||||
{
|
||||
var config = new TenantConfiguration
|
||||
{
|
||||
TenantId = TenantId,
|
||||
PlanTier = "pro",
|
||||
ProDowngradedUtc = Now.AddDays(-5) // stale leftover must not matter while Pro
|
||||
};
|
||||
Assert.Null(TenantEntitlementService.GetRetentionGraceEndUtc(config, Now));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetRetentionGraceEndUtc_NeverLostPro_ReturnsNull()
|
||||
{
|
||||
var config = new TenantConfiguration { TenantId = TenantId, PlanTier = "free" };
|
||||
Assert.Null(TenantEntitlementService.GetRetentionGraceEndUtc(config, Now));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetRetentionGraceEndUtc_ExplicitDowngrade_AnchorPlusGraceDays()
|
||||
{
|
||||
var downgraded = Now.AddDays(-10);
|
||||
var config = new TenantConfiguration
|
||||
{
|
||||
TenantId = TenantId,
|
||||
PlanTier = "community",
|
||||
ProDowngradedUtc = downgraded
|
||||
};
|
||||
Assert.Equal(
|
||||
downgraded.AddDays(FeatureEntitlementCatalog.RetentionDowngradeGraceDays),
|
||||
TenantEntitlementService.GetRetentionGraceEndUtc(config, Now));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetRetentionGraceEndUtc_LatestAnchorWins()
|
||||
{
|
||||
// Downgrade, re-granted trial, trial expired again: the LATER trial expiry anchors.
|
||||
var config = new TenantConfiguration
|
||||
{
|
||||
TenantId = TenantId,
|
||||
PlanTier = "community",
|
||||
ProDowngradedUtc = Now.AddDays(-20),
|
||||
TrialExpiresUtc = Now.AddDays(-3)
|
||||
};
|
||||
Assert.Equal(
|
||||
Now.AddDays(-3).AddDays(FeatureEntitlementCatalog.RetentionDowngradeGraceDays),
|
||||
TenantEntitlementService.GetRetentionGraceEndUtc(config, Now));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetEffectiveRetentionDays_ExplicitDowngrade_ProCapDuringGrace_CommunityCapAfter()
|
||||
{
|
||||
var config = new TenantConfiguration
|
||||
{
|
||||
TenantId = TenantId,
|
||||
PlanTier = "community",
|
||||
DataRetentionDays = 365,
|
||||
ProDowngradedUtc = Now
|
||||
};
|
||||
Assert.Equal(365, TenantEntitlementService.GetEffectiveRetentionDays(config, Now.AddDays(29)));
|
||||
Assert.Equal(90, TenantEntitlementService.GetEffectiveRetentionDays(config, Now.AddDays(31)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetEffectiveRetentionDays_GraceDoesNotRaiseAnythingElse()
|
||||
{
|
||||
// 0 (infinite) passes through untouched, and a stored value below the Community cap
|
||||
// is unaffected — the grace only swaps which CAP clamps.
|
||||
var zero = new TenantConfiguration
|
||||
{
|
||||
TenantId = TenantId, PlanTier = "community", DataRetentionDays = 0, ProDowngradedUtc = Now
|
||||
};
|
||||
Assert.Equal(0, TenantEntitlementService.GetEffectiveRetentionDays(zero, Now.AddDays(5)));
|
||||
|
||||
var below = new TenantConfiguration
|
||||
{
|
||||
TenantId = TenantId, PlanTier = "community", DataRetentionDays = 60, ProDowngradedUtc = Now
|
||||
};
|
||||
Assert.Equal(60, TenantEntitlementService.GetEffectiveRetentionDays(below, Now.AddDays(5)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,8 @@ public class TrialExpirySweepFunctionTests
|
||||
private static readonly DateTime Now = new(2026, 7, 7, 3, 30, 0, DateTimeKind.Utc);
|
||||
|
||||
private static TenantConfiguration Tenant(
|
||||
string id, DateTime? trialExpiresUtc, string planTier = "free", string domain = "contoso.com") => new()
|
||||
string id, DateTime? trialExpiresUtc, string planTier = "free", string domain = "contoso.com",
|
||||
int retentionDays = 90, DateTime? proDowngradedUtc = null) => new()
|
||||
{
|
||||
TenantId = id,
|
||||
DomainName = domain,
|
||||
@@ -28,6 +29,8 @@ public class TrialExpirySweepFunctionTests
|
||||
PlanTier = planTier,
|
||||
TrialExpiresUtc = trialExpiresUtc,
|
||||
TrialConsumed = trialExpiresUtc.HasValue,
|
||||
DataRetentionDays = retentionDays,
|
||||
ProDowngradedUtc = proDowngradedUtc,
|
||||
};
|
||||
|
||||
private static (TrialExpirySweepFunction Sut, List<OpsEventEntry> Events) Build(params TenantConfiguration[] configs)
|
||||
@@ -150,6 +153,88 @@ public class TrialExpirySweepFunctionTests
|
||||
Assert.Equal(2, events.Count);
|
||||
}
|
||||
|
||||
// ── Retention downgrade grace events ─────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task GraceEndingSoon_WithDataAtRisk_EmitsGraceExpiring()
|
||||
{
|
||||
// Downgraded 28 days ago with stored retention 365: grace ends in 2 days.
|
||||
var (sut, events) = Build(Tenant("t1", null, planTier: "community",
|
||||
retentionDays: 365, proDowngradedUtc: Now.AddDays(-28)));
|
||||
|
||||
var result = await sut.RunCoreAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, result.GraceExpiringEmitted);
|
||||
var e = Assert.Single(events);
|
||||
Assert.Equal("TenantRetentionGraceExpiring", e.EventType);
|
||||
Assert.Equal(OpsEventSeverity.Warning, e.Severity);
|
||||
Assert.Contains("2 day", e.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GraceEndedWithinLookBack_EmitsGraceEnded_OlderStaysSilent()
|
||||
{
|
||||
var (sut, events) = Build(
|
||||
// Grace ended 12h ago → emit once.
|
||||
Tenant("just-ended", null, planTier: "community",
|
||||
retentionDays: 365, proDowngradedUtc: Now.AddDays(-30).AddHours(-12)),
|
||||
// Grace ended 3 days ago → reported by a previous run, silent.
|
||||
Tenant("long-ended", null, planTier: "community",
|
||||
retentionDays: 365, proDowngradedUtc: Now.AddDays(-33)));
|
||||
|
||||
var result = await sut.RunCoreAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, result.GraceEndedEmitted);
|
||||
var e = Assert.Single(events);
|
||||
Assert.Equal("TenantRetentionGraceEnded", e.EventType);
|
||||
Assert.Equal("just-ended", e.TenantId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Grace_NoDataAtRisk_StaysSilent()
|
||||
{
|
||||
var (sut, events) = Build(
|
||||
// Stored retention at the Community cap — nothing to lose.
|
||||
Tenant("at-cap", null, planTier: "community",
|
||||
retentionDays: 90, proDowngradedUtc: Now.AddDays(-29)),
|
||||
// 0 = infinite escape hatch — the fanout never deletes, no warning needed.
|
||||
Tenant("infinite", null, planTier: "community",
|
||||
retentionDays: 0, proDowngradedUtc: Now.AddDays(-29)));
|
||||
|
||||
var result = await sut.RunCoreAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(0, result.GraceExpiringEmitted);
|
||||
Assert.Equal(0, result.GraceEndedEmitted);
|
||||
Assert.Empty(events);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Grace_ReUpgradedTenant_StaysSilent()
|
||||
{
|
||||
// Back on Pro: GetRetentionGraceEndUtc returns null even with a stale anchor.
|
||||
var (sut, events) = Build(Tenant("t1", null, planTier: "pro",
|
||||
retentionDays: 365, proDowngradedUtc: Now.AddDays(-29)));
|
||||
|
||||
var result = await sut.RunCoreAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(0, result.GraceExpiringEmitted);
|
||||
Assert.Empty(events);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExpiredTrial_WithDataAtRisk_GetsBothTrialExpiredAndLaterGraceEvents()
|
||||
{
|
||||
// The trial-expiry anchor drives the grace window too: expired 2h ago fires
|
||||
// TenantTrialExpired now; 28 days later the same tenant enters the grace heads-up.
|
||||
var (sut, events) = Build(Tenant("t1", Now.AddHours(-2), retentionDays: 365));
|
||||
|
||||
var result = await sut.RunCoreAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, result.ExpiredEmitted);
|
||||
Assert.Equal(0, result.GraceExpiringEmitted); // grace end is ~30d out — beyond heads-up
|
||||
Assert.Single(events);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConfigLoadFailure_ReturnsEmptyResult_NeverThrows()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
using AutopilotMonitor.Functions.Services.Notifications;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// HMAC request-signature scheme for generic JSON webhooks: known-vector stability (the
|
||||
/// scheme is a published contract receivers implement against), determinism, and
|
||||
/// secret/message sensitivity.
|
||||
/// </summary>
|
||||
public class WebhookSignatureTests
|
||||
{
|
||||
[Fact]
|
||||
public void ComputeSignature_MatchesKnownVector()
|
||||
{
|
||||
// Independently computed: HMACSHA256("whsec_test_0123456789abcdef", "1700000000.{"a":1}")
|
||||
var signature = WebhookSignatureCalculator.ComputeSignature(
|
||||
"whsec_test_0123456789abcdef", "1700000000", "{\"a\":1}");
|
||||
|
||||
Assert.Equal("sha256=07728d7e7b1736bac3de36a46c9bc0b660d59aa4a6f251544124fede1dc42a6c", signature);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeSignature_IsDeterministic_AndSensitiveToEveryInput()
|
||||
{
|
||||
var baseline = WebhookSignatureCalculator.ComputeSignature("secret-0123456789", "1700000000", "{}");
|
||||
|
||||
Assert.Equal(baseline, WebhookSignatureCalculator.ComputeSignature("secret-0123456789", "1700000000", "{}"));
|
||||
Assert.NotEqual(baseline, WebhookSignatureCalculator.ComputeSignature("secret-0123456789X", "1700000000", "{}"));
|
||||
Assert.NotEqual(baseline, WebhookSignatureCalculator.ComputeSignature("secret-0123456789", "1700000001", "{}"));
|
||||
Assert.NotEqual(baseline, WebhookSignatureCalculator.ComputeSignature("secret-0123456789", "1700000000", "{ }"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HeaderNames_AreTheDocumentedContract()
|
||||
{
|
||||
Assert.Equal("X-AutopilotMonitor-Timestamp", WebhookSignatureCalculator.TimestampHeader);
|
||||
Assert.Equal("X-AutopilotMonitor-Signature", WebhookSignatureCalculator.SignatureHeader);
|
||||
}
|
||||
}
|
||||
@@ -56,7 +56,6 @@
|
||||
<PackageReference Include="Microsoft.Identity.Web" Version="4.3.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.10" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="8.0.25" />
|
||||
<PackageReference Include="Resend" Version="0.2.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
+50
@@ -494,6 +494,54 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
}
|
||||
}
|
||||
|
||||
// --- Email Template Overrides ---
|
||||
|
||||
private const string EmailTemplatesPartition = "EmailTemplates";
|
||||
|
||||
public async Task<EmailTemplateOverride?> GetEmailTemplateOverrideAsync(string kind)
|
||||
{
|
||||
try
|
||||
{
|
||||
var entity = await _previewConfigTableClient.GetEntityAsync<TableEntity>(EmailTemplatesPartition, kind);
|
||||
var html = entity.Value.GetString("Html");
|
||||
if (string.IsNullOrEmpty(html)) return null;
|
||||
return new EmailTemplateOverride
|
||||
{
|
||||
Kind = kind,
|
||||
Html = html,
|
||||
UpdatedBy = entity.Value.GetString("UpdatedBy") ?? string.Empty,
|
||||
UpdatedUtc = entity.Value.GetDateTime("UpdatedUtc") ?? DateTime.MinValue,
|
||||
};
|
||||
}
|
||||
catch (RequestFailedException ex) when (ex.Status == 404)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SaveEmailTemplateOverrideAsync(EmailTemplateOverride overrideEntry)
|
||||
{
|
||||
var entity = new TableEntity(EmailTemplatesPartition, overrideEntry.Kind)
|
||||
{
|
||||
["Html"] = overrideEntry.Html,
|
||||
["UpdatedBy"] = overrideEntry.UpdatedBy,
|
||||
["UpdatedUtc"] = overrideEntry.UpdatedUtc == default ? DateTime.UtcNow : overrideEntry.UpdatedUtc,
|
||||
};
|
||||
await _previewConfigTableClient.UpsertEntityAsync(entity, TableUpdateMode.Replace);
|
||||
}
|
||||
|
||||
public async Task DeleteEmailTemplateOverrideAsync(string kind)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _previewConfigTableClient.DeleteEntityAsync(EmailTemplatesPartition, kind);
|
||||
}
|
||||
catch (RequestFailedException ex) when (ex.Status == 404)
|
||||
{
|
||||
// already gone — reset is idempotent
|
||||
}
|
||||
}
|
||||
|
||||
// --- Preview Notification Email ---
|
||||
|
||||
public async Task<string?> GetNotificationEmailAsync(string tenantId)
|
||||
@@ -671,6 +719,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
{ "TrialStartedUtc", config.TrialStartedUtc },
|
||||
{ "TrialConsumed", config.TrialConsumed },
|
||||
{ "TrialGrantedBy", config.TrialGrantedBy },
|
||||
{ "ProDowngradedUtc", config.ProDowngradedUtc },
|
||||
// SLA targets
|
||||
{ "SlaTargetSuccessRate", config.SlaTargetSuccessRate.HasValue ? (double)config.SlaTargetSuccessRate.Value : (double?)null },
|
||||
{ "SlaTargetMaxDurationMinutes", config.SlaTargetMaxDurationMinutes },
|
||||
@@ -783,6 +832,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
TrialStartedUtc = entity.GetDateTime("TrialStartedUtc"),
|
||||
TrialConsumed = entity.GetBoolean("TrialConsumed") ?? false,
|
||||
TrialGrantedBy = entity.GetString("TrialGrantedBy"),
|
||||
ProDowngradedUtc = entity.GetDateTime("ProDowngradedUtc"),
|
||||
// SLA targets
|
||||
SlaTargetSuccessRate = entity.GetDouble("SlaTargetSuccessRate") != null ? (decimal)entity.GetDouble("SlaTargetSuccessRate")! : null,
|
||||
SlaTargetMaxDurationMinutes = entity.GetInt32("SlaTargetMaxDurationMinutes"),
|
||||
|
||||
+3
@@ -72,6 +72,9 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
public Task<List<SessionSummary>> GetLegacyTimeoutFailedSessionsAsync(string tenantId, int maxResults)
|
||||
=> _storage.GetLegacyTimeoutFailedSessionsAsync(tenantId, maxResults);
|
||||
|
||||
public Task<List<SessionSummary>> GetSelfDeployingSilentSessionsAsync(string tenantId, int maxResults)
|
||||
=> _storage.GetSelfDeployingSilentSessionsAsync(tenantId, maxResults);
|
||||
|
||||
public Task<List<SessionSummary>> GetSessionsLeanAsync(string tenantId)
|
||||
=> _storage.GetSessionsLeanAsync(tenantId);
|
||||
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
using System.Net;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Functions.Admin;
|
||||
|
||||
/// <summary>
|
||||
/// Global-Admin management of the two transactional email templates (welcome, farewell):
|
||||
/// read the effective template, store/reset an HTML override, and send a test mail.
|
||||
/// All routes are GlobalAdminOnly via <c>EndpointAccessPolicyCatalog</c>.
|
||||
/// </summary>
|
||||
public class EmailTemplatesFunction
|
||||
{
|
||||
private readonly ILogger<EmailTemplatesFunction> _logger;
|
||||
private readonly EmailTemplateService _templates;
|
||||
private readonly IEmailService _email;
|
||||
private readonly PreviewWhitelistService _previewWhitelistService;
|
||||
private readonly TenantConfigurationService _tenantConfigurationService;
|
||||
|
||||
public EmailTemplatesFunction(
|
||||
ILogger<EmailTemplatesFunction> logger,
|
||||
EmailTemplateService templates,
|
||||
IEmailService email,
|
||||
PreviewWhitelistService previewWhitelistService,
|
||||
TenantConfigurationService tenantConfigurationService)
|
||||
{
|
||||
_logger = logger;
|
||||
_templates = templates;
|
||||
_email = email;
|
||||
_previewWhitelistService = previewWhitelistService;
|
||||
_tenantConfigurationService = tenantConfigurationService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GET /api/global/email-templates/{kind}
|
||||
/// Effective template: the override when stored, otherwise the built-in (raw, with the
|
||||
/// {{domainName}} placeholder so it can be edited as-is).
|
||||
/// </summary>
|
||||
[Function("GetEmailTemplate")]
|
||||
public async Task<HttpResponseData> Get(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "global/email-templates/{kind}")] HttpRequestData req,
|
||||
string kind)
|
||||
{
|
||||
if (!EmailTemplateService.TryParseKind(kind, out var templateKind))
|
||||
return await BadRequest(req, "Unknown template kind. Use 'welcome' or 'farewell'.");
|
||||
|
||||
var overrideEntry = await _templates.GetOverrideAsync(templateKind);
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new
|
||||
{
|
||||
kind = EmailTemplateService.KindKey(templateKind),
|
||||
subject = EmailTemplateService.Subject(templateKind),
|
||||
isOverridden = overrideEntry is not null,
|
||||
html = overrideEntry?.Html ?? EmailTemplateService.BuiltInRaw(templateKind),
|
||||
builtInHtml = EmailTemplateService.BuiltInRaw(templateKind),
|
||||
updatedBy = overrideEntry?.UpdatedBy,
|
||||
updatedUtc = overrideEntry?.UpdatedUtc,
|
||||
placeholder = EmailTemplateService.DomainPlaceholder,
|
||||
maxLength = EmailTemplateService.MaxHtmlLength,
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
/// <summary>PUT /api/global/email-templates/{kind} — body { html } stores an override.</summary>
|
||||
[Function("SaveEmailTemplate")]
|
||||
public async Task<HttpResponseData> Save(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "put", Route = "global/email-templates/{kind}")] HttpRequestData req,
|
||||
string kind)
|
||||
{
|
||||
if (!EmailTemplateService.TryParseKind(kind, out var templateKind))
|
||||
return await BadRequest(req, "Unknown template kind. Use 'welcome' or 'farewell'.");
|
||||
|
||||
var body = await req.ReadFromJsonAsync<EmailTemplateRequest>();
|
||||
var error = EmailTemplateService.Validate(body?.Html);
|
||||
if (error is not null)
|
||||
return await BadRequest(req, error);
|
||||
|
||||
var upn = TenantHelper.GetUserIdentifier(req);
|
||||
var saved = await _templates.SaveOverrideAsync(templateKind, body!.Html!, upn);
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new { kind = saved.Kind, isOverridden = true, updatedBy = saved.UpdatedBy, updatedUtc = saved.UpdatedUtc });
|
||||
return response;
|
||||
}
|
||||
|
||||
/// <summary>DELETE /api/global/email-templates/{kind} — resets to the built-in template.</summary>
|
||||
[Function("ResetEmailTemplate")]
|
||||
public async Task<HttpResponseData> Reset(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "delete", Route = "global/email-templates/{kind}")] HttpRequestData req,
|
||||
string kind)
|
||||
{
|
||||
if (!EmailTemplateService.TryParseKind(kind, out var templateKind))
|
||||
return await BadRequest(req, "Unknown template kind. Use 'welcome' or 'farewell'.");
|
||||
|
||||
await _templates.DeleteOverrideAsync(templateKind, TenantHelper.GetUserIdentifier(req));
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new { kind = EmailTemplateService.KindKey(templateKind), isOverridden = false });
|
||||
return response;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// POST /api/global/email-templates/{kind}/test — body { html? } sends the effective template
|
||||
/// (or the unsaved draft) to the caller's tenant contact address: the activation
|
||||
/// notification email, then TenantConfiguration.ContactEmail, then the caller's UPN.
|
||||
/// Domain = the caller's tenant domain.
|
||||
/// </summary>
|
||||
[Function("SendEmailTemplateTest")]
|
||||
public async Task<HttpResponseData> SendTest(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "global/email-templates/{kind}/test")] HttpRequestData req,
|
||||
string kind)
|
||||
{
|
||||
if (!EmailTemplateService.TryParseKind(kind, out var templateKind))
|
||||
return await BadRequest(req, "Unknown template kind. Use 'welcome' or 'farewell'.");
|
||||
|
||||
var body = await req.ReadFromJsonAsync<EmailTemplateRequest>();
|
||||
var draft = string.IsNullOrWhiteSpace(body?.Html) ? null : body!.Html;
|
||||
if (draft is not null)
|
||||
{
|
||||
var error = EmailTemplateService.Validate(draft);
|
||||
if (error is not null) return await BadRequest(req, error);
|
||||
}
|
||||
|
||||
var tenantId = TenantHelper.GetTenantId(req);
|
||||
var upn = TenantHelper.GetUserIdentifier(req);
|
||||
var tenantConfig = await _tenantConfigurationService.GetConfigurationAsync(tenantId);
|
||||
|
||||
var toEmail = await _previewWhitelistService.GetNotificationEmailAsync(tenantId);
|
||||
if (string.IsNullOrWhiteSpace(toEmail)) toEmail = tenantConfig.ContactEmail;
|
||||
if (string.IsNullOrWhiteSpace(toEmail)) toEmail = upn.Contains('@') ? upn : null;
|
||||
if (string.IsNullOrWhiteSpace(toEmail))
|
||||
return await BadRequest(req, "No contact email found for your tenant — set a notification email or ContactEmail first.");
|
||||
|
||||
var sent = await _email.SendTestAsync(templateKind, toEmail, tenantConfig.DomainName, draft);
|
||||
_logger.LogInformation("Email template test {Kind} requested by {Upn} → {ToEmail}: {Result}", templateKind, upn, toEmail, sent ? "accepted" : "failed");
|
||||
|
||||
if (!sent)
|
||||
{
|
||||
var failed = req.CreateResponse(HttpStatusCode.BadGateway);
|
||||
await failed.WriteAsJsonAsync(new { error = "The email provider did not accept the message. Check the backend log for the provider response.", sentTo = toEmail });
|
||||
return failed;
|
||||
}
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new { sentTo = toEmail, domainName = tenantConfig.DomainName, draft = draft is not null });
|
||||
return response;
|
||||
}
|
||||
|
||||
private static async Task<HttpResponseData> BadRequest(HttpRequestData req, string error)
|
||||
{
|
||||
var bad = req.CreateResponse(HttpStatusCode.BadRequest);
|
||||
await bad.WriteAsJsonAsync(new { error });
|
||||
return bad;
|
||||
}
|
||||
}
|
||||
|
||||
public class EmailTemplateRequest
|
||||
{
|
||||
public string? Html { get; set; }
|
||||
}
|
||||
+5
-1
@@ -35,6 +35,8 @@ namespace AutopilotMonitor.Functions.Functions.Admin
|
||||
/// Query parameters:
|
||||
/// - mode: "legacy_timeouts" (default) — re-classify pre-classifier "Session timed out" Failed rows;
|
||||
/// "pending_orphans" — resolve Pending rows superseded by a later session of the same device.
|
||||
/// "self_deploying_silent" — heal self-deploying (kiosk) rows parked Incomplete/AwaitingUser/Stalled
|
||||
/// after Device ESP provisioned to Succeeded (classifier rule 1c, 2026-08-23).
|
||||
/// - dryRun: "false" to actually write; anything else (or absent) reports only.
|
||||
/// - tenantId: optional single-tenant scope (GUID); absent = all tenants.
|
||||
/// - maxSessions: per-invocation examination cap (default 200, max 2000). Re-run to continue.
|
||||
@@ -82,6 +84,8 @@ namespace AutopilotMonitor.Functions.Functions.Admin
|
||||
await _service.ReclassifyLegacyTimeoutsAsync(tenantIdScope, dryRun, maxSessions, userEmail),
|
||||
LegacyReclassificationService.ModePendingOrphans =>
|
||||
await _service.ResolvePendingOrphansAsync(tenantIdScope, dryRun, maxSessions, userEmail),
|
||||
LegacyReclassificationService.ModeSelfDeployingSilent =>
|
||||
await _service.ReconcileSelfDeployingSilentAsync(tenantIdScope, dryRun, maxSessions, userEmail),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
@@ -90,7 +94,7 @@ namespace AutopilotMonitor.Functions.Functions.Admin
|
||||
var bad = req.CreateResponse(HttpStatusCode.BadRequest);
|
||||
await bad.WriteAsJsonAsync(new
|
||||
{
|
||||
error = $"Unknown mode '{mode}'. Valid: {LegacyReclassificationService.ModeLegacyTimeouts}, {LegacyReclassificationService.ModePendingOrphans}"
|
||||
error = $"Unknown mode '{mode}'. Valid: {LegacyReclassificationService.ModeLegacyTimeouts}, {LegacyReclassificationService.ModePendingOrphans}, {LegacyReclassificationService.ModeSelfDeployingSilent}"
|
||||
});
|
||||
return bad;
|
||||
}
|
||||
|
||||
@@ -142,6 +142,23 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
|| string.Equals(destination, "Hosted", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Global ∪ tenant diagnostics paths: global first, order preserved, blank paths dropped,
|
||||
/// duplicates (trimmed, case-insensitive) collapsed onto the first occurrence. The agent
|
||||
/// keys ZIP entries on the path, so a path present in both lists would otherwise produce
|
||||
/// duplicate entry names inside the archive.
|
||||
/// </summary>
|
||||
internal static List<DiagnosticsLogPath> MergeDiagnosticsLogPaths(
|
||||
IEnumerable<DiagnosticsLogPath> global,
|
||||
IEnumerable<DiagnosticsLogPath> tenant)
|
||||
{
|
||||
return global.Concat(tenant)
|
||||
.Where(p => !string.IsNullOrWhiteSpace(p?.Path))
|
||||
.GroupBy(p => p.Path.Trim(), StringComparer.OrdinalIgnoreCase)
|
||||
.Select(g => g.First())
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the (ZipSha256, ExeSha256) pair appropriate for the calling agent.
|
||||
/// Reads the X-Agent-Version header, parses the major, and dispatches to the
|
||||
@@ -242,10 +259,10 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
// Get active IME log patterns for this tenant (from Table Storage)
|
||||
var imeLogPatterns = await _imeLogPatternService.GetActivePatternsForTenantAsync(tenantId);
|
||||
|
||||
// Merge global + tenant-specific diagnostics log paths
|
||||
var globalDiagPaths = adminConfig.GetDiagnosticsGlobalLogPaths();
|
||||
var tenantDiagPaths = tenantConfig.GetDiagnosticsLogPaths();
|
||||
var diagLogPaths = globalDiagPaths.Concat(tenantDiagPaths).ToList();
|
||||
// Merge global + tenant-specific diagnostics log paths (the built-in sections are
|
||||
// compiled into the agent — DiagnosticsBuiltInSections — and never travel here)
|
||||
var diagLogPaths = MergeDiagnosticsLogPaths(
|
||||
adminConfig.GetDiagnosticsGlobalLogPaths(), tenantConfig.GetDiagnosticsLogPaths());
|
||||
|
||||
// Select per-line hash oracle from the X-Agent-Version header (parametric per major).
|
||||
// The wire response keeps generic field names (LatestAgentSha256 / LatestAgentExeSha256)
|
||||
|
||||
@@ -27,14 +27,16 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
private readonly TenantConfigurationService _configService;
|
||||
private readonly AdminConfigurationService _adminConfigService;
|
||||
private readonly IMaintenanceRepository _maintenanceRepo;
|
||||
private readonly OpsEventService _opsEvents;
|
||||
private readonly TimeProvider _time;
|
||||
|
||||
public PlanManagementFunction(
|
||||
ILogger<PlanManagementFunction> logger,
|
||||
TenantConfigurationService configService,
|
||||
AdminConfigurationService adminConfigService,
|
||||
IMaintenanceRepository maintenanceRepo)
|
||||
: this(logger, configService, adminConfigService, maintenanceRepo, TimeProvider.System)
|
||||
IMaintenanceRepository maintenanceRepo,
|
||||
OpsEventService opsEvents)
|
||||
: this(logger, configService, adminConfigService, maintenanceRepo, opsEvents, TimeProvider.System)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -44,12 +46,14 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
TenantConfigurationService configService,
|
||||
AdminConfigurationService adminConfigService,
|
||||
IMaintenanceRepository maintenanceRepo,
|
||||
OpsEventService opsEvents,
|
||||
TimeProvider time)
|
||||
{
|
||||
_logger = logger;
|
||||
_configService = configService;
|
||||
_adminConfigService = adminConfigService;
|
||||
_maintenanceRepo = maintenanceRepo;
|
||||
_opsEvents = opsEvents;
|
||||
_time = time;
|
||||
}
|
||||
|
||||
@@ -139,22 +143,8 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
var nowUtc = _time.GetUtcNow().UtcDateTime;
|
||||
var changes = new Dictionary<string, string>();
|
||||
|
||||
if (newPlanTier != null && !string.Equals(config.PlanTier, newPlanTier, StringComparison.Ordinal))
|
||||
{
|
||||
changes["PlanTier"] = $"{config.PlanTier} -> {newPlanTier}";
|
||||
config.PlanTier = newPlanTier;
|
||||
}
|
||||
|
||||
if (trialProvided && config.TrialExpiresUtc != newTrialExpiresUtc)
|
||||
{
|
||||
changes["TrialExpiresUtc"] = $"{FormatUtc(config.TrialExpiresUtc)} -> {FormatUtc(newTrialExpiresUtc)}";
|
||||
config.TrialExpiresUtc = newTrialExpiresUtc;
|
||||
if (newTrialExpiresUtc.HasValue)
|
||||
{
|
||||
config.TrialStartedUtc ??= nowUtc;
|
||||
config.TrialGrantedBy = caller;
|
||||
}
|
||||
}
|
||||
var (editionBefore, editionAfter) =
|
||||
ApplyPlanChanges(config, newPlanTier, trialProvided, newTrialExpiresUtc, caller, nowUtc, changes);
|
||||
|
||||
if (changes.Count > 0)
|
||||
{
|
||||
@@ -171,9 +161,17 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
requestCtx.TargetTenantId,
|
||||
caller,
|
||||
changes);
|
||||
}
|
||||
|
||||
var effectiveEdition = FeatureEntitlementCatalog.ResolveEdition(config.PlanTier, config.TrialExpiresUtc, nowUtc);
|
||||
if (editionBefore == TenantEdition.Pro && editionAfter == TenantEdition.Community)
|
||||
{
|
||||
await _opsEvents.RecordTenantPlanDowngradedAsync(
|
||||
requestCtx.TargetTenantId,
|
||||
config.DomainName,
|
||||
caller,
|
||||
TenantEntitlementService.GetRetentionGraceEndUtc(config, nowUtc),
|
||||
config.DataRetentionDays);
|
||||
}
|
||||
}
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new
|
||||
@@ -182,7 +180,8 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
planTier = config.PlanTier,
|
||||
trialExpiresUtc = config.TrialExpiresUtc,
|
||||
trialConsumed = config.TrialConsumed,
|
||||
effectiveEdition = effectiveEdition.ToString().ToLowerInvariant()
|
||||
effectiveEdition = editionAfter.ToString().ToLowerInvariant(),
|
||||
retentionGraceEndsUtc = TenantEntitlementService.GetRetentionGraceEndUtc(config, nowUtc)
|
||||
});
|
||||
return response;
|
||||
}
|
||||
@@ -195,6 +194,61 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure mutation core of the PATCH plan endpoint — no I/O, unit-testable. Applies the
|
||||
/// plan/trial changes to <paramref name="config"/>, records human-readable entries in
|
||||
/// <paramref name="changes"/>, and returns the EFFECTIVE edition before/after (same
|
||||
/// <paramref name="nowUtc"/> for both, so only the mutation itself can flip it).
|
||||
///
|
||||
/// Maintains the retention downgrade grace anchor: an effective Pro→Community
|
||||
/// transition stamps <see cref="TenantConfiguration.ProDowngradedUtc"/> (covers both a
|
||||
/// planTier downgrade and an explicitly ended trial); any state that is effectively Pro
|
||||
/// afterwards clears the anchor, so a re-upgrade also resets the grace clock.
|
||||
/// </summary>
|
||||
internal static (TenantEdition Before, TenantEdition After) ApplyPlanChanges(
|
||||
TenantConfiguration config,
|
||||
string? newPlanTier,
|
||||
bool trialProvided,
|
||||
DateTime? newTrialExpiresUtc,
|
||||
string caller,
|
||||
DateTime nowUtc,
|
||||
Dictionary<string, string> changes)
|
||||
{
|
||||
var before = FeatureEntitlementCatalog.ResolveEdition(config.PlanTier, config.TrialExpiresUtc, nowUtc);
|
||||
|
||||
if (newPlanTier != null && !string.Equals(config.PlanTier, newPlanTier, StringComparison.Ordinal))
|
||||
{
|
||||
changes["PlanTier"] = $"{config.PlanTier} -> {newPlanTier}";
|
||||
config.PlanTier = newPlanTier;
|
||||
}
|
||||
|
||||
if (trialProvided && config.TrialExpiresUtc != newTrialExpiresUtc)
|
||||
{
|
||||
changes["TrialExpiresUtc"] = $"{FormatUtc(config.TrialExpiresUtc)} -> {FormatUtc(newTrialExpiresUtc)}";
|
||||
config.TrialExpiresUtc = newTrialExpiresUtc;
|
||||
if (newTrialExpiresUtc.HasValue)
|
||||
{
|
||||
config.TrialStartedUtc ??= nowUtc;
|
||||
config.TrialGrantedBy = caller;
|
||||
}
|
||||
}
|
||||
|
||||
var after = FeatureEntitlementCatalog.ResolveEdition(config.PlanTier, config.TrialExpiresUtc, nowUtc);
|
||||
|
||||
if (before == TenantEdition.Pro && after == TenantEdition.Community)
|
||||
{
|
||||
changes["ProDowngradedUtc"] = $"{FormatUtc(config.ProDowngradedUtc)} -> {FormatUtc(nowUtc)}";
|
||||
config.ProDowngradedUtc = nowUtc;
|
||||
}
|
||||
else if (after == TenantEdition.Pro && config.ProDowngradedUtc.HasValue)
|
||||
{
|
||||
changes["ProDowngradedUtc"] = $"{FormatUtc(config.ProDowngradedUtc)} -> (none)";
|
||||
config.ProDowngradedUtc = null;
|
||||
}
|
||||
|
||||
return (before, after);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// POST /api/config/{tenantId}/trial — TenantAdminOrGA (catalog-enforced). Self-service
|
||||
/// 30-day Pro trial, exactly once per tenant. 409 when the trial was already
|
||||
@@ -262,6 +316,8 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
config.TrialExpiresUtc = nowUtc.AddDays(SelfServiceTrialDays);
|
||||
config.TrialConsumed = true;
|
||||
config.TrialGrantedBy = caller;
|
||||
// Effectively Pro again — a leftover retention grace anchor is obsolete.
|
||||
config.ProDowngradedUtc = null;
|
||||
config.UpdatedBy = caller;
|
||||
|
||||
// Fail-loud: throws when the write did not persist (cache invalidated in finally).
|
||||
|
||||
+1
-1
@@ -82,7 +82,7 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
|
||||
var testAlert = NotificationAlertBuilder.BuildTestAlert();
|
||||
var result = await _webhookNotificationService.SendNotificationWithResultAsync(
|
||||
channel.Url, (WebhookProviderType)channel.ProviderType, testAlert, channel.GetCustomHeaders());
|
||||
channel.Url, (WebhookProviderType)channel.ProviderType, testAlert, channel.GetCustomHeaders(), channel.GetSigningSecret());
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new
|
||||
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Functions.Diagnostics
|
||||
{
|
||||
public class GetDiagnosticsPathsFunction
|
||||
{
|
||||
private readonly ILogger<GetDiagnosticsPathsFunction> _logger;
|
||||
private readonly AdminConfigurationService _adminConfigService;
|
||||
|
||||
public GetDiagnosticsPathsFunction(
|
||||
ILogger<GetDiagnosticsPathsFunction> logger,
|
||||
AdminConfigurationService adminConfigService)
|
||||
{
|
||||
_logger = logger;
|
||||
_adminConfigService = adminConfigService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GET /api/diagnostics/paths
|
||||
/// What every diagnostics package collects before a tenant's own entries: the built-in
|
||||
/// section catalog (compiled into the agent, shared through
|
||||
/// <see cref="DiagnosticsBuiltInSections"/>) and the platform-wide global paths set by
|
||||
/// Global Admins. Member-readable by design — the catalog is code, the global list is
|
||||
/// non-secret admin data, and every settings viewer needs to see what is collected from
|
||||
/// the tenant's devices. Tenant-less: the payload is platform-wide, not tenant data.
|
||||
/// </summary>
|
||||
[Function("GetDiagnosticsPaths")]
|
||||
public async Task<HttpResponseData> Run(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "diagnostics/paths")] HttpRequestData req)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Authentication + MemberRead authorization enforced by PolicyEnforcementMiddleware
|
||||
var adminConfig = await _adminConfigService.GetConfigurationAsync();
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(BuildPayload(adminConfig));
|
||||
return response;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error getting diagnostics paths");
|
||||
var response = req.CreateResponse(HttpStatusCode.InternalServerError);
|
||||
await response.WriteAsJsonAsync(new { error = "Internal server error" });
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Static projection so unit tests pin the wire shape without an HttpRequestData mock.
|
||||
/// <c>condition</c> travels as the enum NAME ("Always" | "RealmJoinWatcher" |
|
||||
/// "DevicePreparation"), never the integer — the web switches on the string.
|
||||
/// </summary>
|
||||
internal static object BuildPayload(AdminConfiguration adminConfig) => new
|
||||
{
|
||||
builtIn = DiagnosticsBuiltInSections.All.Select(s => new
|
||||
{
|
||||
id = s.Id,
|
||||
zipFolder = s.ZipFolder,
|
||||
sourceFolder = s.SourceFolder,
|
||||
patterns = s.Patterns,
|
||||
includeSubfolders = s.IncludeSubfolders,
|
||||
description = s.Description,
|
||||
condition = s.Condition.ToString(),
|
||||
}).ToArray(),
|
||||
globalPaths = adminConfig.GetDiagnosticsGlobalLogPaths(),
|
||||
};
|
||||
}
|
||||
}
|
||||
+85
-31
@@ -10,15 +10,21 @@ using Microsoft.Extensions.Logging;
|
||||
namespace AutopilotMonitor.Functions.Functions.Maintenance;
|
||||
|
||||
/// <summary>
|
||||
/// Daily informational sweep over tenant Pro trials. NOT load-bearing — trial expiry
|
||||
/// degrades a tenant to Community at READ time (FeatureEntitlementCatalog.ResolveEdition);
|
||||
/// this timer only surfaces the transitions as ops events so the operator has visibility:
|
||||
/// Daily informational sweep over the tenant plan lifecycle. NOT load-bearing — trial expiry
|
||||
/// degrades a tenant to Community at READ time (FeatureEntitlementCatalog.ResolveEdition),
|
||||
/// and the retention grace is likewise resolved read-time by the retention fanout; this timer
|
||||
/// only surfaces the transitions as ops events so the operator has visibility:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>TenantTrialExpired</c> (Warning) — the trial ended within the last sweep window
|
||||
/// (24h look-back matching the daily cadence).</item>
|
||||
/// <item><c>TenantTrialExpiring</c> (Info) — the trial ends within the next
|
||||
/// <see cref="ExpiringHeadsUpDays"/> days (re-emitted each daily run until expiry —
|
||||
/// acceptable for an Info-tier heads-up, no dedupe state to maintain).</item>
|
||||
/// <item><c>TenantRetentionGraceExpiring</c> / <c>TenantRetentionGraceEnded</c> (Warning) —
|
||||
/// same window mechanics for the retention downgrade grace
|
||||
/// (TenantEntitlementService.GetRetentionGraceEndUtc). Emitted only when data is
|
||||
/// actually at risk: stored retention above the Community cap (0 = infinite ⇒ never
|
||||
/// at risk, the fanout skips those tenants).</item>
|
||||
/// </list>
|
||||
/// Tenants whose stored <c>PlanTier</c> is already a permanent Pro tier ("pro"/legacy "enterprise") are skipped —
|
||||
/// their trial timestamps are inert leftovers and expiry changes nothing.
|
||||
@@ -90,45 +96,93 @@ public class TrialExpirySweepFunction
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
if (config.TrialExpiresUtc is not DateTime expiry)
|
||||
continue;
|
||||
|
||||
// Permanent Pro: the trial timestamps are inert leftovers — expiry changes nothing.
|
||||
if (FeatureEntitlementCatalog.IsPermanentProTier(config.PlanTier))
|
||||
continue;
|
||||
|
||||
result.TrialsSeen++;
|
||||
|
||||
if (expiry <= now)
|
||||
{
|
||||
if (expiry > now - ExpiredLookBack)
|
||||
{
|
||||
await _opsEvents.RecordTenantTrialExpiredAsync(config.TenantId, config.DomainName, expiry)
|
||||
.ConfigureAwait(false);
|
||||
result.ExpiredEmitted++;
|
||||
}
|
||||
// Older expiries were reported by a previous run — stay silent.
|
||||
}
|
||||
else if (expiry <= now + ExpiringHeadsUp)
|
||||
{
|
||||
var daysLeft = Math.Max(1, (int)Math.Ceiling((expiry - now).TotalDays));
|
||||
await _opsEvents.RecordTenantTrialExpiringAsync(config.TenantId, config.DomainName, expiry, daysLeft)
|
||||
.ConfigureAwait(false);
|
||||
result.ExpiringEmitted++;
|
||||
}
|
||||
await SweepTrialAsync(config, now, result).ConfigureAwait(false);
|
||||
await SweepRetentionGraceAsync(config, now, result).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"TrialExpirySweep completed: trialsSeen={TrialsSeen} expiredEmitted={ExpiredEmitted} expiringEmitted={ExpiringEmitted}",
|
||||
result.TrialsSeen, result.ExpiredEmitted, result.ExpiringEmitted);
|
||||
"TrialExpirySweep completed: trialsSeen={TrialsSeen} expiredEmitted={ExpiredEmitted} expiringEmitted={ExpiringEmitted} " +
|
||||
"graceExpiringEmitted={GraceExpiringEmitted} graceEndedEmitted={GraceEndedEmitted}",
|
||||
result.TrialsSeen, result.ExpiredEmitted, result.ExpiringEmitted,
|
||||
result.GraceExpiringEmitted, result.GraceEndedEmitted);
|
||||
return result;
|
||||
}
|
||||
|
||||
private async Task SweepTrialAsync(
|
||||
AutopilotMonitor.Shared.Models.TenantConfiguration config, DateTime now, SweepResult result)
|
||||
{
|
||||
if (config.TrialExpiresUtc is not DateTime expiry)
|
||||
return;
|
||||
|
||||
// Permanent Pro: the trial timestamps are inert leftovers — expiry changes nothing.
|
||||
if (FeatureEntitlementCatalog.IsPermanentProTier(config.PlanTier))
|
||||
return;
|
||||
|
||||
result.TrialsSeen++;
|
||||
|
||||
if (expiry <= now)
|
||||
{
|
||||
if (expiry > now - ExpiredLookBack)
|
||||
{
|
||||
await _opsEvents.RecordTenantTrialExpiredAsync(config.TenantId, config.DomainName, expiry)
|
||||
.ConfigureAwait(false);
|
||||
result.ExpiredEmitted++;
|
||||
}
|
||||
// Older expiries were reported by a previous run — stay silent.
|
||||
}
|
||||
else if (expiry <= now + ExpiringHeadsUp)
|
||||
{
|
||||
var daysLeft = Math.Max(1, (int)Math.Ceiling((expiry - now).TotalDays));
|
||||
await _opsEvents.RecordTenantTrialExpiringAsync(config.TenantId, config.DomainName, expiry, daysLeft)
|
||||
.ConfigureAwait(false);
|
||||
result.ExpiringEmitted++;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retention downgrade grace visibility: warn shortly before and once after the grace ends,
|
||||
/// but only when data is actually at risk — stored retention above the Community cap.
|
||||
/// (0 = the GA-only infinite escape hatch: the fanout skips those tenants, nothing is ever
|
||||
/// deleted, so the comparison excludes it naturally.) GetRetentionGraceEndUtc returns null
|
||||
/// for effectively-Pro tenants, so re-upgraded tenants go silent automatically.
|
||||
/// </summary>
|
||||
private async Task SweepRetentionGraceAsync(
|
||||
AutopilotMonitor.Shared.Models.TenantConfiguration config, DateTime now, SweepResult result)
|
||||
{
|
||||
var communityCap = FeatureEntitlementCatalog.Get(TenantEdition.Community).RetentionCapDays;
|
||||
if (config.DataRetentionDays <= communityCap)
|
||||
return;
|
||||
|
||||
if (TenantEntitlementService.GetRetentionGraceEndUtc(config, now) is not DateTime graceEnd)
|
||||
return;
|
||||
|
||||
if (graceEnd <= now)
|
||||
{
|
||||
if (graceEnd > now - ExpiredLookBack)
|
||||
{
|
||||
await _opsEvents.RecordTenantRetentionGraceEndedAsync(
|
||||
config.TenantId, config.DomainName, graceEnd, config.DataRetentionDays)
|
||||
.ConfigureAwait(false);
|
||||
result.GraceEndedEmitted++;
|
||||
}
|
||||
}
|
||||
else if (graceEnd <= now + ExpiringHeadsUp)
|
||||
{
|
||||
var daysLeft = Math.Max(1, (int)Math.Ceiling((graceEnd - now).TotalDays));
|
||||
await _opsEvents.RecordTenantRetentionGraceExpiringAsync(
|
||||
config.TenantId, config.DomainName, graceEnd, daysLeft, config.DataRetentionDays)
|
||||
.ConfigureAwait(false);
|
||||
result.GraceExpiringEmitted++;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Summary counters returned from <see cref="RunCoreAsync"/> so tests can assert outcomes.</summary>
|
||||
internal sealed class SweepResult
|
||||
{
|
||||
public int TrialsSeen { get; set; }
|
||||
public int ExpiredEmitted { get; set; }
|
||||
public int ExpiringEmitted { get; set; }
|
||||
public int GraceExpiringEmitted { get; set; }
|
||||
public int GraceEndedEmitted { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,20 +19,20 @@ public class PreviewWhitelistFunction
|
||||
private readonly PreviewWhitelistService _previewWhitelistService;
|
||||
private readonly TenantApprovalService _tenantApprovalService;
|
||||
private readonly TenantConfigurationService _tenantConfigurationService;
|
||||
private readonly ResendEmailService _resendEmailService;
|
||||
private readonly IEmailService _emailService;
|
||||
|
||||
public PreviewWhitelistFunction(
|
||||
ILogger<PreviewWhitelistFunction> logger,
|
||||
PreviewWhitelistService previewWhitelistService,
|
||||
TenantApprovalService tenantApprovalService,
|
||||
TenantConfigurationService tenantConfigurationService,
|
||||
ResendEmailService resendEmailService)
|
||||
IEmailService emailService)
|
||||
{
|
||||
_logger = logger;
|
||||
_previewWhitelistService = previewWhitelistService;
|
||||
_tenantApprovalService = tenantApprovalService;
|
||||
_tenantConfigurationService = tenantConfigurationService;
|
||||
_resendEmailService = resendEmailService;
|
||||
_emailService = emailService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -221,7 +221,7 @@ public class PreviewWhitelistFunction
|
||||
}
|
||||
|
||||
var tenantConfig = await _tenantConfigurationService.GetConfigurationAsync(tenantId);
|
||||
await _resendEmailService.SendPreviewApprovedEmailAsync(email, tenantConfig.DomainName);
|
||||
await _emailService.SendPreviewApprovedEmailAsync(email, tenantConfig.DomainName);
|
||||
|
||||
// Explicit GA send always sends; consume the once-only marker (best-effort) so
|
||||
// the automatic paths won't produce a duplicate afterwards.
|
||||
|
||||
@@ -182,6 +182,35 @@ namespace AutopilotMonitor.Functions.Helpers
|
||||
var headerError = ValidateWebhookCustomHeaders(channel.CustomHeadersJson);
|
||||
if (headerError != null)
|
||||
return $"channel \"{label}\" headers: {headerError}";
|
||||
|
||||
var secretError = ValidateWebhookSigningSecret(channel.SigningSecret);
|
||||
if (secretError != null)
|
||||
return $"channel \"{label}\" signing secret: {secretError}";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates a generic-webhook HMAC signing secret. Returns an error message, or null when
|
||||
/// valid/absent. A short key is brute-forceable as an HMAC key; the printable-ASCII rule
|
||||
/// keeps the secret copy-paste safe and (as a side effect) rejects a stray unrestored
|
||||
/// redaction sentinel, which is shorter than the minimum. No provider gate here — a secret
|
||||
/// on a non-generic channel is stored but inert at dispatch (GetSigningSecret), matching
|
||||
/// how CustomHeadersJson behaves.
|
||||
/// </summary>
|
||||
internal static string? ValidateWebhookSigningSecret(string? secret)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(secret))
|
||||
return null;
|
||||
|
||||
if (secret.Length < 16 || secret.Length > 128)
|
||||
return "must be 16-128 characters.";
|
||||
|
||||
foreach (var ch in secret)
|
||||
{
|
||||
if (ch < 0x21 || ch > 0x7E)
|
||||
return "must be printable ASCII without spaces or line breaks.";
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -437,8 +437,15 @@ builder.Services.AddHttpClient<AutopilotMonitor.Functions.Services.Notifications
|
||||
.AddPolicyHandler((sp, _) => sp.GetRequiredService<ResiliencePolicies>().Notification);
|
||||
builder.Services.AddHttpClient<TelegramNotificationService>()
|
||||
.AddPolicyHandler((sp, _) => sp.GetRequiredService<ResiliencePolicies>().Notification);
|
||||
builder.Services.AddSingleton<ResendEmailService>();
|
||||
builder.Services.AddSingleton<IOffboardFarewellEmailSender>(sp => sp.GetRequiredService<ResendEmailService>());
|
||||
// Transactional email (welcome / farewell). Typed client like the other outbound notifiers;
|
||||
// the provider is an implementation detail of EmailService + the Email:* settings.
|
||||
builder.Services.AddSingleton<EmailTemplateService>();
|
||||
builder.Services.AddSingleton<IEmailTemplateProvider>(sp => sp.GetRequiredService<EmailTemplateService>());
|
||||
builder.Services.AddHttpClient<EmailService>()
|
||||
.ConfigureHttpClient(client => client.Timeout = TimeSpan.FromSeconds(15))
|
||||
.AddPolicyHandler((sp, _) => sp.GetRequiredService<ResiliencePolicies>().Notification);
|
||||
builder.Services.AddTransient<IEmailService>(sp => sp.GetRequiredService<EmailService>());
|
||||
builder.Services.AddTransient<IOffboardFarewellEmailSender>(sp => sp.GetRequiredService<EmailService>());
|
||||
builder.Services.AddSingleton<GlobalNotificationService>();
|
||||
builder.Services.AddSingleton<TenantNotificationService>();
|
||||
|
||||
|
||||
@@ -282,6 +282,9 @@ public static class EndpointAccessPolicyCatalog
|
||||
new("GET", "audit/tpm-pss-unsupported", EndpointPolicy.MemberRead),
|
||||
new("GET", "diagnostics/download-url", EndpointPolicy.MemberRead, TenantScoping.QueryParam),
|
||||
new("POST", "diagnostics/download-ticket", EndpointPolicy.MemberRead, TenantScoping.QueryParam),
|
||||
// Platform-defined diagnostics collection (built-in catalog + global paths): read-only,
|
||||
// no secrets, needed by every settings viewer — member tier, JWT-scoped (no tenant parameter).
|
||||
new("GET", "diagnostics/paths", EndpointPolicy.MemberRead, TenantScoping.Jwt),
|
||||
new("GET", "rules/gather", EndpointPolicy.MemberRead),
|
||||
new("GET", "rules/analyze", EndpointPolicy.MemberRead),
|
||||
new("GET", "rules/ime-log-patterns", EndpointPolicy.MemberRead),
|
||||
@@ -497,6 +500,12 @@ public static class EndpointAccessPolicyCatalog
|
||||
new("POST", "versions/block", EndpointPolicy.GlobalAdminOnly),
|
||||
new("DELETE", "versions/block/{encodedPattern}", EndpointPolicy.GlobalAdminOnly),
|
||||
new("POST", "maintenance/trigger", EndpointPolicy.GlobalAdminOnly),
|
||||
// Transactional email templates (welcome / farewell): read, override, reset, test send.
|
||||
// Read stays GA-only too — the override HTML is operator content, not tenant data.
|
||||
new("GET", "global/email-templates/{kind}", EndpointPolicy.GlobalAdminOnly),
|
||||
new("PUT", "global/email-templates/{kind}", EndpointPolicy.GlobalAdminOnly),
|
||||
new("DELETE", "global/email-templates/{kind}", EndpointPolicy.GlobalAdminOnly),
|
||||
new("POST", "global/email-templates/{kind}/test", EndpointPolicy.GlobalAdminOnly),
|
||||
// One-time retro-reconcile of misclassified historical sessions (audit 2026-07-16) —
|
||||
// mutating, dry-run by default, GA only.
|
||||
new("POST", "maintenance/reclassify-legacy", EndpointPolicy.GlobalAdminOnly),
|
||||
|
||||
@@ -95,6 +95,16 @@ namespace AutopilotMonitor.Functions.Security
|
||||
/// </summary>
|
||||
public const string LegacyEnterpriseTierName = "enterprise";
|
||||
|
||||
/// <summary>
|
||||
/// Retention downgrade grace period: for this many days after a tenant loses Pro
|
||||
/// (explicit downgrade or trial expiry) the retention sweep keeps enforcing the PRO
|
||||
/// cap, so the downgrade does not immediately hard-delete data older than the
|
||||
/// Community cap. Retention is the only entitlement with a grace — everything else
|
||||
/// gates read-time immediately because it is reversible without data loss.
|
||||
/// Resolved via <see cref="Services.TenantEntitlementService.GetRetentionGraceEndUtc"/>.
|
||||
/// </summary>
|
||||
public const int RetentionDowngradeGraceDays = 30;
|
||||
|
||||
private static readonly EditionEntitlements Community = new()
|
||||
{
|
||||
Edition = TenantEdition.Community,
|
||||
|
||||
+4
-3
@@ -162,16 +162,17 @@ namespace AutopilotMonitor.Functions.Services.Deletion
|
||||
// trial expired, or whose stored value predates the cap, is enforced here even though
|
||||
// the stored DataRetentionDays is left untouched. days <= 0 stays the GA-only
|
||||
// "infinite" escape hatch (skipped below, never clamped).
|
||||
var nowUtc = _utcNow();
|
||||
var storedDays = config?.DataRetentionDays ?? 90;
|
||||
var retentionDays = config == null
|
||||
? 90
|
||||
: TenantEntitlementService.GetEffectiveRetentionDays(config, DateTime.UtcNow);
|
||||
: TenantEntitlementService.GetEffectiveRetentionDays(config, nowUtc);
|
||||
if (retentionDays > 0 && retentionDays < storedDays)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Tenant {TenantId}: DataRetentionDays={Stored} exceeds the {Edition}-edition cap — enforcing {Effective} days",
|
||||
tenantId, storedDays,
|
||||
TenantEntitlementService.ResolveEdition(config!, DateTime.UtcNow), retentionDays);
|
||||
TenantEntitlementService.ResolveEdition(config!, nowUtc), retentionDays);
|
||||
}
|
||||
|
||||
if (retentionDays <= 0)
|
||||
@@ -180,7 +181,7 @@ namespace AutopilotMonitor.Functions.Services.Deletion
|
||||
return;
|
||||
}
|
||||
|
||||
var cutoffUtc = DateTime.UtcNow.AddDays(-retentionDays);
|
||||
var cutoffUtc = nowUtc.AddDays(-retentionDays);
|
||||
|
||||
// Server-bounded read: fetch one more than the per-run dispatch cap. The loop below
|
||||
// only ever advances MaxEnqueuesPerTenantPerRun sessions, so loading the whole backlog
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Sends the product's transactional emails (tenant-activation welcome, post-offboarding
|
||||
/// farewell). Best-effort: failures are logged as warnings and never propagated.
|
||||
/// <para>
|
||||
/// The class and its callers are provider-neutral. Everything provider-specific is confined
|
||||
/// to the <c>Email:*</c> configuration section and <see cref="SendViaMandrillAsync"/>, so a
|
||||
/// future provider swap touches the transport method and the app settings only.
|
||||
/// Current provider: Mailchimp Transactional (Mandrill) — <c>POST {Email:Endpoint}</c> with the
|
||||
/// API key in the JSON body. Open/click tracking is explicitly disabled so the provider
|
||||
/// receives nothing beyond the recipient address and the tenant domain (trust-page claim).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Configuration (App Settings use <c>Email__Key</c>):
|
||||
/// <c>Email:ApiKey</c> (required; empty ⇒ every send is a logged no-op),
|
||||
/// <c>Email:Endpoint</c> (default <see cref="DefaultEndpoint"/>),
|
||||
/// <c>Email:FromAddress</c> (default <see cref="DefaultFromAddress"/>),
|
||||
/// <c>Email:FromName</c> (default <see cref="DefaultFromName"/>).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class EmailService : IEmailService, IOffboardFarewellEmailSender
|
||||
{
|
||||
public const string ApiKeyConfigKey = "Email:ApiKey";
|
||||
public const string DefaultEndpoint = "https://mandrillapp.com/api/1.0/messages/send";
|
||||
public const string DefaultFromAddress = "noreply@autopilotmonitor.com";
|
||||
public const string DefaultFromName = "Autopilot Monitor";
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
};
|
||||
|
||||
private readonly HttpClient _http;
|
||||
private readonly IEmailTemplateProvider _templates;
|
||||
private readonly ILogger<EmailService> _logger;
|
||||
private readonly string _apiKey;
|
||||
private readonly string _endpoint;
|
||||
private readonly string _fromAddress;
|
||||
private readonly string _fromName;
|
||||
|
||||
public EmailService(
|
||||
HttpClient http,
|
||||
IConfiguration configuration,
|
||||
IEmailTemplateProvider templates,
|
||||
ILogger<EmailService> logger)
|
||||
{
|
||||
_http = http;
|
||||
_templates = templates;
|
||||
_logger = logger;
|
||||
_apiKey = configuration[ApiKeyConfigKey] ?? string.Empty;
|
||||
_endpoint = FirstNonBlank(configuration["Email:Endpoint"], DefaultEndpoint);
|
||||
_fromAddress = FirstNonBlank(configuration["Email:FromAddress"], DefaultFromAddress);
|
||||
_fromName = FirstNonBlank(configuration["Email:FromName"], DefaultFromName);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task SendPreviewApprovedEmailAsync(string toEmail, string domainName, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_apiKey))
|
||||
{
|
||||
_logger.LogDebug("{ConfigKey} not configured — skipping preview approval email", ApiKeyConfigKey);
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(toEmail))
|
||||
{
|
||||
_logger.LogDebug("No notification email set — skipping preview approval email for {Domain}", domainName);
|
||||
return;
|
||||
}
|
||||
|
||||
var sent = await SendViaMandrillAsync(
|
||||
toEmail,
|
||||
EmailTemplates.PreviewApprovedSubject,
|
||||
await _templates.GetHtmlAsync(EmailTemplateKind.Welcome, domainName, ct),
|
||||
tag: "welcome",
|
||||
ct);
|
||||
|
||||
if (sent)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Preview approval email sent to {ToEmail} for domain {Domain}",
|
||||
toEmail, domainName);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends the post-offboarding "sorry to see you go" farewell email.
|
||||
/// No-op if the API key or recipient email is not configured. Best-effort: failures
|
||||
/// are logged as warnings and never propagated (the offboarding correctness contract
|
||||
/// does not depend on email delivery).
|
||||
/// </summary>
|
||||
public async Task SendAsync(string toEmail, string domainName, string tenantId, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_apiKey))
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"{ConfigKey} not configured — skipping offboard farewell email for tenant {TenantId}",
|
||||
ApiKeyConfigKey, tenantId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(toEmail))
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"No notification email captured — skipping offboard farewell email for tenant {TenantId} ({Domain})",
|
||||
tenantId, domainName);
|
||||
return;
|
||||
}
|
||||
|
||||
var sent = await SendViaMandrillAsync(
|
||||
toEmail,
|
||||
EmailTemplates.OffboardingFarewellSubject,
|
||||
await _templates.GetHtmlAsync(EmailTemplateKind.Farewell, domainName, ct),
|
||||
tag: "offboarding-farewell",
|
||||
ct);
|
||||
|
||||
if (sent)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Offboard farewell email sent to {ToEmail} for tenant {TenantId} ({Domain})",
|
||||
toEmail, tenantId, domainName);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> SendTestAsync(EmailTemplateKind kind, string toEmail, string domainName, string? draftHtml, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_apiKey))
|
||||
{
|
||||
_logger.LogWarning("{ConfigKey} not configured — cannot send {Kind} test email", ApiKeyConfigKey, kind);
|
||||
return false;
|
||||
}
|
||||
|
||||
var html = draftHtml is null
|
||||
? await _templates.GetHtmlAsync(kind, domainName, ct)
|
||||
: EmailTemplateService.Render(draftHtml, domainName);
|
||||
|
||||
var sent = await SendViaMandrillAsync(toEmail, EmailTemplateService.Subject(kind), html, tag: "test", ct);
|
||||
if (sent)
|
||||
_logger.LogInformation("{Kind} test email sent to {ToEmail} (draft={IsDraft})", kind, toEmail, draftHtml is not null);
|
||||
return sent;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The only provider-specific code path. Posts a Mandrill <c>messages/send</c> request and
|
||||
/// interprets the per-recipient result array: <c>sent</c>/<c>queued</c>/<c>scheduled</c>
|
||||
/// count as success, <c>rejected</c>/<c>invalid</c> as failure (with the provider's
|
||||
/// <c>reject_reason</c> in the warning). Never throws.
|
||||
/// </summary>
|
||||
private async Task<bool> SendViaMandrillAsync(string toEmail, string subject, string html, string tag, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var request = new MandrillSendRequest
|
||||
{
|
||||
Key = _apiKey,
|
||||
Message = new MandrillMessage
|
||||
{
|
||||
FromEmail = _fromAddress,
|
||||
FromName = _fromName,
|
||||
To = new[] { new MandrillRecipient { Email = toEmail, Type = "to" } },
|
||||
Subject = subject,
|
||||
Html = html,
|
||||
AutoText = true,
|
||||
TrackOpens = false,
|
||||
TrackClicks = false,
|
||||
Tags = new[] { tag },
|
||||
},
|
||||
};
|
||||
|
||||
using var response = await _http.PostAsJsonAsync(_endpoint, request, JsonOptions, ct);
|
||||
var body = await response.Content.ReadAsStringAsync(ct);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Email provider returned {StatusCode} for {Tag} mail to {ToEmail}: {Body}",
|
||||
(int)response.StatusCode, tag, toEmail, Truncate(body));
|
||||
return false;
|
||||
}
|
||||
|
||||
var results = JsonSerializer.Deserialize<MandrillSendResult[]>(body, JsonOptions);
|
||||
var result = results?.FirstOrDefault();
|
||||
if (result is null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Email provider returned an empty result for {Tag} mail to {ToEmail}: {Body}",
|
||||
tag, toEmail, Truncate(body));
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!IsAcceptedStatus(result.Status))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Email provider did not accept {Tag} mail to {ToEmail}: status={Status} reason={Reason}",
|
||||
tag, toEmail, result.Status, result.RejectReason ?? "n/a");
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to send {Tag} mail to {ToEmail}", tag, toEmail);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsAcceptedStatus(string? status)
|
||||
=> status is "sent" or "queued" or "scheduled";
|
||||
|
||||
private static string FirstNonBlank(string? value, string fallback)
|
||||
=> string.IsNullOrWhiteSpace(value) ? fallback : value.Trim();
|
||||
|
||||
private static string Truncate(string value)
|
||||
=> value.Length <= 500 ? value : value[..500] + "…";
|
||||
|
||||
// --- Mandrill wire format (https://mailchimp.com/developer/transactional/api/messages/send-new-message/) ---
|
||||
|
||||
private sealed class MandrillSendRequest
|
||||
{
|
||||
[JsonPropertyName("key")] public string Key { get; set; } = string.Empty;
|
||||
[JsonPropertyName("message")] public MandrillMessage Message { get; set; } = new();
|
||||
}
|
||||
|
||||
private sealed class MandrillMessage
|
||||
{
|
||||
[JsonPropertyName("from_email")] public string FromEmail { get; set; } = string.Empty;
|
||||
[JsonPropertyName("from_name")] public string FromName { get; set; } = string.Empty;
|
||||
[JsonPropertyName("to")] public MandrillRecipient[] To { get; set; } = Array.Empty<MandrillRecipient>();
|
||||
[JsonPropertyName("subject")] public string Subject { get; set; } = string.Empty;
|
||||
[JsonPropertyName("html")] public string Html { get; set; } = string.Empty;
|
||||
[JsonPropertyName("auto_text")] public bool AutoText { get; set; }
|
||||
[JsonPropertyName("track_opens")] public bool TrackOpens { get; set; }
|
||||
[JsonPropertyName("track_clicks")] public bool TrackClicks { get; set; }
|
||||
[JsonPropertyName("tags")] public string[] Tags { get; set; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
private sealed class MandrillRecipient
|
||||
{
|
||||
[JsonPropertyName("email")] public string Email { get; set; } = string.Empty;
|
||||
[JsonPropertyName("type")] public string Type { get; set; } = "to";
|
||||
}
|
||||
|
||||
private sealed class MandrillSendResult
|
||||
{
|
||||
[JsonPropertyName("email")] public string? Email { get; set; }
|
||||
[JsonPropertyName("status")] public string? Status { get; set; }
|
||||
[JsonPropertyName("reject_reason")] public string? RejectReason { get; set; }
|
||||
[JsonPropertyName("_id")] public string? Id { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Services;
|
||||
|
||||
/// <summary>The two transactional mails the product sends.</summary>
|
||||
public enum EmailTemplateKind
|
||||
{
|
||||
Welcome,
|
||||
Farewell,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the HTML body for a transactional mail: the operator override when one is
|
||||
/// stored, otherwise the built-in template. Consumed by <see cref="EmailService"/>.
|
||||
/// </summary>
|
||||
public interface IEmailTemplateProvider
|
||||
{
|
||||
Task<string> GetHtmlAsync(EmailTemplateKind kind, string domainName, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Operator-editable email templates. Overrides live in the PreviewConfig table (partition
|
||||
/// "EmailTemplates", row key = kind) and are cached for 5 minutes; the built-in templates in
|
||||
/// <see cref="EmailTemplates"/> remain the fallback. Subjects are not editable — they are pinned
|
||||
/// constants.
|
||||
/// <para>
|
||||
/// Rendering contract: the only placeholder is <c>{{domainName}}</c>; an empty domain renders as
|
||||
/// "your organization", exactly like the built-ins. Resolution is fail-soft — a storage error
|
||||
/// falls back to the built-in template so activation/offboarding mails keep going out.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class EmailTemplateService : IEmailTemplateProvider
|
||||
{
|
||||
public const string DomainPlaceholder = "{{domainName}}";
|
||||
public const string EmptyDomainLabel = "your organization";
|
||||
/// <summary>Azure Table string properties cap at 32K UTF-16 chars; keep headroom.</summary>
|
||||
public const int MaxHtmlLength = 30_000;
|
||||
|
||||
private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(5);
|
||||
|
||||
private readonly IConfigRepository _configRepo;
|
||||
private readonly IMemoryCache _cache;
|
||||
private readonly ILogger<EmailTemplateService> _logger;
|
||||
|
||||
public EmailTemplateService(IConfigRepository configRepo, IMemoryCache cache, ILogger<EmailTemplateService> logger)
|
||||
{
|
||||
_configRepo = configRepo;
|
||||
_cache = cache;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public static bool TryParseKind(string? value, out EmailTemplateKind kind)
|
||||
{
|
||||
switch (value?.Trim().ToLowerInvariant())
|
||||
{
|
||||
case "welcome": kind = EmailTemplateKind.Welcome; return true;
|
||||
case "farewell": kind = EmailTemplateKind.Farewell; return true;
|
||||
default: kind = default; return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static string KindKey(EmailTemplateKind kind) => kind == EmailTemplateKind.Welcome ? "welcome" : "farewell";
|
||||
|
||||
public static string Subject(EmailTemplateKind kind) => kind == EmailTemplateKind.Welcome
|
||||
? EmailTemplates.PreviewApprovedSubject
|
||||
: EmailTemplates.OffboardingFarewellSubject;
|
||||
|
||||
/// <summary>Built-in template with the placeholder left in place (for the editor).</summary>
|
||||
public static string BuiltInRaw(EmailTemplateKind kind) => kind == EmailTemplateKind.Welcome
|
||||
? EmailTemplates.GetPreviewApprovedHtml(DomainPlaceholder)
|
||||
: EmailTemplates.GetOffboardingFarewellHtml(DomainPlaceholder);
|
||||
|
||||
/// <summary>Substitutes the placeholder; an empty domain becomes "your organization".</summary>
|
||||
public static string Render(string rawHtml, string? domainName)
|
||||
{
|
||||
var display = string.IsNullOrWhiteSpace(domainName) ? EmptyDomainLabel : domainName.Trim();
|
||||
return rawHtml.Replace(DomainPlaceholder, display, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>Returns null when the HTML is acceptable, otherwise the validation message.</summary>
|
||||
public static string? Validate(string? html)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(html)) return "Template HTML must not be empty.";
|
||||
if (html.Length > MaxHtmlLength) return $"Template HTML exceeds {MaxHtmlLength:N0} characters ({html.Length:N0}).";
|
||||
return null;
|
||||
}
|
||||
|
||||
public async Task<string> GetHtmlAsync(EmailTemplateKind kind, string domainName, CancellationToken ct = default)
|
||||
{
|
||||
var overrideEntry = await GetOverrideAsync(kind, failSoft: true);
|
||||
return overrideEntry is null
|
||||
? Render(BuiltInRaw(kind), domainName)
|
||||
: Render(overrideEntry.Html, domainName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The stored override, or null for built-in. <paramref name="failSoft"/> swallows storage
|
||||
/// errors (send path must never depend on the override store); the admin endpoints pass
|
||||
/// false so a broken store surfaces as an error instead of silently showing the built-in.
|
||||
/// </summary>
|
||||
public virtual async Task<EmailTemplateOverride?> GetOverrideAsync(EmailTemplateKind kind, bool failSoft = false)
|
||||
{
|
||||
var key = CacheKey(kind);
|
||||
if (_cache.TryGetValue(key, out EmailTemplateOverride? cached))
|
||||
return cached;
|
||||
|
||||
try
|
||||
{
|
||||
var entry = await _configRepo.GetEmailTemplateOverrideAsync(KindKey(kind));
|
||||
// null is a valid (and the common) value — cache it too so the send path
|
||||
// doesn't hit storage on every mail.
|
||||
_cache.Set(key, entry, CacheDuration);
|
||||
return entry;
|
||||
}
|
||||
catch (Exception ex) when (failSoft)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to read {Kind} email template override — using built-in template", kind);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public virtual async Task<EmailTemplateOverride> SaveOverrideAsync(EmailTemplateKind kind, string html, string updatedBy)
|
||||
{
|
||||
var error = Validate(html);
|
||||
if (error is not null) throw new ArgumentException(error, nameof(html));
|
||||
|
||||
var entry = new EmailTemplateOverride
|
||||
{
|
||||
Kind = KindKey(kind),
|
||||
Html = html,
|
||||
UpdatedBy = updatedBy,
|
||||
UpdatedUtc = DateTime.UtcNow,
|
||||
};
|
||||
await _configRepo.SaveEmailTemplateOverrideAsync(entry);
|
||||
_cache.Remove(CacheKey(kind));
|
||||
_logger.LogInformation("Email template override saved: {Kind} by {UpdatedBy} ({Length} chars)", kind, updatedBy, html.Length);
|
||||
return entry;
|
||||
}
|
||||
|
||||
public virtual async Task DeleteOverrideAsync(EmailTemplateKind kind, string deletedBy)
|
||||
{
|
||||
await _configRepo.DeleteEmailTemplateOverrideAsync(KindKey(kind));
|
||||
_cache.Remove(CacheKey(kind));
|
||||
_logger.LogInformation("Email template override reset to built-in: {Kind} by {DeletedBy}", kind, deletedBy);
|
||||
}
|
||||
|
||||
private static string CacheKey(EmailTemplateKind kind) => $"email-template-override:{KindKey(kind)}";
|
||||
}
|
||||
@@ -3,7 +3,7 @@ using AutopilotMonitor.Shared;
|
||||
namespace AutopilotMonitor.Functions.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Email templates for Resend notifications.
|
||||
/// Templates for the product's transactional emails (sent via <see cref="EmailService"/>).
|
||||
/// Keep all email content here for easy maintenance.
|
||||
/// </summary>
|
||||
public static class EmailTemplates
|
||||
@@ -37,12 +37,33 @@ public static class EmailTemplates
|
||||
<tr><td align=""center"">
|
||||
<table width=""600"" cellpadding=""0"" cellspacing=""0"" style=""background-color:#ffffff; border-radius:12px; overflow:hidden; box-shadow:0 4px 6px rgba(0,0,0,0.07);"">
|
||||
|
||||
<!-- Header -->
|
||||
<!-- Header: image-free. Base for every client: solid bgcolor band, text wordmark, brand mark
|
||||
drawn with CSS borders (trapezoid + a green wedge for the slanted right edge; must stay
|
||||
<div>s — Outlook paints transparent <td> borders white). Progressive enhancement where
|
||||
supported: ONE linear-gradient (hex colours, % stops — the only form Outlook web/iOS
|
||||
renders; multiple layers or rgba stops are dropped) forms the facets, and clip-path cuts
|
||||
the slanted bottom edge (Apple Mail only). No bgcolor inside the mark: Outlook dark mode
|
||||
inverts cell backgrounds but leaves border colours alone.
|
||||
Geometry is the approved reference (scratch header-render.html): facets
|
||||
light right of (40%,0)->(70%,100%), dark right of (55%,0)->(85%,100%) and left of
|
||||
(78%,0)->(100%,60%) — the last one is dropped for the single-gradient constraint.
|
||||
Change only against a side-by-side render. -->
|
||||
<tr>
|
||||
<td style=""background:linear-gradient(135deg,#1e8a4c,#33b161); padding:32px 40px; text-align:center;"">
|
||||
<h1 style=""color:#ffffff; margin:0; font-size:24px; font-weight:700;"">Autopilot Monitor</h1>
|
||||
<p style=""color:#c9efd8; margin:8px 0 0; font-size:14px;"">Windows Autopilot monitoring</p>
|
||||
</td>
|
||||
<td bgcolor=""#33b161"" style=""background-color:#33b161; background-image:linear-gradient(39.8deg, #33b161 0%, #33b161 53.8%, #3cbb6a 53.8%, #3cbb6a 65.4%, #2ea85a 65.4%, #2ea85a 100%); clip-path:polygon(0 0, 100% 0, 100% 100%, 0 82%); padding:28px 32px 52px; line-height:0; font-size:0;"">
|
||||
<table cellpadding=""0"" cellspacing=""0"" border=""0""><tr>
|
||||
<td valign=""middle"" style=""padding:0 12px 0 0; line-height:0; font-size:0;"">
|
||||
<!-- brand mark, 34x33, three non-overlapping rows (Outlook ignores negative margins and
|
||||
paints transparent td borders white, so: div triangles + one white td only) -->
|
||||
<div style=""width:0; height:0; border-left:31px solid #ffffff; border-top:13px solid transparent; border-right:0; border-bottom:0; display:block; font-size:0; line-height:0;""></div>
|
||||
<table cellpadding=""0"" cellspacing=""0"" border=""0"" style=""border-collapse:collapse;""><tr>
|
||||
<td style=""padding:0; font-size:0; line-height:0;""><div style=""width:0; height:18px; border-left:31px solid #ffffff; border-top:0; border-right:0; border-bottom:0; display:block; font-size:0; line-height:0;""></div></td>
|
||||
<td style=""padding:0; font-size:0; line-height:0;""><div style=""width:0; height:0; border-left:3px solid #ffffff; border-top:18px solid transparent; border-right:0; border-bottom:0; display:block; font-size:0; line-height:0;""></div></td>
|
||||
</tr></table>
|
||||
<div style=""width:0; height:0; border-right:34px solid #ffffff; border-bottom:2px solid transparent; border-left:0; border-top:0; display:block; font-size:0; line-height:0;""></div>
|
||||
</td>
|
||||
<td valign=""middle"" style=""color:#ffffff; font-size:22px; font-weight:600; letter-spacing:-0.2px; line-height:1; white-space:nowrap; font-family:-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;"">Autopilot Monitor</td>
|
||||
</tr></table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Body -->
|
||||
@@ -56,14 +77,14 @@ public static class EmailTemplates
|
||||
|
||||
<p style=""color:#374151; font-size:15px; line-height:1.6; margin:0 0 24px;"">
|
||||
Thank you for taking the time to try Autopilot Monitor. Every organization that
|
||||
puts it to work in a real environment helps shape what it becomes — we're
|
||||
puts it to work in a real environment helps shape what it becomes – we're
|
||||
genuinely grateful you gave it a chance.
|
||||
</p>
|
||||
|
||||
<!-- Gentle feedback ask -->
|
||||
<p style=""color:#374151; font-size:15px; line-height:1.6; margin:0 0 12px;"">
|
||||
If something didn't fit — a missing feature, a bug that got in the way, or
|
||||
simply a better alternative — we'd love to hear about it. A couple of honest
|
||||
If something didn't fit – a missing feature, a bug that got in the way, or
|
||||
simply a better alternative – we'd love to hear about it. A couple of honest
|
||||
sentences help more than you'd think:
|
||||
</p>
|
||||
|
||||
@@ -78,7 +99,7 @@ public static class EmailTemplates
|
||||
|
||||
<p style=""color:#6b7280; font-size:13px; line-height:1.6; margin:0;"">
|
||||
This is the last email you'll receive from us. This mailbox doesn't accept
|
||||
replies — please use the links above if you'd like to get in touch.
|
||||
replies – please use the links above if you'd like to get in touch.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -115,12 +136,33 @@ public static class EmailTemplates
|
||||
<tr><td align=""center"">
|
||||
<table width=""600"" cellpadding=""0"" cellspacing=""0"" style=""background-color:#ffffff; border-radius:12px; overflow:hidden; box-shadow:0 4px 6px rgba(0,0,0,0.07);"">
|
||||
|
||||
<!-- Header -->
|
||||
<!-- Header: image-free. Base for every client: solid bgcolor band, text wordmark, brand mark
|
||||
drawn with CSS borders (trapezoid + a green wedge for the slanted right edge; must stay
|
||||
<div>s — Outlook paints transparent <td> borders white). Progressive enhancement where
|
||||
supported: ONE linear-gradient (hex colours, % stops — the only form Outlook web/iOS
|
||||
renders; multiple layers or rgba stops are dropped) forms the facets, and clip-path cuts
|
||||
the slanted bottom edge (Apple Mail only). No bgcolor inside the mark: Outlook dark mode
|
||||
inverts cell backgrounds but leaves border colours alone.
|
||||
Geometry is the approved reference (scratch header-render.html): facets
|
||||
light right of (40%,0)->(70%,100%), dark right of (55%,0)->(85%,100%) and left of
|
||||
(78%,0)->(100%,60%) — the last one is dropped for the single-gradient constraint.
|
||||
Change only against a side-by-side render. -->
|
||||
<tr>
|
||||
<td style=""background:linear-gradient(135deg,#1e8a4c,#33b161); padding:32px 40px; text-align:center;"">
|
||||
<h1 style=""color:#ffffff; margin:0; font-size:24px; font-weight:700;"">Autopilot Monitor</h1>
|
||||
<p style=""color:#c9efd8; margin:8px 0 0; font-size:14px;"">Windows Autopilot monitoring</p>
|
||||
</td>
|
||||
<td bgcolor=""#33b161"" style=""background-color:#33b161; background-image:linear-gradient(39.8deg, #33b161 0%, #33b161 53.8%, #3cbb6a 53.8%, #3cbb6a 65.4%, #2ea85a 65.4%, #2ea85a 100%); clip-path:polygon(0 0, 100% 0, 100% 100%, 0 82%); padding:28px 32px 52px; line-height:0; font-size:0;"">
|
||||
<table cellpadding=""0"" cellspacing=""0"" border=""0""><tr>
|
||||
<td valign=""middle"" style=""padding:0 12px 0 0; line-height:0; font-size:0;"">
|
||||
<!-- brand mark, 34x33, three non-overlapping rows (Outlook ignores negative margins and
|
||||
paints transparent td borders white, so: div triangles + one white td only) -->
|
||||
<div style=""width:0; height:0; border-left:31px solid #ffffff; border-top:13px solid transparent; border-right:0; border-bottom:0; display:block; font-size:0; line-height:0;""></div>
|
||||
<table cellpadding=""0"" cellspacing=""0"" border=""0"" style=""border-collapse:collapse;""><tr>
|
||||
<td style=""padding:0; font-size:0; line-height:0;""><div style=""width:0; height:18px; border-left:31px solid #ffffff; border-top:0; border-right:0; border-bottom:0; display:block; font-size:0; line-height:0;""></div></td>
|
||||
<td style=""padding:0; font-size:0; line-height:0;""><div style=""width:0; height:0; border-left:3px solid #ffffff; border-top:18px solid transparent; border-right:0; border-bottom:0; display:block; font-size:0; line-height:0;""></div></td>
|
||||
</tr></table>
|
||||
<div style=""width:0; height:0; border-right:34px solid #ffffff; border-bottom:2px solid transparent; border-left:0; border-top:0; display:block; font-size:0; line-height:0;""></div>
|
||||
</td>
|
||||
<td valign=""middle"" style=""color:#ffffff; font-size:22px; font-weight:600; letter-spacing:-0.2px; line-height:1; white-space:nowrap; font-family:-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;"">Autopilot Monitor</td>
|
||||
</tr></table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Body -->
|
||||
@@ -129,8 +171,8 @@ public static class EmailTemplates
|
||||
<h2 style=""color:#111827; margin:0 0 16px; font-size:20px;"">Welcome to Autopilot Monitor!</h2>
|
||||
|
||||
<p style=""color:#374151; font-size:15px; line-height:1.6; margin:0 0 16px;"">
|
||||
Great news — access for <strong>{displayDomain}</strong> has been activated and is ready to use.
|
||||
You can now sign in and start monitoring your Windows Autopilot enrollments in real time.
|
||||
Great news – access for <strong>{displayDomain}</strong> has been activated and is ready to use.
|
||||
You can now <a href=""{Constants.PortalBaseUrl}"" target=""_blank"" style=""color:#1e8a4c; text-decoration:underline;"">sign in</a> and start monitoring your Windows Autopilot enrollments in real time.
|
||||
</p>
|
||||
|
||||
<p style=""color:#374151; font-size:15px; line-height:1.6; margin:0 0 24px;"">
|
||||
@@ -149,13 +191,6 @@ public static class EmailTemplates
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- Active development note -->
|
||||
<div style=""background-color:#fef3c7; border:1px solid #fde68a; border-radius:8px; padding:16px 20px; margin:0 0 24px;"">
|
||||
<p style=""color:#92400e; font-size:14px; line-height:1.5; margin:0;"">
|
||||
<strong>Please note:</strong> Autopilot Monitor is in active development. Some features are still being built
|
||||
and things may occasionally not work as expected. Your patience and understanding are greatly appreciated!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Feedback -->
|
||||
<p style=""color:#374151; font-size:15px; line-height:1.6; margin:0 0 12px;"">
|
||||
@@ -169,7 +204,7 @@ public static class EmailTemplates
|
||||
</ul>
|
||||
|
||||
<p style=""color:#6b7280; font-size:14px; line-height:1.6; margin:0;"">
|
||||
Thanks for being part of the journey — enjoy Autopilot Monitor!
|
||||
Thanks for being part of the journey – enjoy Autopilot Monitor!
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -92,6 +92,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
bool AccountSetupAllSucceeded,
|
||||
bool HasExplicitFailure,
|
||||
bool HasTerminalComplete,
|
||||
bool HasEnrollmentComplete,
|
||||
bool HasAgentEmergencyBreak,
|
||||
bool DesktopArrived,
|
||||
bool HelloResolved,
|
||||
@@ -113,7 +114,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
bool deviceAll = false;
|
||||
int acctBestN = 0, acctBestM = 0;
|
||||
bool acctFallbackAll = false;
|
||||
bool hasFailure = false, hasComplete = false, hasEmergencyBreak = false;
|
||||
bool hasFailure = false, hasComplete = false, hasEnrollmentComplete = false, hasEmergencyBreak = false;
|
||||
bool desktopArrived = false, helloResolved = false;
|
||||
bool realmJoinDetected = false, realmJoinResolved = false;
|
||||
bool sawHelloPolicyEnabled = false, sawHelloPolicyDisabled = false;
|
||||
@@ -135,7 +136,12 @@ namespace AutopilotMonitor.Functions.Services
|
||||
hasFailure = true;
|
||||
}
|
||||
else if (Eq(type, "enrollment_complete") || Eq(type, "whiteglove_complete"))
|
||||
{
|
||||
hasComplete = true;
|
||||
// whiteglove_complete only proves Part 1 (technician flow) — the WhiteGlove
|
||||
// awaiting-user gate needs to know whether a REAL enrollment completion exists.
|
||||
if (Eq(type, "enrollment_complete")) hasEnrollmentComplete = true;
|
||||
}
|
||||
else if (Eq(type, "agent_emergency_break"))
|
||||
hasEmergencyBreak = true;
|
||||
else if (Eq(type, "desktop_arrived"))
|
||||
@@ -217,6 +223,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
AccountSetupAllSucceeded: acctAll,
|
||||
HasExplicitFailure: hasFailure,
|
||||
HasTerminalComplete: hasComplete,
|
||||
HasEnrollmentComplete: hasEnrollmentComplete,
|
||||
HasAgentEmergencyBreak: hasEmergencyBreak,
|
||||
DesktopArrived: desktopArrived,
|
||||
HelloResolved: helloResolved,
|
||||
@@ -226,6 +233,74 @@ namespace AutopilotMonitor.Functions.Services
|
||||
RealmJoinResolved: realmJoinResolved);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// WhiteGlove Part-2 awaiting-user gate (fairstone.ca analysis 2026-08-21). After the
|
||||
/// reseal-reboot the session is BY DEFINITION in the user-driven final phase: the agent
|
||||
/// resumed (<c>whiteglove_resumed</c> → ResumedAt, the only writer of that column), the
|
||||
/// Part-2 Device ESP re-confirms in minutes, and the device sits at the logon screen.
|
||||
/// Technicians routinely power the machine off there to box it — well before the 10-min
|
||||
/// hybrid-login evidence window — so agent silence in this state means "sealed / powered
|
||||
/// off awaiting the end user", not a stall. The gate demands the full evidence set:
|
||||
/// pre-provisioned + resumed + Device Setup provisioned, and NO trace of a real user
|
||||
/// since the resume (no Account Setup progress, no real-user desktop, no Hello terminal,
|
||||
/// no enrollment completion) and no explicit failure. Any user evidence keeps the honest
|
||||
/// Stalled/timeout handling — a stall after the user showed up is a real stall.
|
||||
/// Shared by the maintenance sweep stage 1, the <c>session_stalled</c> ingest mapping and
|
||||
/// <see cref="ClassifyTimedOutSession"/> so all paths apply one evidence rule.
|
||||
/// </summary>
|
||||
public static bool IsWhiteGloveAwaitingUser(
|
||||
in EspProvisioningRollup rollup, bool isPreProvisioned, DateTime? resumedAt)
|
||||
=> isPreProvisioned
|
||||
&& resumedAt.HasValue
|
||||
&& rollup.DeviceSetupAllSucceeded
|
||||
&& !rollup.HasExplicitFailure
|
||||
&& !rollup.HasEnrollmentComplete
|
||||
&& rollup.AccountSetupSucceededCount == 0
|
||||
&& !rollup.DesktopArrived
|
||||
&& !rollup.HelloResolved;
|
||||
|
||||
/// <summary>
|
||||
/// Customer-facing reason for a WhiteGlove Part-2 session parked as AwaitingUser by the
|
||||
/// silence/stall paths. One authoring spot so the sweep, the ingest mapping and the
|
||||
/// timeout classifier show the identical text.
|
||||
/// </summary>
|
||||
public static string WhiteGloveAwaitingUserReason(in EspProvisioningRollup rollup)
|
||||
{
|
||||
var acct = rollup.AccountSetupTotal > 0
|
||||
? $" (Account Setup {rollup.AccountSetupSucceededCount}/{rollup.AccountSetupTotal})"
|
||||
: " (Account Setup not yet started)";
|
||||
return "Pre-provisioning (WhiteGlove) completed; no user sign-in since Part 2 resumed — " +
|
||||
$"device presumably sealed or powered off awaiting the end user{acct}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Self-deploying (kiosk / shared-device) profile gate. Such a profile has NO user phase:
|
||||
/// Device ESP reaching all-succeeded IS the enrollment's end, and the agent's own
|
||||
/// SelfDeploying terminal only adds a 5-min confirmation window on top of it. When the
|
||||
/// agent goes silent after that point (reboot into the kiosk autologon, powered off and
|
||||
/// boxed for the school term, WiFi never re-associating for the relaunch — tenant
|
||||
/// aebdce78, 836 sessions 2026-08-17..21), "awaiting user / Account Setup" is a factual
|
||||
/// error and the later Incomplete("Account Setup 0/5") is worse: nobody will ever sign
|
||||
/// in, the devices are finished and in service. Demands the registration-time
|
||||
/// registry-confirmed profile flag (CloudAssignedOobeConfig 0x20|0x40), Device Setup
|
||||
/// all-succeeded and no explicit failure. Shared by the sweep (stage 1 + 2), the
|
||||
/// max-lifetime ingest verdict and the retro-reclassification so one evidence rule applies.
|
||||
/// </summary>
|
||||
public static bool IsSelfDeployingProvisioned(in EspProvisioningRollup rollup, bool isSelfDeployingProfile)
|
||||
=> isSelfDeployingProfile
|
||||
&& rollup.DeviceSetupAllSucceeded
|
||||
&& !rollup.HasExplicitFailure;
|
||||
|
||||
/// <summary>
|
||||
/// Customer-facing Succeeded-reconcile reason for <see cref="IsSelfDeployingProvisioned"/>.
|
||||
/// Carries the silence-transparency clause like every other reconcile reason.
|
||||
/// </summary>
|
||||
public static string SelfDeployingReconcileReason(DateTime startedAtUtc, DateTime nowUtc, DateTime? lastEventAtUtc)
|
||||
=> AppendReconcileTiming(
|
||||
"Reconciled: self-deploying profile — Device Setup fully provisioned (this profile has no user / Account Setup phase); " +
|
||||
"agent went silent before confirming completion",
|
||||
startedAtUtc, nowUtc, lastEventAtUtc);
|
||||
|
||||
/// <summary>
|
||||
/// Decide the honest terminal (or non-terminal) state for a timed-out session. See the
|
||||
/// decision table in the design note. Returns the target status and a human-readable
|
||||
@@ -242,17 +317,55 @@ namespace AutopilotMonitor.Functions.Services
|
||||
/// mechanism declared success too early" — session efbc17ff. Falls back to
|
||||
/// <paramref name="startedAtUtc"/> when unknown (same fallback the stalled-marker uses).
|
||||
/// </param>
|
||||
/// <param name="isPreProvisioned">Session's IsPreProvisioned flag (WhiteGlove Part-2 gate input).</param>
|
||||
/// <param name="resumedAt">Session's ResumedAt — set exclusively by the whiteglove_resumed ingest.</param>
|
||||
/// <param name="isSelfDeployingProfile">Session's IsSelfDeployingProfile flag (rule 1c gate input).</param>
|
||||
public static (SessionStatus Status, string Reason) ClassifyTimedOutSession(
|
||||
EspProvisioningRollup rollup, DateTime startedAtUtc, DateTime nowUtc, int graceHours,
|
||||
DateTime? lastEventAtUtc = null)
|
||||
DateTime? lastEventAtUtc = null, bool isPreProvisioned = false, DateTime? resumedAt = null,
|
||||
bool isSelfDeployingProfile = false)
|
||||
{
|
||||
// 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).
|
||||
if (rollup.HasExplicitFailure)
|
||||
return (SessionStatus.Failed, "Enrollment reported an explicit failure before timeout");
|
||||
|
||||
// 1b. WhiteGlove Part 2 with zero user evidence since the resume: the device is parked
|
||||
// between technician and end user (sealed box / powered off at the logon screen).
|
||||
// Must run BEFORE rule 2 — the Part-1 whiteglove_complete in the same event stream
|
||||
// sets HasTerminalComplete and would otherwise reconcile the session to Succeeded
|
||||
// with a reason claiming Account Setup completed, which never happened. Within
|
||||
// grace the honest label is AwaitingUser; past grace the Part-1 success stands as
|
||||
// the session outcome (the user phase is tracked by the session the device
|
||||
// registers when it is finally unboxed), stated with an honest WhiteGlove reason.
|
||||
if (IsWhiteGloveAwaitingUser(rollup, isPreProvisioned, resumedAt))
|
||||
{
|
||||
if ((nowUtc - startedAtUtc).TotalHours < graceHours)
|
||||
return (SessionStatus.AwaitingUser, WhiteGloveAwaitingUserReason(rollup));
|
||||
|
||||
return (SessionStatus.Succeeded, AppendReconcileTiming(
|
||||
$"Reconciled at timeout: pre-provisioning (WhiteGlove Part 1) completed — no user sign-in observed within {graceHours}h grace",
|
||||
startedAtUtc, nowUtc, lastEventAtUtc));
|
||||
}
|
||||
|
||||
// 1c. Self-deploying profile with Device Setup fully provisioned: there is no user phase
|
||||
// to wait for, so neither AwaitingUser nor the grace window apply — the device is
|
||||
// done (see IsSelfDeployingProvisioned). Runs before rule 2 so the reason names the
|
||||
// profile instead of claiming "Account Setup completed"; real user evidence cannot
|
||||
// contradict it (a kiosk autologon is not a user) and an explicit failure already
|
||||
// returned in rule 1.
|
||||
if (IsSelfDeployingProvisioned(rollup, isSelfDeployingProfile))
|
||||
return (SessionStatus.Succeeded, SelfDeployingReconcileReason(startedAtUtc, nowUtc, lastEventAtUtc));
|
||||
|
||||
// 2. Account Setup fully succeeded (or a terminal completion event) → reconcile to success.
|
||||
if (rollup.AccountSetupAllSucceeded || rollup.HasTerminalComplete)
|
||||
// Once Part 2 has resumed, the Part-1 whiteglove_complete in the same stream no longer
|
||||
// proves the session's outcome — only a real enrollment_complete does. Without this,
|
||||
// a resumed session with partial user progress (e.g. Account Setup 1/5, then silence)
|
||||
// reconciled to Succeeded on Part-1 evidence, with a reason claiming Account Setup
|
||||
// completed; rules 4-6 below hold the honest verdicts for that shape.
|
||||
var terminalCompleteProvesOutcome = rollup.HasEnrollmentComplete
|
||||
|| (rollup.HasTerminalComplete && !resumedAt.HasValue);
|
||||
if (rollup.AccountSetupAllSucceeded || terminalCompleteProvesOutcome)
|
||||
return (SessionStatus.Succeeded, AppendReconcileTiming(
|
||||
"Reconciled at timeout: Account Setup completed (all subcategories succeeded / enrollment_complete observed)",
|
||||
startedAtUtc, nowUtc, lastEventAtUtc));
|
||||
|
||||
+68
-3
@@ -14,7 +14,15 @@ namespace AutopilotMonitor.Functions.Services
|
||||
|
||||
foreach (var evt in storedEvents)
|
||||
{
|
||||
if (!classification.EarliestEventTimestamp.HasValue || evt.Timestamp < classification.EarliestEventTimestamp.Value)
|
||||
// System-timeline-watcher rows (clock steps, sleep episodes) are backfilled
|
||||
// from the Windows event log with original timestamps up to 24h before the
|
||||
// agent started — environment observation, not enrollment activity. They must
|
||||
// not pull the session anchor toward pre-enrollment OOBE idle time (mirrors
|
||||
// TableStorageService.IsSessionAnchorEligible on the Events-table probe;
|
||||
// field case d0c5b672).
|
||||
var anchorEligible = evt.Source != "SystemTimelineWatcher";
|
||||
if (anchorEligible &&
|
||||
(!classification.EarliestEventTimestamp.HasValue || evt.Timestamp < classification.EarliestEventTimestamp.Value))
|
||||
classification.EarliestEventTimestamp = evt.Timestamp;
|
||||
if (!classification.LatestEventTimestamp.HasValue || evt.Timestamp > classification.LatestEventTimestamp.Value)
|
||||
classification.LatestEventTimestamp = evt.Timestamp;
|
||||
@@ -279,7 +287,9 @@ namespace AutopilotMonitor.Functions.Services
|
||||
|
||||
var rollup = EnrollmentTimeoutClassifier.ExtractRollup(sessionEvents);
|
||||
var (targetStatus, reason) = EnrollmentTimeoutClassifier.ClassifyTimedOutSession(
|
||||
rollup, effectiveStart, now, graceHours, session?.LastEventAt ?? c.LatestEventTimestamp);
|
||||
rollup, effectiveStart, now, graceHours, session?.LastEventAt ?? c.LatestEventTimestamp,
|
||||
isPreProvisioned: session?.IsPreProvisioned == true, resumedAt: session?.ResumedAt,
|
||||
isSelfDeployingProfile: session?.IsSelfDeployingProfile == true);
|
||||
|
||||
// Keep the max-lifetime trigger visible: the Succeeded reasons already carry their own
|
||||
// silence-transparency clause, everything else gets the watchdog context appended.
|
||||
@@ -348,7 +358,9 @@ namespace AutopilotMonitor.Functions.Services
|
||||
var now = DateTime.UtcNow;
|
||||
var effectiveStart = session.ResumedAt ?? session.StartedAt;
|
||||
var (targetStatus, reason) = EnrollmentTimeoutClassifier.ClassifyTimedOutSession(
|
||||
rollup, effectiveStart, now, graceHours, session.LastEventAt ?? c.LatestEventTimestamp);
|
||||
rollup, effectiveStart, now, graceHours, session.LastEventAt ?? c.LatestEventTimestamp,
|
||||
isPreProvisioned: session.IsPreProvisioned, resumedAt: session.ResumedAt,
|
||||
isSelfDeployingProfile: session.IsSelfDeployingProfile);
|
||||
|
||||
if (targetStatus != SessionStatus.Succeeded)
|
||||
return;
|
||||
@@ -368,6 +380,49 @@ namespace AutopilotMonitor.Functions.Services
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// WhiteGlove Part-2 awaiting-user gate for the agent-reported stall
|
||||
/// (<c>session_stalled</c>). Pre-provisioned sessions with a ResumedAt whose event
|
||||
/// stream shows no user evidence since the resume are parked as AwaitingUser instead of
|
||||
/// Stalled — same evidence rule as the maintenance sweep
|
||||
/// (<see cref="EnrollmentTimeoutClassifier.IsWhiteGloveAwaitingUser"/>). Returns true
|
||||
/// when the AwaitingUser write was applied; false (also on any error, fail-soft) lets
|
||||
/// the caller run the normal Stalled mapping.
|
||||
/// </summary>
|
||||
private async Task<bool> TryMapStallToWhiteGloveAwaitingUserAsync(
|
||||
IngestEventsRequest request, string sessionPrefix, EventClassification c)
|
||||
{
|
||||
try
|
||||
{
|
||||
var session = await _sessionRepo.GetSessionAsync(request.TenantId, request.SessionId);
|
||||
if (session?.IsPreProvisioned != true || !session.ResumedAt.HasValue)
|
||||
return false;
|
||||
|
||||
var sessionEvents = await _sessionRepo.GetSessionEventsAsync(
|
||||
request.TenantId, request.SessionId, maxResults: 1000);
|
||||
var rollup = EnrollmentTimeoutClassifier.ExtractRollup(sessionEvents);
|
||||
if (!EnrollmentTimeoutClassifier.IsWhiteGloveAwaitingUser(
|
||||
rollup, isPreProvisioned: true, session.ResumedAt))
|
||||
return false;
|
||||
|
||||
var transitioned = await _sessionRepo.UpdateSessionStatusAsync(
|
||||
request.TenantId, request.SessionId, SessionStatus.AwaitingUser,
|
||||
earliestEventTimestamp: c.EarliestEventTimestamp, latestEventTimestamp: c.LatestEventTimestamp,
|
||||
failureReason: EnrollmentTimeoutClassifier.WhiteGloveAwaitingUserReason(rollup));
|
||||
if (transitioned)
|
||||
_logger.LogInformation(
|
||||
"{SessionPrefix} Status: AwaitingUser (agent stall probe on WhiteGlove Part 2 with no user evidence since resume)",
|
||||
sessionPrefix);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"{SessionPrefix} WhiteGlove awaiting-user gate failed for session_stalled; falling back to Stalled", sessionPrefix);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsPeriodicOrStallEvent(string? eventType) => eventType switch
|
||||
{
|
||||
"performance_snapshot" => true,
|
||||
@@ -559,6 +614,16 @@ namespace AutopilotMonitor.Functions.Services
|
||||
|
||||
if (c.SessionStalledEvent != null)
|
||||
{
|
||||
// WhiteGlove Part-2 awaiting-user gate (fairstone.ca analysis 2026-08-21): the
|
||||
// agent-side stall probe also fires on a resumed Part-2 device that simply sits
|
||||
// at the logon screen with nobody signing in. With zero user evidence since the
|
||||
// resume that is the expected parking state between technician and end user, so
|
||||
// the honest label is AwaitingUser, not Stalled. Rare path (one probe batch per
|
||||
// stalling session), so the session + events reads are acceptable; fail-soft —
|
||||
// any error falls back to the plain Stalled mapping.
|
||||
if (await TryMapStallToWhiteGloveAwaitingUserAsync(request, sessionPrefix, c))
|
||||
return (statusTransitioned, whiteGloveStatusTransitioned, failureReason);
|
||||
|
||||
var stalledReason = "Agent reported stall after 60min without progress (stall_probe)";
|
||||
var stalledTransitioned = await _sessionRepo.UpdateSessionStatusAsync(
|
||||
request.TenantId, request.SessionId, SessionStatus.Stalled,
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
namespace AutopilotMonitor.Functions.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Provider-neutral transactional email contract for the tenant-activation welcome mail.
|
||||
/// <para>
|
||||
/// Always fail-soft — implementations must NOT throw; activation correctness never depends
|
||||
/// on email delivery (the Global Admin "Send welcome email" button is the manual fallback).
|
||||
/// The farewell mail after offboarding lives on the sibling
|
||||
/// <see cref="IOffboardFarewellEmailSender"/> contract; both are served by <see cref="EmailService"/>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public interface IEmailService
|
||||
{
|
||||
/// <summary>
|
||||
/// Sends the tenant-activation welcome email. No-op when the provider is not configured
|
||||
/// or the recipient is empty.
|
||||
/// </summary>
|
||||
Task SendPreviewApprovedEmailAsync(string toEmail, string domainName, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Operator test send: the effective template of <paramref name="kind"/> (or an unsaved
|
||||
/// <paramref name="draftHtml"/>, placeholder-rendered) to <paramref name="toEmail"/>.
|
||||
/// Returns true when the provider accepted the message. Never throws.
|
||||
/// </summary>
|
||||
Task<bool> SendTestAsync(EmailTemplateKind kind, string toEmail, string domainName, string? draftHtml, CancellationToken ct = default);
|
||||
}
|
||||
@@ -18,6 +18,11 @@ namespace AutopilotMonitor.Functions.Services
|
||||
/// registered a LATER session (Part 2 under a fresh session id, or re-provisioning) are
|
||||
/// resolved as Incomplete("Superseded by ..."). Forward-looking coverage lives in the
|
||||
/// registration supersede pass; this mode clears the existing backlog.</item>
|
||||
/// <item><b>self_deploying_silent</b> — self-deploying-profile rows parked as
|
||||
/// Incomplete / AwaitingUser / Stalled by the pre-2026-08-23 classifier (which had no
|
||||
/// notion of a profile without a user phase) are re-run through the classifier; only a
|
||||
/// Succeeded verdict (<see cref="EnrollmentTimeoutClassifier.IsSelfDeployingProvisioned"/>)
|
||||
/// is applied, everything else keeps its verdict.</item>
|
||||
/// </list>
|
||||
/// Hand-marked sessions (AdminMarkedAction) are never touched.
|
||||
/// </summary>
|
||||
@@ -30,6 +35,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
|
||||
public const string ModeLegacyTimeouts = "legacy_timeouts";
|
||||
public const string ModePendingOrphans = "pending_orphans";
|
||||
public const string ModeSelfDeployingSilent = "self_deploying_silent";
|
||||
private const int MaxSamplesInResponse = 100;
|
||||
|
||||
public LegacyReclassificationService(
|
||||
@@ -109,7 +115,9 @@ namespace AutopilotMonitor.Functions.Services
|
||||
var rollup = EnrollmentTimeoutClassifier.ExtractRollup(events);
|
||||
var effectiveStart = session.ResumedAt ?? session.StartedAt;
|
||||
var (target, reason) = EnrollmentTimeoutClassifier.ClassifyTimedOutSession(
|
||||
rollup, effectiveStart, now, graceHours, session.LastEventAt);
|
||||
rollup, effectiveStart, now, graceHours, session.LastEventAt,
|
||||
isPreProvisioned: session.IsPreProvisioned, resumedAt: session.ResumedAt,
|
||||
isSelfDeployingProfile: session.IsSelfDeployingProfile);
|
||||
|
||||
if (target == SessionStatus.Failed)
|
||||
{
|
||||
@@ -164,6 +172,99 @@ namespace AutopilotMonitor.Functions.Services
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<ReclassificationResult> ReconcileSelfDeployingSilentAsync(
|
||||
string? tenantIdScope, bool dryRun, int maxSessions, string triggeredBy)
|
||||
{
|
||||
var result = new ReclassificationResult { Mode = ModeSelfDeployingSilent, DryRun = dryRun };
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
foreach (var tenantId in await ResolveTenantsAsync(tenantIdScope))
|
||||
{
|
||||
if (result.SessionsExamined >= maxSessions) { result.CapReached = true; break; }
|
||||
result.TenantsExamined++;
|
||||
|
||||
int? tenantGraceHours = null, absoluteMaxHours = null;
|
||||
try
|
||||
{
|
||||
var config = await _configService.GetConfigurationAsync(tenantId);
|
||||
tenantGraceHours = config?.SessionGraceHours;
|
||||
absoluteMaxHours = config?.AbsoluteMaxSessionHours;
|
||||
}
|
||||
catch { /* defaults */ }
|
||||
var graceHours = EnrollmentTimeoutClassifier.ResolveGraceHours(tenantGraceHours, absoluteMaxHours);
|
||||
|
||||
var candidates = await _maintenanceRepo.GetSelfDeployingSilentSessionsAsync(
|
||||
tenantId, maxSessions - result.SessionsExamined + 1);
|
||||
if (candidates.Count > maxSessions - result.SessionsExamined)
|
||||
{
|
||||
result.CapReached = true;
|
||||
candidates = candidates.Take(maxSessions - result.SessionsExamined).ToList();
|
||||
}
|
||||
|
||||
foreach (var session in candidates)
|
||||
{
|
||||
result.SessionsExamined++;
|
||||
try
|
||||
{
|
||||
if (!string.IsNullOrEmpty(session.AdminMarkedAction))
|
||||
{
|
||||
result.Skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var events = await _sessionRepo.GetSessionEventsAsync(tenantId, session.SessionId, maxResults: 1000);
|
||||
var rollup = EnrollmentTimeoutClassifier.ExtractRollup(events);
|
||||
var effectiveStart = session.ResumedAt ?? session.StartedAt;
|
||||
var (target, reason) = EnrollmentTimeoutClassifier.ClassifyTimedOutSession(
|
||||
rollup, effectiveStart, now, graceHours, session.LastEventAt,
|
||||
isPreProvisioned: session.IsPreProvisioned, resumedAt: session.ResumedAt,
|
||||
isSelfDeployingProfile: session.IsSelfDeployingProfile);
|
||||
|
||||
// Heal-only: a verdict other than Succeeded means the device never reached
|
||||
// Device ESP all-succeeded (or failed explicitly) — the existing label stands.
|
||||
if (target != SessionStatus.Succeeded)
|
||||
{
|
||||
result.Skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
RecordSample(result, tenantId, session, target, reason, oldStatus: session.Status.ToString());
|
||||
|
||||
if (dryRun)
|
||||
{
|
||||
result.WouldChange++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Succeeded may upgrade Incomplete / AwaitingUser / Stalled through the normal
|
||||
// reconcile gate (no terminal-reclassification override needed); the storage
|
||||
// layer additionally refuses admin-marked rows.
|
||||
var transitioned = await _sessionRepo.UpdateSessionStatusAsync(
|
||||
tenantId, session.SessionId, SessionStatus.Succeeded,
|
||||
failureReason: reason);
|
||||
if (transitioned)
|
||||
{
|
||||
result.Changed++;
|
||||
result.ToSucceeded++;
|
||||
}
|
||||
else
|
||||
{
|
||||
result.Skipped++;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
result.Errors++;
|
||||
_logger.LogWarning(ex, "Self-deploying retro-reconcile failed for session {SessionId} (tenant {TenantId})",
|
||||
session.SessionId, tenantId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await WriteAuditAsync(result, tenantIdScope, triggeredBy);
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<ReclassificationResult> ResolvePendingOrphansAsync(
|
||||
string? tenantIdScope, bool dryRun, int maxSessions, string triggeredBy)
|
||||
{
|
||||
|
||||
@@ -349,6 +349,8 @@ namespace AutopilotMonitor.Functions.Services
|
||||
tenantId, silenceCutoff, hardCutoff: cutoffTime);
|
||||
|
||||
int silentMarked = 0;
|
||||
int silentAwaitingUser = 0;
|
||||
int silentSelfDeployingReconciled = 0;
|
||||
foreach (var silent in silentSessions)
|
||||
{
|
||||
// The query is a permissive two-frame pre-filter; the decision is made
|
||||
@@ -360,6 +362,31 @@ namespace AutopilotMonitor.Functions.Services
|
||||
if (lastContactAt > silenceCutoff)
|
||||
continue; // provably alive on the server clock
|
||||
|
||||
// WhiteGlove Part-2 awaiting-user gate (fairstone.ca analysis
|
||||
// 2026-08-21): after the reseal-reboot the technician routinely powers
|
||||
// the device off at the logon screen to box it — that silence is the
|
||||
// expected parking state between technician and end user, not a stall.
|
||||
// Only pre-provisioned sessions with a ResumedAt pay the event read;
|
||||
// any user evidence since the resume falls through to Stalled.
|
||||
if (silent.IsPreProvisioned && silent.ResumedAt.HasValue
|
||||
&& await TryMarkWhiteGloveAwaitingUserAsync(silent))
|
||||
{
|
||||
silentAwaitingUser++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Self-deploying profile gate (kiosk tenant aebdce78, 2026-08-23): a
|
||||
// silent agent after Device ESP all-succeeded is a finished device
|
||||
// (rebooted into the kiosk autologon / boxed), not a stall and never
|
||||
// "awaiting user" — reconcile to Succeeded right here instead of
|
||||
// routing it through Stalled → AwaitingUser → Incomplete.
|
||||
if (silent.IsSelfDeployingProfile
|
||||
&& await TryReconcileSelfDeployingAsync(silent, lastContactAt, now))
|
||||
{
|
||||
silentSelfDeployingReconciled++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var silentMinutes = (int)(now - lastContactAt).TotalMinutes;
|
||||
await _sessionRepo.UpdateSessionStatusAsync(
|
||||
silent.TenantId,
|
||||
@@ -370,19 +397,21 @@ namespace AutopilotMonitor.Functions.Services
|
||||
silentMarked++;
|
||||
}
|
||||
|
||||
if (silentMarked > 0)
|
||||
if (silentMarked > 0 || silentAwaitingUser > 0 || silentSelfDeployingReconciled > 0)
|
||||
{
|
||||
totalSessionsMarkedStalled += silentMarked;
|
||||
_logger.LogInformation($"Tenant {tenantId}: Marked {silentMarked} agent-silent sessions as Stalled (silence threshold: {agentSilenceHours}h)");
|
||||
_logger.LogInformation($"Tenant {tenantId}: Marked {silentMarked} agent-silent sessions as Stalled, {silentAwaitingUser} as AwaitingUser (WhiteGlove Part 2), {silentSelfDeployingReconciled} reconciled to Succeeded (self-deploying; silence threshold: {agentSilenceHours}h)");
|
||||
await _maintenanceRepo.LogAuditEntryAsync(
|
||||
tenantId,
|
||||
"SessionStalled",
|
||||
"Session",
|
||||
$"{silentMarked} sessions",
|
||||
$"{silentMarked + silentAwaitingUser + silentSelfDeployingReconciled} sessions",
|
||||
"System.Maintenance",
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
{ "SessionsMarkedStalled", silentMarked.ToString() },
|
||||
{ "SessionsAwaitingUser", silentAwaitingUser.ToString() },
|
||||
{ "SessionsSelfDeployingReconciled", silentSelfDeployingReconciled.ToString() },
|
||||
{ "AgentSilenceHours", agentSilenceHours.ToString() },
|
||||
{ "SilenceCutoff", silenceCutoff.ToString("yyyy-MM-ddTHH:mm:ss") }
|
||||
});
|
||||
@@ -454,7 +483,9 @@ 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, effectiveStart, now, graceHours, session.LastEventAt);
|
||||
rollup, effectiveStart, now, graceHours, session.LastEventAt,
|
||||
isPreProvisioned: session.IsPreProvisioned, resumedAt: session.ResumedAt,
|
||||
isSelfDeployingProfile: session.IsSelfDeployingProfile);
|
||||
|
||||
// No-op if the verdict equals the current (non-terminal) state.
|
||||
if (targetStatus == session.Status)
|
||||
@@ -569,6 +600,81 @@ namespace AutopilotMonitor.Functions.Services
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies the WhiteGlove Part-2 awaiting-user gate to one agent-silent candidate
|
||||
/// (pre-provisioned + ResumedAt set — the caller pre-filters so only WhiteGlove Part-2
|
||||
/// runs pay the event read). Reads the session's events, distills the classifier rollup
|
||||
/// and, when <see cref="EnrollmentTimeoutClassifier.IsWhiteGloveAwaitingUser"/> confirms
|
||||
/// there is no user evidence since the resume, parks the session as AwaitingUser instead
|
||||
/// of Stalled. Returns true when the AwaitingUser write happened; false (fail-soft, also
|
||||
/// on read errors) lets the caller fall through to the normal Stalled marker.
|
||||
/// </summary>
|
||||
private async Task<bool> TryMarkWhiteGloveAwaitingUserAsync(SessionSummary silent)
|
||||
{
|
||||
try
|
||||
{
|
||||
var events = await _sessionRepo.GetSessionEventsAsync(
|
||||
silent.TenantId, silent.SessionId, maxResults: 1000);
|
||||
var rollup = EnrollmentTimeoutClassifier.ExtractRollup(events);
|
||||
if (!EnrollmentTimeoutClassifier.IsWhiteGloveAwaitingUser(
|
||||
rollup, silent.IsPreProvisioned, silent.ResumedAt))
|
||||
return false;
|
||||
|
||||
var transitioned = await _sessionRepo.UpdateSessionStatusAsync(
|
||||
silent.TenantId,
|
||||
silent.SessionId,
|
||||
SessionStatus.AwaitingUser,
|
||||
failureReason: EnrollmentTimeoutClassifier.WhiteGloveAwaitingUserReason(rollup));
|
||||
if (transitioned)
|
||||
_logger.LogInformation(
|
||||
$"Session {silent.SessionId}: WhiteGlove Part 2 silent with no user evidence — parked as AwaitingUser instead of Stalled");
|
||||
return transitioned;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
$"WhiteGlove awaiting-user gate failed for session {silent.SessionId}; falling back to Stalled marker");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies the self-deploying gate to one agent-silent candidate (the caller pre-filters on
|
||||
/// <see cref="SessionSummary.IsSelfDeployingProfile"/> so only those pay the event read).
|
||||
/// When <see cref="EnrollmentTimeoutClassifier.IsSelfDeployingProvisioned"/> confirms Device
|
||||
/// ESP all-succeeded with no explicit failure, reconciles the session to Succeeded with the
|
||||
/// shared honest reason. Returns true when the write happened; false (fail-soft, also on
|
||||
/// read errors) lets the caller fall through to the normal Stalled marker.
|
||||
/// </summary>
|
||||
private async Task<bool> TryReconcileSelfDeployingAsync(SessionSummary silent, DateTime lastContactAt, DateTime now)
|
||||
{
|
||||
try
|
||||
{
|
||||
var events = await _sessionRepo.GetSessionEventsAsync(
|
||||
silent.TenantId, silent.SessionId, maxResults: 1000);
|
||||
var rollup = EnrollmentTimeoutClassifier.ExtractRollup(events);
|
||||
if (!EnrollmentTimeoutClassifier.IsSelfDeployingProvisioned(rollup, silent.IsSelfDeployingProfile))
|
||||
return false;
|
||||
|
||||
var transitioned = await _sessionRepo.UpdateSessionStatusAsync(
|
||||
silent.TenantId,
|
||||
silent.SessionId,
|
||||
SessionStatus.Succeeded,
|
||||
failureReason: EnrollmentTimeoutClassifier.SelfDeployingReconcileReason(
|
||||
silent.ResumedAt ?? silent.StartedAt, now, lastContactAt));
|
||||
if (transitioned)
|
||||
_logger.LogInformation(
|
||||
$"Session {silent.SessionId}: self-deploying profile silent after Device Setup provisioned — reconciled to Succeeded instead of Stalled");
|
||||
return transitioned;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
$"Self-deploying reconcile gate failed for session {silent.SessionId}; falling back to Stalled marker");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the server-authored <c>session_timeout</c> event injected into the stream when the
|
||||
/// maintenance sweep graduates a stalled session to terminal Failed. Static + pure so the field
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using AutopilotMonitor.Shared.Models.Notifications;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Services.Notifications
|
||||
{
|
||||
/// <summary>
|
||||
/// Renders a NotificationAlert as a Discord webhook payload (single embed).
|
||||
/// Discord webhooks cannot post buttons, so openUrl actions are appended to the
|
||||
/// embed description as markdown links.
|
||||
/// </summary>
|
||||
public class DiscordRenderer : INotificationRenderer
|
||||
{
|
||||
// Discord embed limits: https://discord.com/developers/docs/resources/message#embed-object-embed-limits
|
||||
private const int MaxTitleLength = 256;
|
||||
private const int MaxDescriptionLength = 4096;
|
||||
private const int MaxFields = 25;
|
||||
private const int MaxFieldNameLength = 256;
|
||||
private const int MaxFieldValueLength = 1024;
|
||||
private const int MaxEmbedTotalLength = 6000;
|
||||
|
||||
public WebhookProviderType ProviderType => WebhookProviderType.Discord;
|
||||
|
||||
public string RenderToJson(NotificationAlert alert)
|
||||
{
|
||||
var title = Truncate($"{GetSeverityEmoji(alert.Severity)} {alert.Title}", MaxTitleLength);
|
||||
var description = Truncate(BuildDescription(alert), MaxDescriptionLength);
|
||||
|
||||
// Facts as inline fields. Discord rejects fields with an empty name or value.
|
||||
var facts = alert.Facts
|
||||
.Where(f => !string.IsNullOrWhiteSpace(f.Name) && !string.IsNullOrWhiteSpace(f.Value))
|
||||
.Take(MaxFields)
|
||||
.Select(f => (Name: Truncate(f.Name, MaxFieldNameLength), Value: Truncate(f.Value, MaxFieldValueLength)))
|
||||
.ToList();
|
||||
|
||||
// Enforce the 6000-char total cap across title + description + fields:
|
||||
// drop trailing fields first, then shrink the description to the remaining budget.
|
||||
var fixedLength = title.Length + description.Length;
|
||||
while (facts.Count > 0 && fixedLength + facts.Sum(f => f.Name.Length + f.Value.Length) > MaxEmbedTotalLength)
|
||||
facts.RemoveAt(facts.Count - 1);
|
||||
if (fixedLength > MaxEmbedTotalLength)
|
||||
description = Truncate(description, MaxEmbedTotalLength - title.Length);
|
||||
|
||||
var fields = facts
|
||||
.Select(f => new { name = f.Name, value = f.Value, inline = true })
|
||||
.ToArray();
|
||||
|
||||
var embed = new
|
||||
{
|
||||
title,
|
||||
description = description.Length > 0 ? description : null,
|
||||
color = GetColor(alert),
|
||||
fields = fields.Length > 0 ? fields : null
|
||||
};
|
||||
|
||||
var payload = new { embeds = new[] { embed } };
|
||||
|
||||
return JsonConvert.SerializeObject(payload, new JsonSerializerSettings
|
||||
{
|
||||
NullValueHandling = NullValueHandling.Ignore,
|
||||
ContractResolver = new Newtonsoft.Json.Serialization.DefaultContractResolver
|
||||
{
|
||||
NamingStrategy = new Newtonsoft.Json.Serialization.CamelCaseNamingStrategy()
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static string BuildDescription(NotificationAlert alert)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
|
||||
if (!string.IsNullOrEmpty(alert.Summary))
|
||||
sb.Append(alert.Summary);
|
||||
|
||||
foreach (var section in alert.Sections)
|
||||
{
|
||||
var text = "";
|
||||
if (!string.IsNullOrEmpty(section.Title))
|
||||
text += $"**{section.Title}**\n";
|
||||
if (!string.IsNullOrEmpty(section.Text))
|
||||
text += section.Text;
|
||||
|
||||
if (text.Length > 0)
|
||||
{
|
||||
if (sb.Length > 0)
|
||||
sb.Append("\n\n");
|
||||
sb.Append(text);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var action in alert.Actions.Where(a => a.Type == "openUrl" && !string.IsNullOrEmpty(a.Url)))
|
||||
{
|
||||
if (sb.Length > 0)
|
||||
sb.Append("\n\n");
|
||||
sb.Append($"[{action.Title}]({action.Url})");
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static int GetColor(NotificationAlert alert)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(alert.ThemeColor)
|
||||
&& int.TryParse(alert.ThemeColor.TrimStart('#'), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var color))
|
||||
return color;
|
||||
|
||||
return alert.Severity switch
|
||||
{
|
||||
NotificationSeverity.Success => 0x2ECC71,
|
||||
NotificationSeverity.Error => 0xE74C3C,
|
||||
NotificationSeverity.Warning => 0xF1C40F,
|
||||
_ => 0x3498DB
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetSeverityEmoji(NotificationSeverity severity)
|
||||
{
|
||||
return severity switch
|
||||
{
|
||||
NotificationSeverity.Success => "\u2705",
|
||||
NotificationSeverity.Error => "\u274c",
|
||||
NotificationSeverity.Warning => "\u26a0\ufe0f",
|
||||
NotificationSeverity.Info => "\u2139\ufe0f",
|
||||
_ => "\u2139\ufe0f"
|
||||
};
|
||||
}
|
||||
|
||||
private static string Truncate(string value, int maxLength)
|
||||
{
|
||||
if (maxLength <= 0)
|
||||
return "";
|
||||
if (value.Length <= maxLength)
|
||||
return value;
|
||||
return value.Substring(0, maxLength - 1) + "\u2026";
|
||||
}
|
||||
}
|
||||
}
|
||||
+25
-7
@@ -29,6 +29,7 @@ namespace AutopilotMonitor.Functions.Services.Notifications
|
||||
[WebhookProviderType.TeamsWorkflowWebhook] = new TeamsWorkflowAdaptiveCardRenderer(),
|
||||
[WebhookProviderType.Slack] = new SlackRenderer(),
|
||||
[WebhookProviderType.GenericJson] = new GenericJsonRenderer(),
|
||||
[WebhookProviderType.Discord] = new DiscordRenderer(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -36,9 +37,11 @@ namespace AutopilotMonitor.Functions.Services.Notifications
|
||||
/// Sends a notification (fire-and-forget, non-fatal). Exceptions are logged as warnings.
|
||||
/// <paramref name="customHeaders"/> (generic webhooks only) are attached to the request,
|
||||
/// e.g. an API-key/Authorization header for a ticketing system or SMTP gateway.
|
||||
/// <paramref name="signingSecret"/> (generic webhooks only) adds HMAC signature headers
|
||||
/// (see <see cref="WebhookSignatureCalculator"/>).
|
||||
/// </summary>
|
||||
public async Task SendNotificationAsync(string webhookUrl, WebhookProviderType providerType, NotificationAlert alert,
|
||||
IReadOnlyDictionary<string, string>? customHeaders = null)
|
||||
IReadOnlyDictionary<string, string>? customHeaders = null, string? signingSecret = null)
|
||||
{
|
||||
if (string.IsNullOrEmpty(webhookUrl) || providerType == WebhookProviderType.None)
|
||||
return;
|
||||
@@ -54,7 +57,7 @@ namespace AutopilotMonitor.Functions.Services.Notifications
|
||||
await SsrfGuard.ValidateDestinationAsync(webhookUrl);
|
||||
|
||||
var json = renderer.RenderToJson(alert);
|
||||
var response = await PostAsync(webhookUrl, json, customHeaders);
|
||||
var response = await PostAsync(webhookUrl, json, customHeaders, signingSecret);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
_logger.LogInformation("Webhook notification sent: {Summary} (provider={ProviderType})", alert.Summary, providerType);
|
||||
@@ -88,16 +91,18 @@ namespace AutopilotMonitor.Functions.Services.Notifications
|
||||
channel.Url,
|
||||
(WebhookProviderType)channel.ProviderType,
|
||||
alert,
|
||||
channel.GetCustomHeaders());
|
||||
channel.GetCustomHeaders(),
|
||||
channel.GetSigningSecret());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a notification and returns the result (for test endpoint). Not fire-and-forget.
|
||||
/// <paramref name="customHeaders"/> (generic webhooks only) are attached to the request.
|
||||
/// <paramref name="customHeaders"/> and <paramref name="signingSecret"/> (generic
|
||||
/// webhooks only) are applied exactly like in <see cref="SendNotificationAsync"/>.
|
||||
/// </summary>
|
||||
public async Task<WebhookTestResult> SendNotificationWithResultAsync(string webhookUrl, WebhookProviderType providerType, NotificationAlert alert,
|
||||
IReadOnlyDictionary<string, string>? customHeaders = null)
|
||||
IReadOnlyDictionary<string, string>? customHeaders = null, string? signingSecret = null)
|
||||
{
|
||||
if (string.IsNullOrEmpty(webhookUrl))
|
||||
return new WebhookTestResult { Success = false, Message = "Webhook URL is not configured." };
|
||||
@@ -120,7 +125,7 @@ namespace AutopilotMonitor.Functions.Services.Notifications
|
||||
try
|
||||
{
|
||||
var json = renderer.RenderToJson(alert);
|
||||
var response = await PostAsync(webhookUrl, json, customHeaders);
|
||||
var response = await PostAsync(webhookUrl, json, customHeaders, signingSecret);
|
||||
var statusCode = (int)response.StatusCode;
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
@@ -145,8 +150,11 @@ namespace AutopilotMonitor.Functions.Services.Notifications
|
||||
/// <summary>
|
||||
/// POSTs the rendered JSON, attaching any custom headers as request headers. Restricted
|
||||
/// (framing/host/content) headers are already filtered upstream by GetGenericWebhookHeaders().
|
||||
/// When a signing secret is present, HMAC signature headers are computed over the exact
|
||||
/// JSON string being posted — after custom headers, so they can never be spoofed by one.
|
||||
/// </summary>
|
||||
private Task<HttpResponseMessage> PostAsync(string webhookUrl, string json, IReadOnlyDictionary<string, string>? customHeaders)
|
||||
private Task<HttpResponseMessage> PostAsync(string webhookUrl, string json, IReadOnlyDictionary<string, string>? customHeaders,
|
||||
string? signingSecret = null)
|
||||
{
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, webhookUrl)
|
||||
{
|
||||
@@ -159,6 +167,16 @@ namespace AutopilotMonitor.Functions.Services.Notifications
|
||||
request.Headers.TryAddWithoutValidation(header.Key, header.Value);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(signingSecret))
|
||||
{
|
||||
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
request.Headers.Remove(WebhookSignatureCalculator.TimestampHeader);
|
||||
request.Headers.Remove(WebhookSignatureCalculator.SignatureHeader);
|
||||
request.Headers.TryAddWithoutValidation(WebhookSignatureCalculator.TimestampHeader, timestamp);
|
||||
request.Headers.TryAddWithoutValidation(WebhookSignatureCalculator.SignatureHeader,
|
||||
WebhookSignatureCalculator.ComputeSignature(signingSecret, timestamp, json));
|
||||
}
|
||||
|
||||
return _http.SendAsync(request);
|
||||
}
|
||||
}
|
||||
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
using System;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Services.Notifications
|
||||
{
|
||||
/// <summary>
|
||||
/// Computes the HMAC request signature for generic JSON webhooks (Stripe-style scheme).
|
||||
/// The receiver recomputes HMACSHA256(secret, "{timestamp}.{rawBody}") and compares it
|
||||
/// (constant-time) against the signature header; the timestamp binds replay protection.
|
||||
/// </summary>
|
||||
public static class WebhookSignatureCalculator
|
||||
{
|
||||
/// <summary>Header carrying the unix-seconds timestamp the signature was computed with.</summary>
|
||||
public const string TimestampHeader = "X-AutopilotMonitor-Timestamp";
|
||||
|
||||
/// <summary>Header carrying the signature: "sha256=<lowercase hex HMAC>".</summary>
|
||||
public const string SignatureHeader = "X-AutopilotMonitor-Signature";
|
||||
|
||||
/// <summary>
|
||||
/// Computes "sha256=<hex>" over "{timestamp}.{body}" with the given secret.
|
||||
/// Secret and message are UTF-8; hex is lowercase.
|
||||
/// </summary>
|
||||
public static string ComputeSignature(string secret, string timestamp, string body)
|
||||
{
|
||||
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
|
||||
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes($"{timestamp}.{body}"));
|
||||
return "sha256=" + Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -527,6 +527,60 @@ namespace AutopilotMonitor.Functions.Services
|
||||
tenantId, "System.TrialSweep", new { domainName, trialExpiredUtc });
|
||||
}
|
||||
|
||||
// ── Plan downgrade / retention grace (informational — enforcement is read-time) ──
|
||||
// All three types are dual-registered in OpsAlertRulesSection.tsx OPS_EVENT_TYPES
|
||||
// (memory feedback_ops_event_types_dual_register). Downgraded is dispatched by
|
||||
// PlanManagementFunction, the grace events by TrialExpirySweepFunction.
|
||||
|
||||
/// <summary>
|
||||
/// A GA plan mutation dropped the tenant's EFFECTIVE edition Pro → Community. The
|
||||
/// retention downgrade grace period starts now; data older than the Community cap is
|
||||
/// deleted only after it ends. Warning-tier: the business moment (e.g. non-payment)
|
||||
/// an operator wants a ping for.
|
||||
/// </summary>
|
||||
public Task RecordTenantPlanDowngradedAsync(
|
||||
string tenantId, string? domainName, string caller, DateTime? retentionGraceEndsUtc, int storedRetentionDays)
|
||||
{
|
||||
var tenantLabel = string.IsNullOrWhiteSpace(domainName) ? tenantId : $"{domainName} ({tenantId})";
|
||||
var graceNote = retentionGraceEndsUtc is DateTime graceEnd
|
||||
? $"retention grace until {graceEnd:yyyy-MM-dd HH:mm}Z"
|
||||
: "no retention grace applicable";
|
||||
return WriteAsync(OpsEventCategory.Tenant, "TenantPlanDowngraded", OpsEventSeverity.Warning,
|
||||
$"Tenant {tenantLabel} was downgraded Pro → Community by {caller} — {graceNote} (stored retention {storedRetentionDays}d)",
|
||||
tenantId, caller, new { domainName, retentionGraceEndsUtc, storedRetentionDays });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Heads-up: a downgraded tenant's retention grace ends within the next few days —
|
||||
/// after that, the sweep enforces the Community cap and deletes the older data.
|
||||
/// Emitted only for tenants that actually have data at risk (stored retention above
|
||||
/// the Community cap). Re-fired daily during the heads-up window (no dedup state).
|
||||
/// </summary>
|
||||
public Task RecordTenantRetentionGraceExpiringAsync(
|
||||
string tenantId, string? domainName, DateTime graceEndsUtc, int daysLeft, int storedRetentionDays)
|
||||
{
|
||||
var tenantLabel = string.IsNullOrWhiteSpace(domainName) ? tenantId : $"{domainName} ({tenantId})";
|
||||
return WriteAsync(OpsEventCategory.Tenant, "TenantRetentionGraceExpiring", OpsEventSeverity.Warning,
|
||||
$"Retention grace for downgraded tenant {tenantLabel} ends in {daysLeft} day(s) ({graceEndsUtc:yyyy-MM-dd HH:mm}Z) — " +
|
||||
$"data older than the Community cap will then be deleted (stored retention {storedRetentionDays}d)",
|
||||
tenantId, "System.TrialSweep", new { domainName, graceEndsUtc, daysLeft, storedRetentionDays });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A downgraded tenant's retention grace ended within the last sweep window — from now
|
||||
/// on the retention sweep enforces the Community cap and starts deleting the older data.
|
||||
/// Last call for a GA intervention (re-upgrade / retention decision).
|
||||
/// </summary>
|
||||
public Task RecordTenantRetentionGraceEndedAsync(
|
||||
string tenantId, string? domainName, DateTime graceEndedUtc, int storedRetentionDays)
|
||||
{
|
||||
var tenantLabel = string.IsNullOrWhiteSpace(domainName) ? tenantId : $"{domainName} ({tenantId})";
|
||||
return WriteAsync(OpsEventCategory.Tenant, "TenantRetentionGraceEnded", OpsEventSeverity.Warning,
|
||||
$"Retention grace for downgraded tenant {tenantLabel} ended ({graceEndedUtc:yyyy-MM-dd HH:mm}Z) — " +
|
||||
$"the Community retention cap is now enforced and older data will be deleted (stored retention {storedRetentionDays}d)",
|
||||
tenantId, "System.TrialSweep", new { domainName, graceEndedUtc, storedRetentionDays });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// F3 regression radar (insights spec §F3): an analyze rule's 7-day hit rate rose ≥2×
|
||||
/// over its 28-day baseline with disjoint Wilson intervals. Fired ONCE per episode (the
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Resend;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Sends transactional emails via Resend.com for tenant-activation notifications.
|
||||
/// Best-effort: failures are logged as warnings and never propagated.
|
||||
/// Temporary — remove after GA.
|
||||
/// </summary>
|
||||
public class ResendEmailService : IOffboardFarewellEmailSender
|
||||
{
|
||||
private readonly string _apiKey;
|
||||
private readonly ILogger<ResendEmailService> _logger;
|
||||
|
||||
public ResendEmailService(
|
||||
IConfiguration configuration,
|
||||
ILogger<ResendEmailService> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
_apiKey = configuration["RESEND_API_KEY"] ?? string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends the tenant-activation welcome email.
|
||||
/// No-op if the API key or recipient email is not configured.
|
||||
/// </summary>
|
||||
public virtual async Task SendPreviewApprovedEmailAsync(string toEmail, string domainName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_apiKey))
|
||||
{
|
||||
_logger.LogDebug("RESEND_API_KEY not configured — skipping preview approval email");
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(toEmail))
|
||||
{
|
||||
_logger.LogDebug("No notification email set — skipping preview approval email for {Domain}", domainName);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var resend = ResendClient.Create(_apiKey);
|
||||
|
||||
var message = new EmailMessage
|
||||
{
|
||||
From = "Autopilot Monitor <noreply@autopilotmonitor.com>",
|
||||
To = toEmail,
|
||||
Subject = EmailTemplates.PreviewApprovedSubject,
|
||||
HtmlBody = EmailTemplates.GetPreviewApprovedHtml(domainName)
|
||||
};
|
||||
|
||||
await resend.EmailSendAsync(message);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Preview approval email sent to {ToEmail} for domain {Domain}",
|
||||
toEmail, domainName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"Failed to send preview approval email to {ToEmail} for domain {Domain}",
|
||||
toEmail, domainName);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends the post-offboarding "sorry to see you go" farewell email.
|
||||
/// No-op if the API key or recipient email is not configured. Best-effort: failures
|
||||
/// are logged as warnings and never propagated (the offboarding correctness contract
|
||||
/// does not depend on email delivery).
|
||||
/// </summary>
|
||||
public async Task SendAsync(string toEmail, string domainName, string tenantId, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_apiKey))
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"RESEND_API_KEY not configured — skipping offboard farewell email for tenant {TenantId}",
|
||||
tenantId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(toEmail))
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"No notification email captured — skipping offboard farewell email for tenant {TenantId} ({Domain})",
|
||||
tenantId, domainName);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var resend = ResendClient.Create(_apiKey);
|
||||
|
||||
var message = new EmailMessage
|
||||
{
|
||||
From = "Autopilot Monitor <noreply@autopilotmonitor.com>",
|
||||
To = toEmail,
|
||||
Subject = EmailTemplates.OffboardingFarewellSubject,
|
||||
HtmlBody = EmailTemplates.GetOffboardingFarewellHtml(domainName)
|
||||
};
|
||||
|
||||
await resend.EmailSendAsync(message, ct);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Offboard farewell email sent to {ToEmail} for tenant {TenantId} ({Domain})",
|
||||
toEmail, tenantId, domainName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"Failed to send offboard farewell email to {ToEmail} for tenant {TenantId} ({Domain})",
|
||||
toEmail, tenantId, domainName);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -767,6 +767,39 @@ namespace AutopilotMonitor.Functions.Services
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Self-deploying-profile sessions sitting in Incomplete / AwaitingUser / Stalled — the
|
||||
/// shapes the pre-2026-08-23 classifier produced for a finished kiosk device whose agent
|
||||
/// went silent after Device ESP. Candidates for the retro-reconcile; the classifier
|
||||
/// decides per session, this is only the cheap server-side pre-filter.
|
||||
/// </summary>
|
||||
public async Task<List<SessionSummary>> GetSelfDeployingSilentSessionsAsync(string tenantId, int maxResults)
|
||||
{
|
||||
SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId));
|
||||
|
||||
try
|
||||
{
|
||||
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Sessions);
|
||||
var filter = $"PartitionKey eq '{tenantId}' " +
|
||||
$"and IsSelfDeployingProfile eq true " +
|
||||
$"and (Status eq 'Incomplete' or Status eq 'AwaitingUser' or Status eq 'Stalled')";
|
||||
|
||||
var sessions = new List<SessionSummary>();
|
||||
await foreach (var entity in tableClient.QueryAsync<TableEntity>(filter: filter))
|
||||
{
|
||||
sessions.Add(MapToSessionSummary(entity));
|
||||
if (sessions.Count >= maxResults)
|
||||
break;
|
||||
}
|
||||
return sessions;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, $"Failed to get self-deploying silent sessions for tenant {tenantId}");
|
||||
return new List<SessionSummary>();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Narrow projection of every session row in the tenant partition — one scan feeding the
|
||||
/// in-memory Pending-orphan matching (misclassification audit 2026-07-16). Fields outside
|
||||
|
||||
@@ -3250,12 +3250,27 @@ namespace AutopilotMonitor.Functions.Services
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the earliest event timestamp persisted for a session, if any.
|
||||
/// Events are written with RowKey "{Timestamp}_{Sequence}", so querying the partition
|
||||
/// and taking the first row yields the earliest event. Resolution must NOT rely on
|
||||
/// the system Timestamp: it is reset by storage migrations, and this value feeds the
|
||||
/// StartedAt/Duration reconciliation — a migration-reset read would rewrite those
|
||||
/// session fields with the migration date.
|
||||
/// Event sources whose rows never anchor the session. The system timeline watcher
|
||||
/// backfills environment observations (clock steps, sleep episodes) from the Windows
|
||||
/// event log with their ORIGINAL timestamps — up to 24h before the agent started.
|
||||
/// They belong on the timeline, but they are not enrollment activity: anchoring on
|
||||
/// them silently stretches StartedAt/DurationSeconds toward pre-enrollment OOBE idle
|
||||
/// time (field case d0c5b672: a pre-agent clock correction pulled StartedAt ~14min
|
||||
/// before the first real activity; the 2h backward-shift guard only caps, not
|
||||
/// prevents, this). User decision 2026-08-21: the session anchor stays on real
|
||||
/// enrollment activity.
|
||||
/// </summary>
|
||||
internal static bool IsSessionAnchorEligible(TableEntity eventRow)
|
||||
=> eventRow.GetString("Source") != "SystemTimelineWatcher";
|
||||
|
||||
/// <summary>
|
||||
/// Returns the earliest ANCHOR-ELIGIBLE event timestamp persisted for a session, if
|
||||
/// any. Events are written with RowKey "{Timestamp}_{Sequence}", so scanning the
|
||||
/// partition in order and taking the first eligible row (see
|
||||
/// <see cref="IsSessionAnchorEligible"/>) yields the earliest activity event.
|
||||
/// Resolution must NOT rely on the system Timestamp: it is reset by storage
|
||||
/// migrations, and this value feeds the StartedAt/Duration reconciliation — a
|
||||
/// migration-reset read would rewrite those session fields with the migration date.
|
||||
/// </summary>
|
||||
private async Task<DateTime?> GetEarliestSessionEventTimestampAsync(string tenantId, string sessionId)
|
||||
{
|
||||
@@ -3265,12 +3280,15 @@ namespace AutopilotMonitor.Functions.Services
|
||||
var partitionKey = $"{tenantId}_{sessionId}";
|
||||
var query = tableClient.QueryAsync<TableEntity>(
|
||||
filter: $"PartitionKey eq '{partitionKey}'",
|
||||
maxPerPage: 1,
|
||||
select: new[] { "RowKey", BusinessTimestamp.OccurredUtcColumn, "Timestamp" }
|
||||
maxPerPage: 25,
|
||||
select: new[] { "RowKey", BusinessTimestamp.OccurredUtcColumn, "Timestamp", "Source" }
|
||||
);
|
||||
|
||||
await foreach (var entity in query)
|
||||
{
|
||||
if (!IsSessionAnchorEligible(entity))
|
||||
continue; // environment observation, not enrollment activity — keep scanning
|
||||
|
||||
return ResolveEventTimestampOrNull(entity);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,20 +16,20 @@ public class TenantApprovalService
|
||||
private readonly PreviewWhitelistService _previewWhitelistService;
|
||||
private readonly TenantConfigurationService _tenantConfigurationService;
|
||||
private readonly TenantAdminsService _tenantAdminsService;
|
||||
private readonly ResendEmailService _resendEmailService;
|
||||
private readonly IEmailService _emailService;
|
||||
|
||||
public TenantApprovalService(
|
||||
ILogger<TenantApprovalService> logger,
|
||||
PreviewWhitelistService previewWhitelistService,
|
||||
TenantConfigurationService tenantConfigurationService,
|
||||
TenantAdminsService tenantAdminsService,
|
||||
ResendEmailService resendEmailService)
|
||||
IEmailService emailService)
|
||||
{
|
||||
_logger = logger;
|
||||
_previewWhitelistService = previewWhitelistService;
|
||||
_tenantConfigurationService = tenantConfigurationService;
|
||||
_tenantAdminsService = tenantAdminsService;
|
||||
_resendEmailService = resendEmailService;
|
||||
_emailService = emailService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -140,7 +140,7 @@ public class TenantApprovalService
|
||||
}
|
||||
|
||||
var tenantConfig = await _tenantConfigurationService.GetConfigurationAsync(tenantId);
|
||||
_ = _resendEmailService.SendPreviewApprovedEmailAsync(
|
||||
_ = _emailService.SendPreviewApprovedEmailAsync(
|
||||
notificationEmail, tenantConfig.DomainName)
|
||||
.ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException,
|
||||
"Fire-and-forget welcome email failed for tenant {TenantId}", tenantId),
|
||||
|
||||
@@ -121,6 +121,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
"LastUpdated", "UpdatedBy", "OnboardedAt", "OnboardedBy",
|
||||
"HomedAppClientId", "LastAuthClientId", "LastAuthClientIdSince",
|
||||
"PlanTier", "TrialExpiresUtc", "TrialStartedUtc", "TrialConsumed", "TrialGrantedBy",
|
||||
"ProDowngradedUtc",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
@@ -148,6 +149,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
"HomedAppClientId", "LastAuthClientId", "LastAuthClientIdSince",
|
||||
"OnboardedBy", "OnboardedAt",
|
||||
"PlanTier", "TrialExpiresUtc", "TrialStartedUtc", "TrialConsumed", "TrialGrantedBy",
|
||||
"ProDowngradedUtc",
|
||||
};
|
||||
|
||||
private static readonly Dictionary<string, PropertyInfo> ModelProperties =
|
||||
|
||||
@@ -97,10 +97,36 @@ namespace AutopilotMonitor.Functions.Services
|
||||
&& config.UnrestrictedModeEnabled
|
||||
&& config.UnrestrictedMode;
|
||||
|
||||
/// <summary>
|
||||
/// End of the retention downgrade grace period, or null when none applies (tenant is
|
||||
/// effectively Pro, or never lost Pro). The anchor is the LATEST of the explicit
|
||||
/// downgrade timestamp (<see cref="TenantConfiguration.ProDowngradedUtc"/>, written by
|
||||
/// the plan endpoint) and an expired trial's <see cref="TenantConfiguration.TrialExpiresUtc"/>
|
||||
/// (read-time, no write on expiry). A returned value in the past means the grace is over.
|
||||
/// </summary>
|
||||
public static DateTime? GetRetentionGraceEndUtc(TenantConfiguration config, DateTime nowUtc)
|
||||
{
|
||||
if (ResolveEdition(config, nowUtc) == TenantEdition.Pro)
|
||||
return null;
|
||||
|
||||
DateTime? anchor = config.ProDowngradedUtc;
|
||||
if (config.TrialExpiresUtc is DateTime trialEnd && trialEnd <= nowUtc &&
|
||||
(anchor is null || trialEnd > anchor))
|
||||
{
|
||||
anchor = trialEnd;
|
||||
}
|
||||
|
||||
return anchor?.AddDays(FeatureEntitlementCatalog.RetentionDowngradeGraceDays);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The retention days the platform actually enforces for this config: the stored value
|
||||
/// clamped to the edition's cap. <c>days <= 0</c> is the GA-only "infinite" escape hatch
|
||||
/// and is passed through unclamped (the fanout skips those tenants entirely).
|
||||
/// Downgrade grace: for <see cref="FeatureEntitlementCatalog.RetentionDowngradeGraceDays"/>
|
||||
/// days after losing Pro the PRO cap keeps applying, so a downgrade or trial expiry never
|
||||
/// immediately hard-deletes the 90–365-day band. Retention is the only entitlement with
|
||||
/// a grace — the others are reversible gates.
|
||||
/// </summary>
|
||||
public static int GetEffectiveRetentionDays(TenantConfiguration config, DateTime nowUtc)
|
||||
{
|
||||
@@ -108,7 +134,14 @@ namespace AutopilotMonitor.Functions.Services
|
||||
if (days <= 0)
|
||||
return 0;
|
||||
|
||||
var cap = FeatureEntitlementCatalog.Get(ResolveEdition(config, nowUtc)).RetentionCapDays;
|
||||
var edition = ResolveEdition(config, nowUtc);
|
||||
var cap = FeatureEntitlementCatalog.Get(edition).RetentionCapDays;
|
||||
if (edition == TenantEdition.Community &&
|
||||
GetRetentionGraceEndUtc(config, nowUtc) is DateTime graceEnd && graceEnd > nowUtc)
|
||||
{
|
||||
cap = FeatureEntitlementCatalog.Get(TenantEdition.Pro).RetentionCapDays;
|
||||
}
|
||||
|
||||
return Math.Min(days, cap);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +83,12 @@ namespace AutopilotMonitor.Shared.DataAccess
|
||||
Task<Dictionary<string, string>> GetPreviewConfigAsync();
|
||||
Task<bool> SavePreviewConfigAsync(string key, string value);
|
||||
|
||||
// --- Email Template Overrides (operator-level, PreviewConfig table, partition "EmailTemplates") ---
|
||||
/// <summary>Returns the stored override for the given kind, or null when the built-in template applies.</summary>
|
||||
Task<EmailTemplateOverride?> GetEmailTemplateOverrideAsync(string kind);
|
||||
Task SaveEmailTemplateOverrideAsync(EmailTemplateOverride overrideEntry);
|
||||
Task DeleteEmailTemplateOverrideAsync(string kind);
|
||||
|
||||
// --- Preview Notification Email ---
|
||||
Task<string?> GetNotificationEmailAsync(string tenantId);
|
||||
Task SaveNotificationEmailAsync(string tenantId, string? email);
|
||||
|
||||
@@ -92,6 +92,14 @@ namespace AutopilotMonitor.Shared.DataAccess
|
||||
/// </summary>
|
||||
Task<List<SessionSummary>> GetLegacyTimeoutFailedSessionsAsync(string tenantId, int maxResults);
|
||||
|
||||
/// <summary>
|
||||
/// Self-deploying-profile sessions parked in a non-success, non-failure state
|
||||
/// (Incomplete / AwaitingUser / Stalled) — candidates for the one-time retro-reconcile
|
||||
/// through <c>EnrollmentTimeoutClassifier.IsSelfDeployingProvisioned</c> (kiosk tenant
|
||||
/// audit 2026-08-23). Server-side filter, capped at <paramref name="maxResults"/>.
|
||||
/// </summary>
|
||||
Task<List<SessionSummary>> GetSelfDeployingSilentSessionsAsync(string tenantId, int maxResults);
|
||||
|
||||
/// <summary>
|
||||
/// Narrow projection of every session row in the tenant partition (id, status, timing,
|
||||
/// serial). Used by the Pending-orphan resolution to match superseding sessions of the
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
using System;
|
||||
|
||||
namespace AutopilotMonitor.Shared.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// Operator-supplied replacement for one built-in transactional email template
|
||||
/// (kind = "welcome" | "farewell"). Stored in the PreviewConfig table, partition
|
||||
/// "EmailTemplates". The HTML may contain the <c>{{domainName}}</c> placeholder.
|
||||
/// </summary>
|
||||
public sealed class EmailTemplateOverride
|
||||
{
|
||||
public string Kind { get; set; } = string.Empty;
|
||||
public string Html { get; set; } = string.Empty;
|
||||
public string UpdatedBy { get; set; } = string.Empty;
|
||||
public DateTime UpdatedUtc { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -166,6 +166,17 @@ namespace AutopilotMonitor.Shared.Models
|
||||
/// </summary>
|
||||
public string? TrialGrantedBy { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When the tenant's EFFECTIVE edition last dropped Pro → Community via the plan
|
||||
/// endpoint (UTC). Anchors the retention downgrade grace period: for
|
||||
/// RetentionDowngradeGraceDays after losing Pro the retention cap stays at the Pro
|
||||
/// value so a downgrade (e.g. non-payment) does not immediately hard-delete data
|
||||
/// older than the Community cap. Trial expiry needs no write — TrialExpiresUtc
|
||||
/// itself is the anchor there. Cleared whenever the tenant becomes effectively Pro
|
||||
/// again. Backend-only: not delivered to the agent (no ConfigVersion impact).
|
||||
/// </summary>
|
||||
public DateTime? ProDowngradedUtc { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Hardware whitelist: Allowed manufacturers (supports wildcards like "Dell*")
|
||||
/// Comma-separated list
|
||||
@@ -809,6 +820,7 @@ namespace AutopilotMonitor.Shared.Models
|
||||
{
|
||||
channel.Url = RedactNullable(channel.Url);
|
||||
channel.CustomHeadersJson = RedactNullable(channel.CustomHeadersJson);
|
||||
channel.SigningSecret = RedactNullable(channel.SigningSecret);
|
||||
}
|
||||
|
||||
return Notifications.NotificationChannel.SerializeList(channels);
|
||||
@@ -867,6 +879,7 @@ namespace AutopilotMonitor.Shared.Models
|
||||
continue;
|
||||
if (channel.Url == Constants.RedactedSecretPlaceholder) channel.Url = match.Url;
|
||||
if (channel.CustomHeadersJson == Constants.RedactedSecretPlaceholder) channel.CustomHeadersJson = match.CustomHeadersJson;
|
||||
if (channel.SigningSecret == Constants.RedactedSecretPlaceholder) channel.SigningSecret = match.SigningSecret;
|
||||
}
|
||||
|
||||
NotificationChannelsJson = Notifications.NotificationChannel.SerializeList(incoming);
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace AutopilotMonitor.Shared.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// Gate for a built-in diagnostics section. The agent evaluates it against its effective
|
||||
/// configuration and the enrollment scenario; the portal renders it as a context pill.
|
||||
/// </summary>
|
||||
public enum DiagnosticsSectionCondition
|
||||
{
|
||||
/// <summary>Collected by every agent on every enrollment.</summary>
|
||||
Always = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Collected only when the tenant's RealmJoin Watcher is enabled
|
||||
/// (<c>AnalyzerConfiguration.EnableRealmJoinWatcher</c>).
|
||||
/// </summary>
|
||||
RealmJoinWatcher = 1,
|
||||
|
||||
/// <summary>
|
||||
/// Collected only on Windows Autopilot Device Preparation enrollments (deterministic
|
||||
/// WDP marker — see <c>EnrollmentRegistryDetector.IsDeterministicDevicePreparation</c>).
|
||||
/// </summary>
|
||||
DevicePreparation = 2,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One built-in collection section of the diagnostics ZIP. Pure data — no I/O. The agent
|
||||
/// expands <see cref="SourceFolder"/> (environment variables and the
|
||||
/// <see cref="DiagnosticsBuiltInSections.UserProfileToken"/>) at collection time.
|
||||
/// </summary>
|
||||
public sealed class DiagnosticsBuiltInSection
|
||||
{
|
||||
/// <summary>Stable key — used for test overrides and package-manifest lines.</summary>
|
||||
public string Id { get; }
|
||||
|
||||
/// <summary>Folder inside the ZIP (e.g. <c>AgentLogs</c>, <c>RealmJoinLogs/Windows</c>).</summary>
|
||||
public string ZipFolder { get; }
|
||||
|
||||
/// <summary>
|
||||
/// UNEXPANDED source folder: may contain <c>%ProgramData%</c> or the
|
||||
/// <see cref="DiagnosticsBuiltInSections.UserProfileToken"/>.
|
||||
/// </summary>
|
||||
public string SourceFolder { get; }
|
||||
|
||||
/// <summary>File-name patterns collected from <see cref="SourceFolder"/>.</summary>
|
||||
public IReadOnlyList<string> Patterns { get; }
|
||||
|
||||
/// <summary>When true, subdirectories are walked recursively (tree preserved in the ZIP).</summary>
|
||||
public bool IncludeSubfolders { get; }
|
||||
|
||||
/// <summary>Human-readable description shown in the portal.</summary>
|
||||
public string Description { get; }
|
||||
|
||||
public DiagnosticsSectionCondition Condition { get; }
|
||||
|
||||
public DiagnosticsBuiltInSection(
|
||||
string id,
|
||||
string zipFolder,
|
||||
string sourceFolder,
|
||||
string[] patterns,
|
||||
bool includeSubfolders,
|
||||
string description,
|
||||
DiagnosticsSectionCondition condition = DiagnosticsSectionCondition.Always)
|
||||
{
|
||||
Id = id;
|
||||
ZipFolder = zipFolder;
|
||||
SourceFolder = sourceFolder;
|
||||
Patterns = patterns;
|
||||
IncludeSubfolders = includeSubfolders;
|
||||
Description = description;
|
||||
Condition = condition;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The single source of truth for what a diagnostics package collects BEFORE any
|
||||
/// admin-configured path: the agent iterates <see cref="All"/> in order when building the
|
||||
/// archive, and the backend serves the same list to the portal (GET diagnostics/paths) so
|
||||
/// what administrators see is exactly what the agent does. Built-in sections are reviewed
|
||||
/// code and therefore bypass the configured-path guards (DiagnosticsPathGuards), which exist
|
||||
/// to validate admin-typed input.
|
||||
/// </summary>
|
||||
public static class DiagnosticsBuiltInSections
|
||||
{
|
||||
/// <summary>
|
||||
/// Custom token resolved by the agent to the signed-in user's profile folder
|
||||
/// (the agent runs as SYSTEM, so <c>%USERPROFILE%</c> would point at the SYSTEM profile).
|
||||
/// </summary>
|
||||
public const string UserProfileToken = "%LOGGED_ON_USER_PROFILE%";
|
||||
|
||||
/// <summary>
|
||||
/// File extensions collected from log folders (Agent + IME): standard logs, structured
|
||||
/// data, traces, event logs, and diagnostics archives.
|
||||
/// </summary>
|
||||
public static readonly string[] LogFilePatterns =
|
||||
{
|
||||
"*.log", "*.txt", "*.json", "*.jsonl",
|
||||
"*.etl", "*.evtx", "*.xml", "*.csv", "*.cab",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Patterns collected from the agent state folder: the log patterns plus completion
|
||||
/// markers (<c>enrollment-complete.marker</c>, <c>whiteglove-backfill-state.json</c>).
|
||||
/// </summary>
|
||||
public static readonly string[] StateFilePatterns =
|
||||
{
|
||||
"*.log", "*.txt", "*.json", "*.jsonl",
|
||||
"*.etl", "*.evtx", "*.xml", "*.csv", "*.cab",
|
||||
"*.complete", "*.marker",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Marker patterns from the top-level data directory. Kept tight on purpose: only
|
||||
/// completion/exit markers, no session.id / bootstrap.json (config, not forensic state).
|
||||
/// </summary>
|
||||
public static readonly string[] RootMarkerPatterns = { "*.complete", "*.marker" };
|
||||
|
||||
/// <summary>Telemetry spool: only the JSON-shaped files (pending uploads + upload cursor).</summary>
|
||||
public static readonly string[] SpoolFilePatterns = { "*.jsonl", "*.json" };
|
||||
|
||||
/// <summary>
|
||||
/// Ordered catalog — archive order equals catalog order. The Always sections form the
|
||||
/// historical package layout and are lock-tested; the conditional sections were added
|
||||
/// 2026-08-23 (RealmJoin logs behind the tenant's RealmJoin Watcher toggle, the Device
|
||||
/// Preparation bootstrapper event log behind the deterministic WDP marker).
|
||||
/// </summary>
|
||||
public static readonly IReadOnlyList<DiagnosticsBuiltInSection> All = new[]
|
||||
{
|
||||
new DiagnosticsBuiltInSection(
|
||||
"AgentLogs", "AgentLogs", Constants.LogDirectory, LogFilePatterns, false,
|
||||
"Autopilot Monitor agent logs"),
|
||||
new DiagnosticsBuiltInSection(
|
||||
"ImeLogs", "ImeLogs", @"%ProgramData%\Microsoft\IntuneManagementExtension\Logs", LogFilePatterns, false,
|
||||
"Intune Management Extension logs"),
|
||||
// Active event-log channels hold their .evtx exclusively locked; the agent exports
|
||||
// the channel via wevtutil, which costs seconds — only worth it on WDP enrollments,
|
||||
// where this channel IS the provisioning record (batches, workloads, errors).
|
||||
new DiagnosticsBuiltInSection(
|
||||
"ImeBootstrapperEventLog", "ImeLogs", @"C:\Windows\System32\winevt\Logs",
|
||||
new[] { "BootstrapperAgentServiceLogProvider.evtx" }, false,
|
||||
"Device Preparation event log (IME bootstrapper agent)",
|
||||
DiagnosticsSectionCondition.DevicePreparation),
|
||||
new DiagnosticsBuiltInSection(
|
||||
"AgentState", "AgentState", Constants.StateDirectory, StateFilePatterns, true,
|
||||
"Agent decision-engine state, journal and markers (incl. quarantine and WhiteGlove Part-1 buckets)"),
|
||||
new DiagnosticsBuiltInSection(
|
||||
"AgentSpool", "AgentSpool", Constants.SpoolDirectory, SpoolFilePatterns, false,
|
||||
"Pending telemetry uploads and upload cursor"),
|
||||
new DiagnosticsBuiltInSection(
|
||||
"AgentMarkers", "AgentMarkers", Constants.AgentDataDirectory, RootMarkerPatterns, false,
|
||||
"Top-level completion and clean-exit markers"),
|
||||
// RealmJoin — opt-in per tenant (RealmJoin Watcher). ZIP layout mirrors the disk:
|
||||
// C:\Windows\Logs\{realmjoin*.log, RealmJoin\...} → RealmJoinLogs/Windows/...,
|
||||
// %LOCALAPPDATA%\RealmJoin\{tray*.log, Logs\...} → RealmJoinLogs/User/...
|
||||
new DiagnosticsBuiltInSection(
|
||||
"RealmJoinWindows", "RealmJoinLogs/Windows", @"C:\Windows\Logs",
|
||||
new[] { "realmjoin*.log" }, false,
|
||||
"RealmJoin client logs",
|
||||
DiagnosticsSectionCondition.RealmJoinWatcher),
|
||||
new DiagnosticsBuiltInSection(
|
||||
"RealmJoinPackages", "RealmJoinLogs/Windows/RealmJoin", @"C:\Windows\Logs\RealmJoin",
|
||||
new[] { "*.log" }, true,
|
||||
"RealmJoin package logs",
|
||||
DiagnosticsSectionCondition.RealmJoinWatcher),
|
||||
new DiagnosticsBuiltInSection(
|
||||
"RealmJoinChoco", "RealmJoinLogs/Choco", @"%ProgramData%\RealmJoin\choco\logs",
|
||||
new[] { "*.log" }, true,
|
||||
"RealmJoin Chocolatey install logs",
|
||||
DiagnosticsSectionCondition.RealmJoinWatcher),
|
||||
new DiagnosticsBuiltInSection(
|
||||
"RealmJoinUserTray", "RealmJoinLogs/User", UserProfileToken + @"\AppData\Local\RealmJoin",
|
||||
new[] { "tray*.log" }, false,
|
||||
"RealmJoin tray logs (signed-in user)",
|
||||
DiagnosticsSectionCondition.RealmJoinWatcher),
|
||||
new DiagnosticsBuiltInSection(
|
||||
"RealmJoinUserLogs", "RealmJoinLogs/User/Logs", UserProfileToken + @"\AppData\Local\RealmJoin\Logs",
|
||||
new[] { "*.log" }, true,
|
||||
"RealmJoin per-user logs (signed-in user)",
|
||||
DiagnosticsSectionCondition.RealmJoinWatcher),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -43,7 +43,7 @@ namespace AutopilotMonitor.Shared.Models.Notifications
|
||||
|
||||
/// <summary>
|
||||
/// Provider type (int form of <see cref="WebhookProviderType"/>):
|
||||
/// 1=TeamsLegacyConnector, 2=TeamsWorkflowWebhook, 10=Slack, 20=GenericJson.
|
||||
/// 1=TeamsLegacyConnector, 2=TeamsWorkflowWebhook, 10=Slack, 20=GenericJson, 30=Discord.
|
||||
/// </summary>
|
||||
public int ProviderType { get; set; }
|
||||
|
||||
@@ -56,6 +56,12 @@ namespace AutopilotMonitor.Shared.Models.Notifications
|
||||
/// </summary>
|
||||
public string? CustomHeadersJson { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// HMAC key for signing outgoing requests (X-AutopilotMonitor-Signature), applied
|
||||
/// only when <see cref="ProviderType"/> is GenericJson. Secret — redacted for readers.
|
||||
/// </summary>
|
||||
public string? SigningSecret { get; set; }
|
||||
|
||||
/// <summary>Master switch: a disabled channel never receives anything.</summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
@@ -93,6 +99,18 @@ namespace AutopilotMonitor.Shared.Models.Notifications
|
||||
return WebhookHeaderParser.Parse(CustomHeadersJson);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the HMAC signing secret for the generic-webhook dispatcher, or null when
|
||||
/// the channel is not GenericJson or no secret is configured.
|
||||
/// </summary>
|
||||
public string? GetSigningSecret()
|
||||
{
|
||||
if (ProviderType != (int)WebhookProviderType.GenericJson)
|
||||
return null;
|
||||
|
||||
return string.IsNullOrWhiteSpace(SigningSecret) ? null : SigningSecret;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deserializes a channel list from its JSON storage form. Fail-soft: null/blank/malformed
|
||||
/// input yields an empty list (dispatch then simply has no channels to send to). Entries
|
||||
|
||||
@@ -23,5 +23,11 @@ namespace AutopilotMonitor.Shared.Models.Notifications
|
||||
/// Supports per-tenant custom request headers for API-key authentication.
|
||||
/// </summary>
|
||||
GenericJson = 20,
|
||||
|
||||
/// <summary>
|
||||
/// Discord channel webhook (embed format). Webhooks cannot post buttons, so
|
||||
/// openUrl actions are rendered as markdown links inside the embed description.
|
||||
/// </summary>
|
||||
Discord = 30,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -235,7 +235,7 @@ export default function AboutPage() {
|
||||
title: "Integrations",
|
||||
items: [
|
||||
"Microsoft Intune — agent deployment target",
|
||||
"Microsoft Teams, Slack & generic JSON webhooks — start, success, failure, hardware rejection and SLA alerts",
|
||||
"Microsoft Teams, Slack, Discord & generic JSON webhooks — start, success, failure, hardware rejection and SLA alerts",
|
||||
"Intune Management Extension (IME) log — event source for log pattern detection",
|
||||
"WMI & Registry — extended data gather rules",
|
||||
],
|
||||
|
||||
@@ -61,6 +61,7 @@ export function AdminPageSections() {
|
||||
{ id: "diagnostics-log-paths", label: "Diagnostics Log Paths", href: route("/admin/settings/diagnostics-log-paths"), group: "Settings" },
|
||||
{ id: "config-reseed", label: "Config Reseed", href: route("/admin/settings/config-reseed"), group: "Settings" },
|
||||
{ id: "usage-plans", label: "Usage Plans", href: route("/admin/settings/usage-plans"), group: "Settings" },
|
||||
{ id: "email-templates", label: "Email Templates", href: route("/admin/settings/email-templates"), group: "Settings" },
|
||||
|
||||
// Ops (single page)
|
||||
{ id: "ops", label: "Maintenance", href: "/admin/ops", group: "Ops", groupIcon: <WrenchIcon /> },
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import { validateDiagnosticsPath } from "@/utils/guardValidation";
|
||||
import { ValidationIndicator } from "@/components/ValidationIndicator";
|
||||
import type { DiagnosticsLogPath } from "@/types/diagnostics";
|
||||
import { BuiltInSectionsList } from "@/components/diagnostics/BuiltInSectionsList";
|
||||
import { DiagnosticsPathRow } from "@/components/diagnostics/DiagnosticsPathRow";
|
||||
import type { DiagnosticsBuiltInSection, DiagnosticsLogPath } from "@/types/diagnostics";
|
||||
|
||||
// Re-export so existing consumers importing from this component keep working
|
||||
export type { DiagnosticsLogPath };
|
||||
@@ -11,6 +13,9 @@ export type { DiagnosticsLogPath };
|
||||
interface DiagnosticsLogPathsSectionProps {
|
||||
globalDiagPaths: DiagnosticsLogPath[];
|
||||
setGlobalDiagPaths: React.Dispatch<React.SetStateAction<DiagnosticsLogPath[]>>;
|
||||
/** Sections compiled into the agent (GET /api/diagnostics/paths) — shown read-only, collapsed. */
|
||||
builtInSections: DiagnosticsBuiltInSection[];
|
||||
builtInLoading: boolean;
|
||||
loadingConfig: boolean;
|
||||
savingDiagPaths: boolean;
|
||||
adminConfigExists: boolean;
|
||||
@@ -20,6 +25,8 @@ interface DiagnosticsLogPathsSectionProps {
|
||||
export function DiagnosticsLogPathsSection({
|
||||
globalDiagPaths,
|
||||
setGlobalDiagPaths,
|
||||
builtInSections,
|
||||
builtInLoading,
|
||||
loadingConfig,
|
||||
savingDiagPaths,
|
||||
adminConfigExists,
|
||||
@@ -34,6 +41,15 @@ export function DiagnosticsLogPathsSection({
|
||||
[newDiagPath]
|
||||
);
|
||||
|
||||
const addPath = () => {
|
||||
const p = newDiagPath.trim().replace(/^["']+|["']+$/g, "");
|
||||
if (!p) return;
|
||||
setGlobalDiagPaths([...globalDiagPaths, { path: p, description: newDiagDesc.trim(), isBuiltIn: true, includeSubfolders: newDiagSubfolders }]);
|
||||
setNewDiagPath("");
|
||||
setNewDiagDesc("");
|
||||
setNewDiagSubfolders(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-gradient-to-br from-teal-50 to-cyan-50 dark:from-gray-800 dark:to-gray-800 border-2 border-teal-300 dark:border-teal-700 rounded-lg shadow-lg">
|
||||
<div className="p-6 border-b border-teal-200 dark:border-teal-700 bg-gradient-to-r from-teal-100 to-cyan-100 dark:from-teal-900/40 dark:to-cyan-900/40">
|
||||
@@ -43,7 +59,9 @@ export function DiagnosticsLogPathsSection({
|
||||
</svg>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-teal-900 dark:text-teal-100">Diagnostics Log Paths</h2>
|
||||
<p className="text-sm text-teal-600 dark:text-teal-300 mt-1">Global log file paths included in diagnostics packages for all tenants. Tenants may add their own paths in Settings.</p>
|
||||
<p className="text-sm text-teal-600 dark:text-teal-300 mt-1">
|
||||
What every diagnostics package collects: the built-in sections compiled into the agent, plus the global paths below sent to all tenants. Tenants may add their own paths in Settings.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -54,109 +72,87 @@ export function DiagnosticsLogPathsSection({
|
||||
<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>
|
||||
<p className="text-xs text-teal-700 dark:text-teal-300">
|
||||
All paths are validated on the agent against an allowlist of safe prefixes (DiagnosticsPathGuards). Wildcards are only allowed in the last path segment. Environment variables are expanded by the agent.
|
||||
Global paths are validated on the agent against an allowlist of safe prefixes (DiagnosticsPathGuards). Wildcards are only allowed in the last path segment. Environment variables and %LOGGED_ON_USER_PROFILE% are expanded by the agent.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Current paths list */}
|
||||
{loadingConfig ? (
|
||||
<div className="flex items-center space-x-2 text-sm text-gray-500">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-teal-600"></div>
|
||||
<span>Loading...</span>
|
||||
{/* Built-in sections (read-only, collapsed) */}
|
||||
<BuiltInSectionsList sections={builtInSections} loading={builtInLoading} />
|
||||
|
||||
{/* Global paths — add-row first so a long list never pushes the input out of reach */}
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-400 uppercase tracking-wide mb-2">Global (all tenants)</p>
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Path or wildcard (e.g. C:\Windows\Panther\*.log)"
|
||||
value={newDiagPath}
|
||||
onChange={(e) => setNewDiagPath(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addPath(); } }}
|
||||
className="flex-1 px-3 py-1.5 border border-gray-300 dark:border-gray-600 rounded-lg text-sm text-gray-900 dark:text-gray-100 bg-white dark:bg-gray-700 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-teal-500 font-mono"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Description (optional)"
|
||||
value={newDiagDesc}
|
||||
onChange={(e) => setNewDiagDesc(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addPath(); } }}
|
||||
className="flex-1 px-3 py-1.5 border border-gray-300 dark:border-gray-600 rounded-lg text-sm text-gray-900 dark:text-gray-100 bg-white dark:bg-gray-700 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-teal-500"
|
||||
/>
|
||||
<label className="flex items-center gap-1.5 whitespace-nowrap text-xs text-gray-600 dark:text-gray-400 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={newDiagSubfolders}
|
||||
onChange={() => setNewDiagSubfolders(!newDiagSubfolders)}
|
||||
className="w-3.5 h-3.5 rounded border-gray-400 text-green-600 focus:ring-green-500"
|
||||
/>
|
||||
subfolders
|
||||
</label>
|
||||
<button
|
||||
onClick={addPath}
|
||||
disabled={!newDiagPath.trim()}
|
||||
className="px-4 py-1.5 bg-teal-600 text-white rounded-lg text-sm font-medium hover:bg-teal-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors whitespace-nowrap"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
) : globalDiagPaths.length === 0 ? (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 italic">No global paths configured yet.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{globalDiagPaths.map((entry, idx) => (
|
||||
<div key={idx} className="flex items-start justify-between bg-teal-100 dark:bg-teal-900/40 border border-teal-300 dark:border-teal-700 rounded-lg px-3 py-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<p className="font-mono text-sm text-teal-900 dark:text-teal-100 break-all">{entry.path}</p>
|
||||
<ValidationIndicator result={validateDiagnosticsPath(entry.path, false)} />
|
||||
{entry.includeSubfolders && (
|
||||
<span className="text-xs bg-teal-200 dark:bg-teal-800 text-teal-800 dark:text-teal-200 rounded-full px-1.5 py-0.5">+subfolders</span>
|
||||
)}
|
||||
</div>
|
||||
{entry.description && (
|
||||
<p className="text-xs text-teal-600 dark:text-teal-400 mt-0.5">{entry.description}</p>
|
||||
)}
|
||||
<label className="flex items-center gap-1.5 mt-1 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={entry.includeSubfolders || false}
|
||||
onChange={() => {
|
||||
const updated = [...globalDiagPaths];
|
||||
updated[idx] = { ...entry, includeSubfolders: !entry.includeSubfolders };
|
||||
setGlobalDiagPaths(updated);
|
||||
}}
|
||||
className="w-3.5 h-3.5 rounded border-teal-400 text-green-600 focus:ring-green-500"
|
||||
/>
|
||||
<span className="text-xs text-teal-600 dark:text-teal-400">Include subfolders</span>
|
||||
</label>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
const updated = globalDiagPaths.filter((_, i) => i !== idx);
|
||||
{newPathValidation && (
|
||||
<div className="mt-1.5">
|
||||
<ValidationIndicator result={newPathValidation} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loadingConfig ? (
|
||||
<div className="mt-3 flex items-center space-x-2 text-sm text-gray-500">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-teal-600"></div>
|
||||
<span>Loading...</span>
|
||||
</div>
|
||||
) : globalDiagPaths.length === 0 ? (
|
||||
<p className="mt-3 text-sm text-gray-500 dark:text-gray-400 italic">
|
||||
No global paths configured yet — every tenant receives only the built-in collection.
|
||||
</p>
|
||||
) : (
|
||||
<div className="mt-3 space-y-1">
|
||||
{globalDiagPaths.map((entry, idx) => (
|
||||
<DiagnosticsPathRow
|
||||
key={`${entry.path}-${idx}`}
|
||||
path={entry.path}
|
||||
description={entry.description}
|
||||
includeSubfolders={entry.includeSubfolders || false}
|
||||
validation={validateDiagnosticsPath(entry.path, false)}
|
||||
className="bg-teal-100 dark:bg-teal-900/40 border-teal-300 dark:border-teal-700"
|
||||
pathClassName="text-teal-900 dark:text-teal-100"
|
||||
onToggleSubfolders={() => {
|
||||
const updated = [...globalDiagPaths];
|
||||
updated[idx] = { ...entry, includeSubfolders: !entry.includeSubfolders };
|
||||
setGlobalDiagPaths(updated);
|
||||
}}
|
||||
className="ml-3 flex-shrink-0 text-teal-500 hover:text-red-600 dark:hover:text-red-400 transition-colors"
|
||||
title="Remove"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add new path */}
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Path or wildcard (e.g. C:\Windows\Panther\*.log)"
|
||||
value={newDiagPath}
|
||||
onChange={(e) => setNewDiagPath(e.target.value)}
|
||||
className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-sm text-gray-900 dark:text-gray-100 bg-white dark:bg-gray-700 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-teal-500 font-mono"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Description (optional)"
|
||||
value={newDiagDesc}
|
||||
onChange={(e) => setNewDiagDesc(e.target.value)}
|
||||
className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-sm text-gray-900 dark:text-gray-100 bg-white dark:bg-gray-700 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-teal-500"
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
const p = newDiagPath.trim().replace(/^["']+|["']+$/g, "");
|
||||
if (!p) return;
|
||||
setGlobalDiagPaths([...globalDiagPaths, { path: p, description: newDiagDesc.trim(), isBuiltIn: true, includeSubfolders: newDiagSubfolders }]);
|
||||
setNewDiagPath("");
|
||||
setNewDiagDesc("");
|
||||
setNewDiagSubfolders(false);
|
||||
}}
|
||||
disabled={!newDiagPath.trim()}
|
||||
className="px-4 py-2 bg-teal-600 text-white rounded-lg text-sm font-medium hover:bg-teal-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors whitespace-nowrap"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
onRemove={() => setGlobalDiagPaths(globalDiagPaths.filter((_, i) => i !== idx))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<label className="flex items-center gap-1.5 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={newDiagSubfolders}
|
||||
onChange={() => setNewDiagSubfolders(!newDiagSubfolders)}
|
||||
className="w-3.5 h-3.5 rounded border-gray-400 text-green-600 focus:ring-green-500"
|
||||
/>
|
||||
<span className="text-xs text-gray-600 dark:text-gray-400">Include subfolders</span>
|
||||
</label>
|
||||
{newPathValidation && (
|
||||
<div className="mt-1">
|
||||
<ValidationIndicator result={newPathValidation} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Save button */}
|
||||
<div className="flex justify-end pt-2">
|
||||
|
||||
@@ -135,6 +135,15 @@ const OPS_EVENT_TYPES: Record<string, string[]> = {
|
||||
// (AutoApproveNewTenants enabled). Info-tier. Dual-register per memory
|
||||
// feedback_ops_event_types_dual_register.
|
||||
"TenantAutoApproved",
|
||||
// Plan downgrade / retention grace. Downgraded is dispatched by PlanManagementFunction
|
||||
// (RecordTenantPlanDowngradedAsync) when a GA plan mutation drops the effective edition
|
||||
// Pro → Community; the grace events by TrialExpirySweepFunction — Expiring during the
|
||||
// ≤3-day heads-up (re-emitted daily), Ended once via the 24h look-back, both only when
|
||||
// stored retention exceeds the Community cap (data actually at risk). All Warning-tier.
|
||||
// Dual-register per memory feedback_ops_event_types_dual_register.
|
||||
"TenantPlanDowngraded",
|
||||
"TenantRetentionGraceExpiring",
|
||||
"TenantRetentionGraceEnded",
|
||||
],
|
||||
Agent: [
|
||||
"BlobStorageMissing",
|
||||
|
||||
@@ -10,6 +10,7 @@ import { SectionTenantGroups } from "../sections/SectionTenantGroups";
|
||||
import { SectionConfigReseed } from "../sections/SectionConfigReseed";
|
||||
import { SectionUsagePlans } from "../sections/SectionUsagePlans";
|
||||
import { SectionAlerts } from "../sections/SectionAlerts";
|
||||
import { SectionEmailTemplates } from "../sections/SectionEmailTemplates";
|
||||
|
||||
const SECTION_COMPONENTS: Record<SettingsSectionId, React.ComponentType> = {
|
||||
"global": SectionGlobalSettings,
|
||||
@@ -20,6 +21,7 @@ const SECTION_COMPONENTS: Record<SettingsSectionId, React.ComponentType> = {
|
||||
"config-reseed": SectionConfigReseed,
|
||||
"usage-plans": SectionUsagePlans,
|
||||
"alerts": SectionAlerts,
|
||||
"email-templates": SectionEmailTemplates,
|
||||
};
|
||||
|
||||
export function SectionClient({ section }: { section: string }) {
|
||||
|
||||
+5
@@ -4,9 +4,12 @@ import { useEffect } from "react";
|
||||
import { useAdminConfig } from "../../AdminConfigContext";
|
||||
import { DiagnosticsLogPathsSection } from "../../components/DiagnosticsLogPathsSection";
|
||||
import { AdminNotifications } from "../../AdminNotifications";
|
||||
import { useDiagnosticsPathsCatalog } from "@/hooks/useDiagnosticsPathsCatalog";
|
||||
|
||||
export function SectionDiagnosticsLogPaths() {
|
||||
const { ensureAdminConfigLoaded, globalDiagPaths, setGlobalDiagPaths, loadingConfig, savingDiagPaths, adminConfig, handleSaveDiagPaths } = useAdminConfig();
|
||||
// Built-in catalog is read-only context; the editable global list stays on the admin config.
|
||||
const catalog = useDiagnosticsPathsCatalog();
|
||||
|
||||
useEffect(() => { ensureAdminConfigLoaded(); }, [ensureAdminConfigLoaded]);
|
||||
|
||||
@@ -16,6 +19,8 @@ export function SectionDiagnosticsLogPaths() {
|
||||
<DiagnosticsLogPathsSection
|
||||
globalDiagPaths={globalDiagPaths}
|
||||
setGlobalDiagPaths={setGlobalDiagPaths}
|
||||
builtInSections={catalog.builtIn}
|
||||
builtInLoading={catalog.loading}
|
||||
loadingConfig={loadingConfig}
|
||||
savingDiagPaths={savingDiagPaths}
|
||||
adminConfigExists={!!adminConfig}
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
|
||||
import { useAdminConfig } from "../../AdminConfigContext";
|
||||
import { AdminNotifications } from "../../AdminNotifications";
|
||||
|
||||
type TemplateKind = "welcome" | "farewell";
|
||||
|
||||
interface TemplateState {
|
||||
kind: TemplateKind;
|
||||
subject: string;
|
||||
isOverridden: boolean;
|
||||
html: string;
|
||||
builtInHtml: string;
|
||||
updatedBy: string | null;
|
||||
updatedUtc: string | null;
|
||||
placeholder: string;
|
||||
maxLength: number;
|
||||
}
|
||||
|
||||
const KINDS: { kind: TemplateKind; title: string; description: string }[] = [
|
||||
{ kind: "welcome", title: "Welcome email", description: "Sent once when a tenant is activated, to the notification address entered on the activation page." },
|
||||
{ kind: "farewell", title: "Farewell email", description: "Sent once after a tenant's offboarding completes, to the address captured at offboarding start." },
|
||||
];
|
||||
|
||||
const SAMPLE_DOMAIN = "contoso.com";
|
||||
|
||||
function renderPreview(html: string, placeholder: string): string {
|
||||
return html.split(placeholder).join(SAMPLE_DOMAIN);
|
||||
}
|
||||
|
||||
function formatUtc(value: string | null): string {
|
||||
if (!value) return "";
|
||||
const d = new Date(value);
|
||||
return Number.isNaN(d.getTime()) ? value : d.toISOString().replace("T", " ").substring(0, 16) + " UTC";
|
||||
}
|
||||
|
||||
export function SectionEmailTemplates() {
|
||||
const { getAccessToken, setError, setSuccessMessage } = useAdminConfig();
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminNotifications />
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow">
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Email Templates</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Preview the transactional emails, send a test to your tenant's contact address, or replace the
|
||||
HTML without a code deployment. Changes apply to real sends immediately; subjects are fixed.
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-6 space-y-6">
|
||||
{KINDS.map((k) => (
|
||||
<TemplateCard
|
||||
key={k.kind}
|
||||
kind={k.kind}
|
||||
title={k.title}
|
||||
description={k.description}
|
||||
getAccessToken={getAccessToken}
|
||||
setError={setError}
|
||||
setSuccessMessage={setSuccessMessage}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface TemplateCardProps {
|
||||
kind: TemplateKind;
|
||||
title: string;
|
||||
description: string;
|
||||
getAccessToken: () => Promise<string | null>;
|
||||
setError: (error: string | null) => void;
|
||||
setSuccessMessage: (message: string | null) => void;
|
||||
}
|
||||
|
||||
function TemplateCard({ kind, title, description, getAccessToken, setError, setSuccessMessage }: TemplateCardProps) {
|
||||
const [state, setState] = useState<TemplateState | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [busy, setBusy] = useState<"save" | "reset" | "test" | null>(null);
|
||||
const [previewHtml, setPreviewHtml] = useState<string | null>(null);
|
||||
|
||||
const fail = useCallback((err: unknown, fallback: string) => {
|
||||
if (err instanceof TokenExpiredError) {
|
||||
console.error("Session expired", err);
|
||||
} else {
|
||||
console.error(fallback, err);
|
||||
}
|
||||
setError(err instanceof Error ? err.message : fallback);
|
||||
}, [setError]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await authenticatedFetch(api.emailTemplates.get(kind), getAccessToken);
|
||||
if (!response.ok) throw new Error(`Failed to load ${kind} template: ${response.statusText}`);
|
||||
const data = (await response.json()) as TemplateState;
|
||||
setState(data);
|
||||
setDraft(data.html);
|
||||
} catch (err) {
|
||||
fail(err, `Failed to load ${kind} template`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [kind, getAccessToken, fail]);
|
||||
|
||||
useEffect(() => {
|
||||
const run = async () => { await load(); };
|
||||
void run();
|
||||
}, [load]);
|
||||
|
||||
const flash = (message: string) => {
|
||||
setSuccessMessage(message);
|
||||
setTimeout(() => setSuccessMessage(null), 6000);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!state) return;
|
||||
try {
|
||||
setBusy("save");
|
||||
setError(null);
|
||||
const response = await authenticatedFetch(api.emailTemplates.save(kind), getAccessToken, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ html: draft }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const body = await response.json().catch(() => null);
|
||||
throw new Error(body?.error ?? `Failed to save ${kind} template: ${response.statusText}`);
|
||||
}
|
||||
flash(`${title} saved — real sends use the customized HTML from now on.`);
|
||||
setEditing(false);
|
||||
await load();
|
||||
} catch (err) {
|
||||
fail(err, `Failed to save ${kind} template`);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = async () => {
|
||||
if (!window.confirm(`Reset the ${title.toLowerCase()} to the built-in template? The customized HTML will be deleted.`)) return;
|
||||
try {
|
||||
setBusy("reset");
|
||||
setError(null);
|
||||
const response = await authenticatedFetch(api.emailTemplates.reset(kind), getAccessToken, { method: "DELETE" });
|
||||
if (!response.ok) throw new Error(`Failed to reset ${kind} template: ${response.statusText}`);
|
||||
flash(`${title} reset to the built-in template.`);
|
||||
setEditing(false);
|
||||
await load();
|
||||
} catch (err) {
|
||||
fail(err, `Failed to reset ${kind} template`);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendTest = async (useDraft: boolean) => {
|
||||
try {
|
||||
setBusy("test");
|
||||
setError(null);
|
||||
const response = await authenticatedFetch(api.emailTemplates.sendTest(kind), getAccessToken, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(useDraft ? { html: draft } : {}),
|
||||
});
|
||||
const body = await response.json().catch(() => null);
|
||||
if (!response.ok) throw new Error(body?.error ?? `Failed to send ${kind} test email: ${response.statusText}`);
|
||||
flash(`Test ${title.toLowerCase()} sent to ${body?.sentTo ?? "your tenant contact address"}${useDraft ? " (unsaved draft)" : ""}.`);
|
||||
} catch (err) {
|
||||
fail(err, `Failed to send ${kind} test email`);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const draftDirty = state !== null && draft !== state.html;
|
||||
const draftTooLong = state !== null && draft.length > state.maxLength;
|
||||
|
||||
return (
|
||||
<div className="border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||
<div className="px-5 py-4 flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-base font-semibold text-gray-900 dark:text-white">{title}</h3>
|
||||
{state && (
|
||||
state.isOverridden ? (
|
||||
<span className="inline-flex items-center rounded-full border border-gray-300 dark:border-gray-600 px-2 py-0.5 text-xs text-gray-700 dark:text-gray-300">
|
||||
Customized{state.updatedBy ? ` by ${state.updatedBy}` : ""}{state.updatedUtc ? ` · ${formatUtc(state.updatedUtc)}` : ""}
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center rounded-full border border-gray-300 dark:border-gray-600 px-2 py-0.5 text-xs text-gray-700 dark:text-gray-300">
|
||||
Built-in
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">{description}</p>
|
||||
{state && (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Subject: <span className="font-medium text-gray-700 dark:text-gray-300">{state.subject}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!state || loading}
|
||||
onClick={() => state && setPreviewHtml(renderPreview(state.html, state.placeholder))}
|
||||
className="px-3 py-1.5 text-sm rounded border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50"
|
||||
>
|
||||
Preview
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!state || busy !== null}
|
||||
onClick={() => handleSendTest(false)}
|
||||
className="px-3 py-1.5 text-sm rounded border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50"
|
||||
>
|
||||
{busy === "test" ? "Sending…" : "Send test to me"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!state || loading}
|
||||
onClick={() => { setEditing((v) => !v); if (state) setDraft(state.html); }}
|
||||
className="px-3 py-1.5 text-sm rounded bg-green-600 text-white hover:bg-green-700 disabled:opacity-50"
|
||||
>
|
||||
{editing ? "Close editor" : "Edit HTML"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editing && state && (
|
||||
<div className="px-5 pb-5 space-y-3 border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Use <code className="font-mono bg-gray-100 dark:bg-gray-700 px-1 rounded">{state.placeholder}</code> where the tenant's domain
|
||||
should appear (an empty domain renders as "your organization"). Inline CSS only — mail clients ignore
|
||||
stylesheets, gradients and images may be blocked. Maximum {state.maxLength.toLocaleString()} characters.
|
||||
</p>
|
||||
{draftTooLong && (
|
||||
<p className="text-xs text-red-600 bg-red-50 border border-red-200 rounded px-3 py-2">
|
||||
{draft.length.toLocaleString()} characters — exceeds the {state.maxLength.toLocaleString()} character limit.
|
||||
</p>
|
||||
)}
|
||||
<textarea
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
rows={22}
|
||||
spellCheck={false}
|
||||
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg text-sm font-mono text-gray-900 dark:text-gray-100 bg-gray-50 dark:bg-gray-900 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 resize-y"
|
||||
/>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPreviewHtml(renderPreview(draft, state.placeholder))}
|
||||
className="px-3 py-1.5 text-sm rounded border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
>
|
||||
Preview draft
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy !== null || draftTooLong || draft.trim() === ""}
|
||||
onClick={() => handleSendTest(true)}
|
||||
className="px-3 py-1.5 text-sm rounded border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50"
|
||||
>
|
||||
{busy === "test" ? "Sending…" : "Send draft as test"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy !== null}
|
||||
onClick={() => setDraft(state.builtInHtml)}
|
||||
className="px-3 py-1.5 text-sm rounded border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50"
|
||||
>
|
||||
Load built-in HTML
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{state.isOverridden && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy !== null}
|
||||
onClick={handleReset}
|
||||
className="px-3 py-1.5 text-sm rounded border border-red-300 text-red-700 hover:bg-red-50 dark:border-red-700 dark:text-red-300 dark:hover:bg-red-900/30 disabled:opacity-50"
|
||||
>
|
||||
{busy === "reset" ? "Resetting…" : "Reset to built-in"}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy !== null || !draftDirty || draftTooLong || draft.trim() === ""}
|
||||
onClick={handleSave}
|
||||
className="px-3 py-1.5 text-sm rounded bg-green-600 text-white hover:bg-green-700 disabled:opacity-50"
|
||||
>
|
||||
{busy === "save" ? "Saving…" : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{previewHtml !== null && (
|
||||
<PreviewModal title={`${title} · preview (${SAMPLE_DOMAIN})`} html={previewHtml} onClose={() => setPreviewHtml(null)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PreviewModal({ title, html, onClose }: { title: string; html: string; onClose: () => void }) {
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-3xl w-full max-h-[90vh] flex flex-col"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="border-b border-gray-200 dark:border-gray-700 px-6 py-4 flex items-start justify-between rounded-t-lg">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">{title}</h2>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Rendered in an isolated frame. Mail clients vary — Outlook drops gradients and slanted edges.
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200" aria-label="Close">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 bg-gray-100 dark:bg-gray-900 p-2">
|
||||
<iframe
|
||||
title={title}
|
||||
sandbox=""
|
||||
srcDoc={html}
|
||||
className="w-full h-[70vh] bg-white rounded border border-gray-200 dark:border-gray-700"
|
||||
/>
|
||||
</div>
|
||||
<div className="bg-gray-50 dark:bg-gray-900 px-6 py-3 border-t border-gray-200 dark:border-gray-700 flex justify-end rounded-b-lg">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-3 py-2 text-sm bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-100 rounded hover:bg-gray-300 dark:hover:bg-gray-600"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ export const SETTINGS_NAV_SECTIONS = [
|
||||
{ id: "config-reseed", label: "Config Reseed", description: "Fetch and reseed rules from GitHub" },
|
||||
{ id: "usage-plans", label: "Usage Plans", description: "Define MCP usage plan tiers and limits" },
|
||||
{ id: "alerts", label: "Alerts", description: "Configure ops event alert rules and notification providers" },
|
||||
{ id: "email-templates", label: "Email Templates", description: "Preview, test and customize the welcome and farewell emails" },
|
||||
] as const;
|
||||
|
||||
export type SettingsSectionId = (typeof SETTINGS_NAV_SECTIONS)[number]["id"];
|
||||
|
||||
@@ -211,6 +211,7 @@ const WEBHOOK_PROVIDERS: Record<number, string> = {
|
||||
2: 'Teams (Workflow Webhook)',
|
||||
10: 'Slack',
|
||||
20: 'Generic JSON',
|
||||
30: 'Discord',
|
||||
};
|
||||
|
||||
function formatValue(val: unknown): string {
|
||||
|
||||
@@ -517,7 +517,7 @@ export default function AnalyzeRuleCard({
|
||||
</div>
|
||||
<p className="text-xs text-gray-600 leading-relaxed">
|
||||
When enabled, each <span className="font-semibold">new</span> finding of this rule sends a message to the selected
|
||||
notification channels (Teams, Slack, webhook — configured under Settings → Notifications) with device, rule, and a
|
||||
notification channels (Teams, Slack, Discord, webhook — configured under Settings → Notifications) with device, rule, and a
|
||||
direct link to the session.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -275,55 +275,57 @@ export default function InstallsTab({ scope, timeRange }: InstallsTabProps) {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr className="text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<th className="px-4 py-3 cursor-pointer select-none" onClick={() => toggleSort("appName")}>App {sortIndicator("appName")}</th>
|
||||
<th className="px-4 py-3 cursor-pointer select-none text-right" onClick={() => toggleSort("totalInstalls")}>Installs {sortIndicator("totalInstalls")}</th>
|
||||
<th className="px-4 py-3 cursor-pointer select-none text-right" onClick={() => toggleSort("succeeded")}>Succeeded {sortIndicator("succeeded")}</th>
|
||||
<th className="px-4 py-3 cursor-pointer select-none text-right" onClick={() => toggleSort("failed")}>Failed {sortIndicator("failed")}</th>
|
||||
<th className="px-4 py-3 cursor-pointer select-none text-right" onClick={() => toggleSort("failureRate")}>Failure Rate {sortIndicator("failureRate")}</th>
|
||||
<th
|
||||
className="px-4 py-3 cursor-pointer select-none text-right"
|
||||
onClick={() => toggleSort("avgDurationSeconds")}
|
||||
title="Average duration of the final install attempt. IME evaluation passes and time queued behind other apps are not counted."
|
||||
>
|
||||
Avg Install Time {sortIndicator("avgDurationSeconds")}
|
||||
</th>
|
||||
<th className="px-4 py-3 cursor-pointer select-none text-right" onClick={() => toggleSort("trend")}>Trend {sortIndicator("trend")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{pageRows.map((row) => (
|
||||
<tr key={row.appName} onClick={() => openApp(row.appName)} className="hover:bg-gray-50 cursor-pointer text-sm">
|
||||
<td className="px-4 py-3 text-gray-900">
|
||||
<span className="font-medium">{row.appName}</span>
|
||||
{appTypeBadge(row.appType)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-gray-700">{row.totalInstalls}</td>
|
||||
<td className="px-4 py-3 text-right text-emerald-700">
|
||||
{row.succeeded}
|
||||
{row.skipped > 0 && (
|
||||
<span
|
||||
className="ml-1 text-xs text-gray-400 dark:text-gray-500"
|
||||
title={`${row.skipped} skipped (not applicable) — excluded from failure rate and durations`}
|
||||
>
|
||||
+{row.skipped}s
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-red-700">{row.failed}</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<span className={row.failureRate >= 20 ? "text-red-700 font-semibold" : row.failureRate >= 5 ? "text-amber-700" : "text-gray-700"}>
|
||||
{row.failureRate.toFixed(1)}%
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-gray-700">{formatDuration(row.avgDurationSeconds, "—")}</td>
|
||||
<td className="px-4 py-3 text-right">{trendBadge(row)}</td>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr className="text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<th className="px-4 py-3 cursor-pointer select-none" onClick={() => toggleSort("appName")}>App {sortIndicator("appName")}</th>
|
||||
<th className="px-4 py-3 cursor-pointer select-none text-right" onClick={() => toggleSort("totalInstalls")}>Installs {sortIndicator("totalInstalls")}</th>
|
||||
<th className="px-4 py-3 cursor-pointer select-none text-right" onClick={() => toggleSort("succeeded")}>Succeeded {sortIndicator("succeeded")}</th>
|
||||
<th className="px-4 py-3 cursor-pointer select-none text-right" onClick={() => toggleSort("failed")}>Failed {sortIndicator("failed")}</th>
|
||||
<th className="px-4 py-3 cursor-pointer select-none text-right" onClick={() => toggleSort("failureRate")}>Failure Rate {sortIndicator("failureRate")}</th>
|
||||
<th
|
||||
className="px-4 py-3 cursor-pointer select-none text-right"
|
||||
onClick={() => toggleSort("avgDurationSeconds")}
|
||||
title="Average duration of the final install attempt. IME evaluation passes and time queued behind other apps are not counted."
|
||||
>
|
||||
Avg Install Time {sortIndicator("avgDurationSeconds")}
|
||||
</th>
|
||||
<th className="px-4 py-3 cursor-pointer select-none text-right" onClick={() => toggleSort("trend")}>Trend {sortIndicator("trend")}</th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{pageRows.map((row) => (
|
||||
<tr key={row.appName} onClick={() => openApp(row.appName)} className="hover:bg-gray-50 cursor-pointer text-sm">
|
||||
<td className="px-4 py-3 text-gray-900">
|
||||
<span className="font-medium">{row.appName}</span>
|
||||
{appTypeBadge(row.appType)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-gray-700">{row.totalInstalls}</td>
|
||||
<td className="px-4 py-3 text-right text-emerald-700">
|
||||
{row.succeeded}
|
||||
{row.skipped > 0 && (
|
||||
<span
|
||||
className="ml-1 text-xs text-gray-400 dark:text-gray-500"
|
||||
title={`${row.skipped} skipped (not applicable) — excluded from failure rate and durations`}
|
||||
>
|
||||
+{row.skipped}s
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-red-700">{row.failed}</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<span className={row.failureRate >= 20 ? "text-red-700 font-semibold" : row.failureRate >= 5 ? "text-amber-700" : "text-gray-700"}>
|
||||
{row.failureRate.toFixed(1)}%
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-gray-700">{formatDuration(row.avgDurationSeconds, "—")}</td>
|
||||
<td className="px-4 py-3 text-right">{trendBadge(row)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{pageCount > 1 && (
|
||||
<div className="flex items-center justify-between px-4 py-3 text-sm text-gray-600 border-t border-gray-200">
|
||||
<span>{filteredAndSorted.length} apps · page {page + 1} of {pageCount}</span>
|
||||
|
||||
@@ -151,36 +151,38 @@ export default function InventoryTab({ scope }: { scope: SoftwareTabScope }) {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr className="text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<th className="px-4 py-3">Software</th>
|
||||
<th className="px-4 py-3">Version</th>
|
||||
<th className="px-4 py-3">Publisher</th>
|
||||
<th className="px-4 py-3 text-right">Sessions</th>
|
||||
<th className="px-4 py-3 text-right">Last seen</th>
|
||||
<th className="px-4 py-3 text-right">CPE</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{pageRows.map((row, idx) => (
|
||||
<tr key={`${row.normalizedVendor}|${row.normalizedName}|${row.normalizedVersion}|${idx}`} className="text-sm">
|
||||
<td className="px-4 py-3 text-gray-900 font-medium">{row.displayName || row.normalizedName}</td>
|
||||
<td className="px-4 py-3 text-gray-700">{row.normalizedVersion || "—"}</td>
|
||||
<td className="px-4 py-3 text-gray-700">{row.publisher || "—"}</td>
|
||||
<td className="px-4 py-3 text-right text-gray-700">{row.sessionCount}</td>
|
||||
<td className="px-4 py-3 text-right text-gray-500">{formatDate(row.lastSeenAt)}</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
{row.cpeUri ? (
|
||||
<span className="inline-block px-1.5 py-0.5 rounded text-xs bg-emerald-100 text-emerald-800">mapped</span>
|
||||
) : (
|
||||
<span className="inline-block px-1.5 py-0.5 rounded text-xs bg-amber-100 text-amber-800">unmatched</span>
|
||||
)}
|
||||
</td>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr className="text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<th className="px-4 py-3">Software</th>
|
||||
<th className="px-4 py-3">Version</th>
|
||||
<th className="px-4 py-3">Publisher</th>
|
||||
<th className="px-4 py-3 text-right">Sessions</th>
|
||||
<th className="px-4 py-3 text-right">Last seen</th>
|
||||
<th className="px-4 py-3 text-right">CPE</th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{pageRows.map((row, idx) => (
|
||||
<tr key={`${row.normalizedVendor}|${row.normalizedName}|${row.normalizedVersion}|${idx}`} className="text-sm">
|
||||
<td className="px-4 py-3 text-gray-900 font-medium">{row.displayName || row.normalizedName}</td>
|
||||
<td className="px-4 py-3 text-gray-700">{row.normalizedVersion || "—"}</td>
|
||||
<td className="px-4 py-3 text-gray-700">{row.publisher || "—"}</td>
|
||||
<td className="px-4 py-3 text-right text-gray-700">{row.sessionCount}</td>
|
||||
<td className="px-4 py-3 text-right text-gray-500">{formatDate(row.lastSeenAt)}</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
{row.cpeUri ? (
|
||||
<span className="inline-block px-1.5 py-0.5 rounded text-xs bg-emerald-100 text-emerald-800">mapped</span>
|
||||
) : (
|
||||
<span className="inline-block px-1.5 py-0.5 rounded text-xs bg-amber-100 text-amber-800">unmatched</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{pageCount > 1 && (
|
||||
<div className="flex items-center justify-between px-4 py-3 text-sm text-gray-600 border-t border-gray-200">
|
||||
<span>{filtered.length} titles · page {page + 1} of {pageCount}</span>
|
||||
|
||||
@@ -459,12 +459,12 @@ function AppDetailContent() {
|
||||
>
|
||||
← Back to all apps
|
||||
</button>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-normal text-gray-900 inline-flex items-center">
|
||||
{appName}
|
||||
<div className="flex flex-wrap items-center justify-between gap-y-3">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl font-normal text-gray-900 flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<span className="break-words">{appName}</span>
|
||||
{analytics?.appType && (
|
||||
<span className="ml-3 px-2 py-0.5 text-xs rounded bg-blue-100 text-blue-800">
|
||||
<span className="px-2 py-0.5 text-xs rounded bg-blue-100 text-blue-800">
|
||||
{analytics.appType}
|
||||
</span>
|
||||
)}
|
||||
@@ -475,7 +475,7 @@ function AppDetailContent() {
|
||||
: ""}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
|
||||
<TenantScopeSelector scope={scope} allowAggregated />
|
||||
{([7, 30, 90] as const).map((d) => (
|
||||
<button
|
||||
@@ -679,69 +679,71 @@ function AppDetailContent() {
|
||||
{/* Top failure codes table */}
|
||||
<Card title="Top Failure Codes">
|
||||
{analytics.topFailureCodes.length > 0 ? (
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="text-left text-xs text-gray-500 uppercase tracking-wider border-b">
|
||||
<tr>
|
||||
<th className="px-3 py-2">Code</th>
|
||||
<th className="px-3 py-2">Description</th>
|
||||
<th className="px-3 py-2">Exit Code</th>
|
||||
<th className="px-3 py-2 text-right">Count</th>
|
||||
<th className="px-3 py-2">Sample Message</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{analytics.topFailureCodes.map((row) => {
|
||||
const entry = getErrorCodeEntry(row.code);
|
||||
return (
|
||||
<tr key={row.code}>
|
||||
<td className="px-3 py-2 font-mono text-xs text-gray-900">
|
||||
{formatErrorCode(row.code)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-gray-700">
|
||||
{entry ? (
|
||||
<>
|
||||
{entry.description}
|
||||
<span
|
||||
className={`ml-2 px-1.5 py-0.5 rounded text-xs ${
|
||||
entry.confidence === "high"
|
||||
? "bg-emerald-100 text-emerald-800"
|
||||
: entry.confidence === "medium"
|
||||
? "bg-amber-100 text-amber-800"
|
||||
: "bg-gray-100 text-gray-700"
|
||||
}`}
|
||||
title={`Source: ${entry.source}`}
|
||||
>
|
||||
{entry.confidence}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-gray-400">Unknown code</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 font-mono text-xs">
|
||||
{row.exitCode != null ? (
|
||||
(() => {
|
||||
const ec = getErrorCodeEntry(row.exitCode);
|
||||
return (
|
||||
<span title={ec?.description ?? ""}>
|
||||
{row.exitCode}
|
||||
{ec ? ` (${ec.description.slice(0, 24)}${ec.description.length > 24 ? "…" : ""})` : ""}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="text-left text-xs text-gray-500 uppercase tracking-wider border-b">
|
||||
<tr>
|
||||
<th className="px-3 py-2">Code</th>
|
||||
<th className="px-3 py-2">Description</th>
|
||||
<th className="px-3 py-2">Exit Code</th>
|
||||
<th className="px-3 py-2 text-right">Count</th>
|
||||
<th className="px-3 py-2">Sample Message</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{analytics.topFailureCodes.map((row) => {
|
||||
const entry = getErrorCodeEntry(row.code);
|
||||
return (
|
||||
<tr key={row.code}>
|
||||
<td className="px-3 py-2 font-mono text-xs text-gray-900">
|
||||
{formatErrorCode(row.code)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-gray-700">
|
||||
{entry ? (
|
||||
<>
|
||||
{entry.description}
|
||||
<span
|
||||
className={`ml-2 px-1.5 py-0.5 rounded text-xs ${
|
||||
entry.confidence === "high"
|
||||
? "bg-emerald-100 text-emerald-800"
|
||||
: entry.confidence === "medium"
|
||||
? "bg-amber-100 text-amber-800"
|
||||
: "bg-gray-100 text-gray-700"
|
||||
}`}
|
||||
title={`Source: ${entry.source}`}
|
||||
>
|
||||
{entry.confidence}
|
||||
</span>
|
||||
);
|
||||
})()
|
||||
) : (
|
||||
<span className="text-gray-400">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right text-gray-900">{row.count}</td>
|
||||
<td className="px-3 py-2 text-gray-500 text-xs truncate max-w-md" title={row.sampleMessage}>
|
||||
{row.sampleMessage || "—"}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-gray-400">Unknown code</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 font-mono text-xs">
|
||||
{row.exitCode != null ? (
|
||||
(() => {
|
||||
const ec = getErrorCodeEntry(row.exitCode);
|
||||
return (
|
||||
<span title={ec?.description ?? ""}>
|
||||
{row.exitCode}
|
||||
{ec ? ` (${ec.description.slice(0, 24)}${ec.description.length > 24 ? "…" : ""})` : ""}
|
||||
</span>
|
||||
);
|
||||
})()
|
||||
) : (
|
||||
<span className="text-gray-400">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right text-gray-900">{row.count}</td>
|
||||
<td className="px-3 py-2 text-gray-500 text-xs truncate max-w-md" title={row.sampleMessage}>
|
||||
{row.sampleMessage || "—"}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState text="No failures recorded" />
|
||||
)}
|
||||
@@ -750,44 +752,46 @@ function AppDetailContent() {
|
||||
{/* Device model breakdown */}
|
||||
<Card title="Device Model Correlation">
|
||||
{analytics.deviceModelBreakdown.length > 0 ? (
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="text-left text-xs text-gray-500 uppercase tracking-wider border-b">
|
||||
<tr>
|
||||
<th className="px-3 py-2">Manufacturer</th>
|
||||
<th className="px-3 py-2">Model</th>
|
||||
<th className="px-3 py-2 text-right">Installs</th>
|
||||
<th className="px-3 py-2 text-right">Failed</th>
|
||||
<th className="px-3 py-2 text-right">Failure Rate</th>
|
||||
<th className="px-3 py-2 text-right">Lift vs baseline</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{analytics.deviceModelBreakdown.map((row, i) => (
|
||||
<tr key={`${row.manufacturer}-${row.model}-${i}`}>
|
||||
<td className="px-3 py-2 text-gray-700">{row.manufacturer}</td>
|
||||
<td className="px-3 py-2 text-gray-900">{row.model}</td>
|
||||
<td className="px-3 py-2 text-right text-gray-700">{row.installs}</td>
|
||||
<td className="px-3 py-2 text-right text-red-700">{row.failed}</td>
|
||||
<td className="px-3 py-2 text-right text-gray-900">
|
||||
{row.failureRate.toFixed(1)}%
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<span
|
||||
className={`px-2 py-0.5 rounded text-xs font-medium ${
|
||||
row.liftVsBaseline >= 2
|
||||
? "bg-red-100 text-red-800"
|
||||
: row.liftVsBaseline >= 1.2
|
||||
? "bg-amber-100 text-amber-800"
|
||||
: "bg-emerald-100 text-emerald-800"
|
||||
}`}
|
||||
>
|
||||
{row.liftVsBaseline.toFixed(2)}×
|
||||
</span>
|
||||
</td>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="text-left text-xs text-gray-500 uppercase tracking-wider border-b">
|
||||
<tr>
|
||||
<th className="px-3 py-2">Manufacturer</th>
|
||||
<th className="px-3 py-2">Model</th>
|
||||
<th className="px-3 py-2 text-right">Installs</th>
|
||||
<th className="px-3 py-2 text-right">Failed</th>
|
||||
<th className="px-3 py-2 text-right">Failure Rate</th>
|
||||
<th className="px-3 py-2 text-right">Lift vs baseline</th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{analytics.deviceModelBreakdown.map((row, i) => (
|
||||
<tr key={`${row.manufacturer}-${row.model}-${i}`}>
|
||||
<td className="px-3 py-2 text-gray-700">{row.manufacturer}</td>
|
||||
<td className="px-3 py-2 text-gray-900">{row.model}</td>
|
||||
<td className="px-3 py-2 text-right text-gray-700">{row.installs}</td>
|
||||
<td className="px-3 py-2 text-right text-red-700">{row.failed}</td>
|
||||
<td className="px-3 py-2 text-right text-gray-900">
|
||||
{row.failureRate.toFixed(1)}%
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<span
|
||||
className={`px-2 py-0.5 rounded text-xs font-medium ${
|
||||
row.liftVsBaseline >= 2
|
||||
? "bg-red-100 text-red-800"
|
||||
: row.liftVsBaseline >= 1.2
|
||||
? "bg-amber-100 text-amber-800"
|
||||
: "bg-emerald-100 text-emerald-800"
|
||||
}`}
|
||||
>
|
||||
{row.liftVsBaseline.toFixed(2)}×
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState text="Not enough installs per device model to compute correlation" />
|
||||
)}
|
||||
@@ -795,7 +799,7 @@ function AppDetailContent() {
|
||||
|
||||
{/* Affected sessions panel */}
|
||||
<Card title="Affected Sessions">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="flex flex-wrap items-center gap-2 mb-3">
|
||||
{(["failed", "all", "succeeded"] as const).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
@@ -868,117 +872,119 @@ function AppDetailContent() {
|
||||
<div className="text-center text-gray-500 p-4">Loading sessions…</div>
|
||||
) : sessions && sessions.items.length > 0 ? (
|
||||
<>
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="text-left text-xs text-gray-500 uppercase tracking-wider border-b">
|
||||
<tr>
|
||||
{activeSessionColumns.map((col) => (
|
||||
<th
|
||||
key={col.key}
|
||||
className={`px-3 py-2 ${col.numeric ? "text-right" : ""}`}
|
||||
>
|
||||
{col.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{sessions.items.map((row) => {
|
||||
const entry = row.failureCode ? getErrorCodeEntry(row.failureCode) : null;
|
||||
return (
|
||||
<tr
|
||||
key={`${row.tenantId}-${row.sessionId}`}
|
||||
onClick={() => router.push(sessionUrl(row.sessionId))}
|
||||
className="hover:bg-gray-50 cursor-pointer"
|
||||
>
|
||||
{activeSessionColumns.map((col) => {
|
||||
switch (col.key) {
|
||||
case "device":
|
||||
return (
|
||||
<td key={col.key} className="px-3 py-2 text-gray-900">
|
||||
{row.deviceName || "—"}
|
||||
</td>
|
||||
);
|
||||
case "tenant": {
|
||||
const friendly = tenantLookup.get(row.tenantId);
|
||||
return (
|
||||
<td
|
||||
key={col.key}
|
||||
className="px-3 py-2 text-gray-700 text-xs"
|
||||
title={row.tenantId}
|
||||
>
|
||||
{friendly || `${row.tenantId.substring(0, 8)}…`}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
case "model":
|
||||
return (
|
||||
<td key={col.key} className="px-3 py-2 text-gray-700 text-xs">
|
||||
{row.manufacturer} {row.model}
|
||||
</td>
|
||||
);
|
||||
case "version":
|
||||
return (
|
||||
<td key={col.key} className="px-3 py-2 text-gray-700 font-mono text-xs">
|
||||
{row.appVersion || "—"}
|
||||
</td>
|
||||
);
|
||||
case "status":
|
||||
return (
|
||||
<td key={col.key} className="px-3 py-2">
|
||||
<span
|
||||
className={`px-2 py-0.5 rounded text-xs ${
|
||||
row.status === "Failed"
|
||||
? "bg-red-100 text-red-800"
|
||||
: row.status === "Succeeded"
|
||||
? "bg-emerald-100 text-emerald-800"
|
||||
: "bg-gray-100 text-gray-700"
|
||||
}`}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="text-left text-xs text-gray-500 uppercase tracking-wider border-b">
|
||||
<tr>
|
||||
{activeSessionColumns.map((col) => (
|
||||
<th
|
||||
key={col.key}
|
||||
className={`px-3 py-2 ${col.numeric ? "text-right" : ""}`}
|
||||
>
|
||||
{col.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{sessions.items.map((row) => {
|
||||
const entry = row.failureCode ? getErrorCodeEntry(row.failureCode) : null;
|
||||
return (
|
||||
<tr
|
||||
key={`${row.tenantId}-${row.sessionId}`}
|
||||
onClick={() => router.push(sessionUrl(row.sessionId))}
|
||||
className="hover:bg-gray-50 cursor-pointer"
|
||||
>
|
||||
{activeSessionColumns.map((col) => {
|
||||
switch (col.key) {
|
||||
case "device":
|
||||
return (
|
||||
<td key={col.key} className="px-3 py-2 text-gray-900">
|
||||
{row.deviceName || "—"}
|
||||
</td>
|
||||
);
|
||||
case "tenant": {
|
||||
const friendly = tenantLookup.get(row.tenantId);
|
||||
return (
|
||||
<td
|
||||
key={col.key}
|
||||
className="px-3 py-2 text-gray-700 text-xs"
|
||||
title={row.tenantId}
|
||||
>
|
||||
{row.status}
|
||||
</span>
|
||||
</td>
|
||||
);
|
||||
case "attempts":
|
||||
return (
|
||||
<td key={col.key} className="px-3 py-2 text-right text-gray-700">
|
||||
{row.attemptNumber || "—"}
|
||||
</td>
|
||||
);
|
||||
case "failureCode":
|
||||
return (
|
||||
<td key={col.key} className="px-3 py-2 font-mono text-xs">
|
||||
{row.failureCode ? (
|
||||
<span title={entry?.description ?? ""}>
|
||||
{formatErrorCode(row.failureCode)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-400">—</span>
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
case "duration":
|
||||
return (
|
||||
<td key={col.key} className="px-3 py-2 text-right text-gray-700">
|
||||
{formatDuration(row.durationSeconds)}
|
||||
{(row.installPassCount ?? 0) > 1 && (
|
||||
{friendly || `${row.tenantId.substring(0, 8)}…`}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
case "model":
|
||||
return (
|
||||
<td key={col.key} className="px-3 py-2 text-gray-700 text-xs">
|
||||
{row.manufacturer} {row.model}
|
||||
</td>
|
||||
);
|
||||
case "version":
|
||||
return (
|
||||
<td key={col.key} className="px-3 py-2 text-gray-700 font-mono text-xs">
|
||||
{row.appVersion || "—"}
|
||||
</td>
|
||||
);
|
||||
case "status":
|
||||
return (
|
||||
<td key={col.key} className="px-3 py-2">
|
||||
<span
|
||||
className="ml-1 text-xs text-gray-400 dark:text-gray-500"
|
||||
title={`Processed in ${row.installPassCount} IME passes (evaluation + install) — install time counts only the final attempt`}
|
||||
className={`px-2 py-0.5 rounded text-xs ${
|
||||
row.status === "Failed"
|
||||
? "bg-red-100 text-red-800"
|
||||
: row.status === "Succeeded"
|
||||
? "bg-emerald-100 text-emerald-800"
|
||||
: "bg-gray-100 text-gray-700"
|
||||
}`}
|
||||
>
|
||||
×{row.installPassCount}
|
||||
{row.status}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
);
|
||||
case "attempts":
|
||||
return (
|
||||
<td key={col.key} className="px-3 py-2 text-right text-gray-700">
|
||||
{row.attemptNumber || "—"}
|
||||
</td>
|
||||
);
|
||||
case "failureCode":
|
||||
return (
|
||||
<td key={col.key} className="px-3 py-2 font-mono text-xs">
|
||||
{row.failureCode ? (
|
||||
<span title={entry?.description ?? ""}>
|
||||
{formatErrorCode(row.failureCode)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-400">—</span>
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
case "duration":
|
||||
return (
|
||||
<td key={col.key} className="px-3 py-2 text-right text-gray-700">
|
||||
{formatDuration(row.durationSeconds)}
|
||||
{(row.installPassCount ?? 0) > 1 && (
|
||||
<span
|
||||
className="ml-1 text-xs text-gray-400 dark:text-gray-500"
|
||||
title={`Processed in ${row.installPassCount} IME passes (evaluation + install) — install time counts only the final attempt`}
|
||||
>
|
||||
×{row.installPassCount}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/* Pagination */}
|
||||
{sessions.total > SESSIONS_PAGE_SIZE && (
|
||||
<div className="flex items-center justify-between mt-3 text-xs text-gray-600">
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { Metadata } from "next";
|
||||
import { SITE_URL } from "@/utils/config";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Get Pro – Purchase Options",
|
||||
description:
|
||||
"How to purchase Autopilot Monitor Pro: direct online purchase and the Microsoft commercial marketplace — both coming soon. Pricing will be announced; Community stays free.",
|
||||
keywords: [
|
||||
"Autopilot Monitor Pro",
|
||||
"Autopilot Monitor buy",
|
||||
"Autopilot Monitor purchase",
|
||||
"Autopilot Monitor marketplace",
|
||||
"Windows Autopilot monitoring Pro",
|
||||
],
|
||||
openGraph: {
|
||||
title: "Get Pro – Autopilot Monitor",
|
||||
description:
|
||||
"How to purchase Autopilot Monitor Pro: direct online purchase and the Microsoft commercial marketplace — both coming soon.",
|
||||
url: `${SITE_URL}/buy`,
|
||||
},
|
||||
twitter: {
|
||||
title: "Get Pro – Autopilot Monitor",
|
||||
description:
|
||||
"How to purchase Autopilot Monitor Pro: direct online purchase and the Microsoft commercial marketplace — both coming soon.",
|
||||
},
|
||||
alternates: {
|
||||
canonical: `${SITE_URL}/buy`,
|
||||
},
|
||||
};
|
||||
|
||||
export default function BuyLayout({ children }: { children: React.ReactNode }) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import Link from "next/link";
|
||||
import { LandingNavbar } from "../../components/landing/LandingNavbar";
|
||||
import { SiteFooter } from "../../components/SiteFooter";
|
||||
|
||||
/**
|
||||
* Purchase page for Pro. Linked from the public /plans page and from the
|
||||
* portal's Plan section (absolute www URL — /buy is public surface).
|
||||
*
|
||||
* Both purchase channels are honest "coming soon" placeholders until the
|
||||
* checkout links exist. When they do: the provider host must be registered in
|
||||
* utils/config.ts, the CSP in staticwebapp.config.json, the swaConfig guard
|
||||
* test, and the dev CSP in next.config.ts — see tasks/todo.md follow-up.
|
||||
*/
|
||||
const CHANNELS = [
|
||||
{
|
||||
label: "Direct purchase",
|
||||
description:
|
||||
"Buy Pro online with a credit card or on invoice — a fast, self-service checkout for a single organization.",
|
||||
},
|
||||
{
|
||||
label: "Microsoft Marketplace",
|
||||
description:
|
||||
"Purchase through the Microsoft commercial marketplace using your organization's existing Microsoft billing relationship.",
|
||||
},
|
||||
];
|
||||
|
||||
export default function BuyPage() {
|
||||
return (
|
||||
<div className="landing-v2 min-h-screen bg-[var(--lp-bg)]">
|
||||
<LandingNavbar />
|
||||
<header className="px-4 sm:px-6 lg:px-8 pt-14 sm:pt-16">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-[var(--lp-ink-faint)]">Pro</p>
|
||||
<h1 className="mt-3 text-3xl sm:text-4xl font-bold tracking-tight text-[var(--lp-ink)]">
|
||||
Get Autopilot Monitor Pro
|
||||
</h1>
|
||||
<p className="mt-4 max-w-2xl text-[15px] text-gray-600 leading-relaxed">
|
||||
Pro will be available through two purchase channels. Neither is open yet — pricing
|
||||
and availability will be announced. Until then, Community is the full product, free
|
||||
for everyone.
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-10 space-y-10 text-[15px]">
|
||||
{/* Purchase channels */}
|
||||
<section className="grid grid-cols-1 sm:grid-cols-2 gap-5">
|
||||
{CHANNELS.map((channel) => (
|
||||
<div key={channel.label} className="bg-[var(--lp-surface)] border border-[var(--lp-line-soft)] rounded-xl p-8 flex flex-col">
|
||||
<h2 className="text-lg font-bold text-gray-900 mb-3">{channel.label}</h2>
|
||||
<p className="text-sm text-gray-600 leading-relaxed mb-4 flex-1">{channel.description}</p>
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
title="Available once Pro pricing is announced."
|
||||
className="w-full rounded-lg border border-[var(--lp-line-soft)] bg-[var(--lp-surface-2)] px-4 py-2.5 text-sm font-semibold text-[var(--lp-ink-faint)] cursor-not-allowed"
|
||||
>
|
||||
Coming soon
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
|
||||
{/* Interim path */}
|
||||
<section className="bg-[var(--lp-surface)] border border-[var(--lp-line-soft)] rounded-xl p-8">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">Questions About Pro?</h2>
|
||||
<p className="text-gray-700 leading-relaxed">
|
||||
Interested in Pro for your organization or an MSP scenario, or want to know when it
|
||||
opens? Reach out through any channel on the{" "}
|
||||
<Link href="/help" className="text-[var(--lp-accent-ink)] hover:opacity-80 underline">
|
||||
Help & Support
|
||||
</Link>{" "}
|
||||
page — you'll talk directly to the person building the platform. And if you
|
||||
haven't compared the plans yet, the{" "}
|
||||
<Link href="/plans" className="text-[var(--lp-accent-ink)] hover:opacity-80 underline">
|
||||
plans overview
|
||||
</Link>{" "}
|
||||
shows exactly what Pro adds.
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
"use client";
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { DOCS_URL } from "@/utils/config";
|
||||
import { trackEvent } from "@/lib/appInsights";
|
||||
|
||||
// Session-scoped dismissal: sessionStorage is per-tab, so the banner stays hidden
|
||||
// across reloads in this tab (service-desk monitor use case) but reappears in every
|
||||
// new tab — deliberately never a permanent opt-out. Module-level store in the
|
||||
// themeStore shape: a same-tab sessionStorage write fires no "storage" event, and
|
||||
// useSyncExternalStore keeps the statically prerendered markup hydration-safe.
|
||||
const DismissKey = "devBannerDismissed";
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
const getSnapshot = (): boolean => sessionStorage.getItem(DismissKey) === "1";
|
||||
const getServerSnapshot = (): boolean => false;
|
||||
|
||||
function subscribe(onStoreChange: () => void): () => void {
|
||||
listeners.add(onStoreChange);
|
||||
return () => listeners.delete(onStoreChange);
|
||||
}
|
||||
|
||||
function dismiss(): void {
|
||||
sessionStorage.setItem(DismissKey, "1");
|
||||
for (const listener of [...listeners]) listener();
|
||||
}
|
||||
|
||||
const linkClassName =
|
||||
"underline font-medium hover:text-green-600 dark:hover:text-blue-200";
|
||||
|
||||
export function ActivelyDevelopedBanner() {
|
||||
const dismissed = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
||||
if (dismissed) return null;
|
||||
|
||||
const trackLink = (link: string) => trackEvent("dev_banner_link_clicked", { link });
|
||||
|
||||
return (
|
||||
<div className="mb-4 bg-blue-50 border border-blue-300 rounded-lg px-4 py-3 flex items-start gap-3 dark:bg-blue-950/30 dark:border-blue-700/50">
|
||||
<svg className="w-4 h-4 text-blue-500 mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z" />
|
||||
</svg>
|
||||
<p className="text-sm text-blue-800 dark:text-blue-300">
|
||||
<span className="font-semibold">Actively developed.</span>{" "}
|
||||
Autopilot Monitor recognizes a wide range of deployment scenarios and improves
|
||||
continuously — your reports directly shape it.{" "}
|
||||
If something looks off, check the{" "}
|
||||
<a
|
||||
href={`${DOCS_URL}/changelog/platform-changelog`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={linkClassName}
|
||||
onClick={() => trackLink("platform_changelog")}
|
||||
>
|
||||
Platform Changelog
|
||||
</a>{" "}
|
||||
or{" "}
|
||||
<a
|
||||
href={`${DOCS_URL}/troubleshooting/service-announcements`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={linkClassName}
|
||||
onClick={() => trackLink("service_announcements")}
|
||||
>
|
||||
Service Announcements
|
||||
</a>
|
||||
.{" "}
|
||||
Feedback or bug report?{" "}
|
||||
<a
|
||||
href="https://github.com/okieselbach/AutopilotMonitor/issues"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={linkClassName}
|
||||
onClick={() => trackLink("github_issues")}
|
||||
>
|
||||
Open a GitHub issue
|
||||
</a>
|
||||
{" "}or message me on{" "}
|
||||
<a
|
||||
href="https://www.linkedin.com/in/oliver-kieselbach/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={linkClassName}
|
||||
onClick={() => trackLink("linkedin")}
|
||||
>
|
||||
LinkedIn
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
trackEvent("dev_banner_dismissed");
|
||||
dismiss();
|
||||
}}
|
||||
aria-label="Hide for this browser tab"
|
||||
title="Hide for this browser tab"
|
||||
className="ml-auto shrink-0 -mr-1 -mt-1 p-1 rounded text-blue-300 hover:text-blue-600 hover:bg-blue-100 transition-colors dark:text-blue-700 dark:hover:text-blue-300 dark:hover:bg-blue-900/40"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,12 +6,12 @@ export function WelcomeMessage() {
|
||||
return (
|
||||
<div className="bg-white shadow rounded-lg overflow-hidden">
|
||||
{/* Top accent bar */}
|
||||
<div className="h-1 bg-gradient-to-r from-blue-500 via-indigo-500 to-purple-500" />
|
||||
<div className="h-1 bg-gradient-to-r from-green-500 via-emerald-500 to-teal-500" />
|
||||
<div className="p-6 sm:p-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-start gap-4 mb-6">
|
||||
<div className="shrink-0 w-10 h-10 rounded-xl bg-blue-100 flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<div className="shrink-0 w-10 h-10 rounded-xl bg-green-100 flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
@@ -49,10 +49,10 @@ export function WelcomeMessage() {
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<a
|
||||
href={DOCS_URL} target="_blank" rel="noopener noreferrer"
|
||||
className="group flex items-center gap-3 rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 hover:border-blue-300 hover:bg-blue-50 transition-colors"
|
||||
className="group flex items-center gap-3 rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 hover:border-green-300 hover:bg-green-50 transition-colors"
|
||||
>
|
||||
<span className="shrink-0 w-8 h-8 rounded-lg bg-white border border-gray-200 group-hover:border-blue-200 flex items-center justify-center shadow-sm">
|
||||
<svg className="w-4 h-4 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<span className="shrink-0 w-8 h-8 rounded-lg bg-white border border-gray-200 group-hover:border-green-200 flex items-center justify-center shadow-sm">
|
||||
<svg className="w-4 h-4 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||
</svg>
|
||||
</span>
|
||||
@@ -60,7 +60,7 @@ export function WelcomeMessage() {
|
||||
<p className="text-sm font-medium text-gray-900">Documentation</p>
|
||||
<p className="text-xs text-gray-500">Setup and configuration guides</p>
|
||||
</div>
|
||||
<svg className="w-4 h-4 text-gray-300 group-hover:text-blue-400 ml-auto shrink-0 transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<svg className="w-4 h-4 text-gray-300 group-hover:text-green-400 ml-auto shrink-0 transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</a>
|
||||
|
||||
@@ -12,6 +12,7 @@ import { useAuth } from "../../contexts/AuthContext";
|
||||
import { useNotifications } from "../../contexts/NotificationContext";
|
||||
import { StatsCard } from "./components/StatsCards";
|
||||
import { WelcomeMessage } from "./components/WelcomeMessage";
|
||||
import { ActivelyDevelopedBanner } from "./components/ActivelyDevelopedBanner";
|
||||
import { SessionTable } from "./components/SessionTable";
|
||||
import { TenantFilterBar } from "./components/TenantFilterBar";
|
||||
import { DeleteConfirmModal, BlockConfirmModal } from "./components/ConfirmationModals";
|
||||
@@ -29,7 +30,6 @@ import { api } from "@/lib/api";
|
||||
import { authenticatedFetch } from "@/lib/authenticatedFetch";
|
||||
import { formatDuration } from "@/lib/formatting";
|
||||
import { hasTenantReadScope } from "@/lib/tenantScope";
|
||||
import { DOCS_URL } from "@/utils/config";
|
||||
import { TableSkeleton } from "@/components/skeletons/TableSkeleton";
|
||||
|
||||
export default function Home() {
|
||||
@@ -345,55 +345,8 @@ function HomeContent() {
|
||||
{/* Main content */}
|
||||
<main className={mainClassName}>
|
||||
<div className="px-4 sm:px-0">
|
||||
{/* Feedback & bug report banner */}
|
||||
<div className="mb-4 bg-blue-50 border border-blue-300 rounded-lg px-4 py-3 flex items-start gap-3 dark:bg-blue-950/30 dark:border-blue-700/50">
|
||||
<svg className="w-4 h-4 text-blue-500 mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z" />
|
||||
</svg>
|
||||
<p className="text-sm text-blue-800 dark:text-blue-300">
|
||||
<span className="font-semibold">Actively developed.</span>{" "}
|
||||
Autopilot Monitor recognizes a wide range of deployment scenarios and improves
|
||||
continuously — your reports directly shape it.{" "}
|
||||
If something looks off, check the{" "}
|
||||
<a
|
||||
href={`${DOCS_URL}/changelog/platform-changelog`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline font-medium hover:text-green-600 dark:hover:text-blue-200"
|
||||
>
|
||||
Platform Changelog
|
||||
</a>{" "}
|
||||
or{" "}
|
||||
<a
|
||||
href={`${DOCS_URL}/troubleshooting/service-announcements`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline font-medium hover:text-green-600 dark:hover:text-blue-200"
|
||||
>
|
||||
Service Announcements
|
||||
</a>
|
||||
.{" "}
|
||||
Feedback or bug report?{" "}
|
||||
<a
|
||||
href="https://github.com/okieselbach/AutopilotMonitor/issues"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline font-medium hover:text-green-600 dark:hover:text-blue-200"
|
||||
>
|
||||
Open a GitHub issue
|
||||
</a>
|
||||
{" "}or message me on{" "}
|
||||
<a
|
||||
href="https://www.linkedin.com/in/oliver-kieselbach/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline font-medium hover:text-green-600 dark:hover:text-blue-200"
|
||||
>
|
||||
LinkedIn
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
{/* Feedback & bug report banner (session-dismissable, telemetry-instrumented) */}
|
||||
<ActivelyDevelopedBanner />
|
||||
|
||||
{serialValidationEnabled === false && (
|
||||
<div className="mb-6 bg-red-600 border-2 border-red-700 rounded-xl p-5 shadow-lg">
|
||||
|
||||
@@ -103,9 +103,7 @@ export default function GetStartedPage() {
|
||||
<p className="mt-6 text-sm text-[var(--lp-ink-faint)] leading-relaxed max-w-xl">
|
||||
When you need more later: the{" "}
|
||||
<a
|
||||
href={`${DOCS_URL}/plans`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="/plans"
|
||||
className="text-[var(--lp-accent-ink)] hover:opacity-80 underline"
|
||||
>
|
||||
Pro plan
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user