added optional EnrollmentSummary dialog, better Event Timeline (added search) and Phase Timeline (clickable Phases)

This commit is contained in:
Oliver Kieselbach
2026-03-06 12:10:20 +01:00
parent 7de7ebadf3
commit 4982ae893d
22 changed files with 2341 additions and 62 deletions
+733 -1
View File
@@ -1,4 +1,10 @@
# Architecture
# Autopilot Monitor – Architecture Guide
> Living document describing the system architecture, design decisions, and conventions.
---
## High-Level Overview
```text
+----------------------------------------------------------------------------------+
@@ -36,3 +42,729 @@
| Agent on Windows Devices | | Teams / Webhooks ... |
| Enrollment + app progress | +---------------------------+
+---------------------------+
```
**Core Data Flow:**
```
Device (Agent) ──NDJSON+gzip──► Azure Functions ──► Azure Table Storage
│ │
├── SignalR ─────────►│
│ │
Web Dashboard ◄── REST API ◄──┘
```
| Component | Technology | Purpose |
|-----------|------------|---------|
| **Agent** | .NET Framework 4.8 (Windows) | Runs on devices during enrollment, collects telemetry |
| **Backend** | Azure Functions .NET 8 (Isolated Worker) | REST API, event processing, rule engine, storage |
| **Web** | Next.js 15 + TypeScript + React 18 | Dashboard, settings, analytics, real-time UI |
| **Shared** | .NET Standard 2.0 | DTOs, models, enums, constants shared between Agent & Backend |
---
## Table of Contents
1. [Solution Structure](#solution-structure)
2. [Agent](#agent)
3. [Backend](#backend)
4. [Web Frontend](#web-frontend)
5. [Shared Library](#shared-library)
6. [Data Model](#data-model)
7. [Security Architecture](#security-architecture)
8. [Real-Time Communication](#real-time-communication)
9. [Rules Engine](#rules-engine)
10. [Session Lifecycle](#session-lifecycle)
11. [Configuration Hierarchy](#configuration-hierarchy)
12. [Diagnostics & Upload](#diagnostics--upload)
13. [Infrastructure & Deployment](#infrastructure--deployment)
14. [Build & Development](#build--development)
---
## Solution Structure
```
AutopilotMonitor.sln
├── src/
│ ├── Shared/AutopilotMonitor.Shared/ (netstandard2.0)
│ ├── Agent/
│ │ ├── AutopilotMonitor.Agent/ (net48, Exe – entry point)
│ │ └── AutopilotMonitor.Agent.Core/ (net48, Lib – core logic)
│ ├── Backend/
│ │ ├── AutopilotMonitor.Functions/ (net8.0, Azure Functions v4)
│ │ └── AutopilotMonitor.Functions.Tests/ (net8.0, xUnit)
│ └── Web/
│ └── autopilot-monitor-web/ (Next.js 15, TypeScript)
├── rules/
│ ├── gather/ Individual gather rule JSONs
│ ├── analyze/ Individual analyze rule JSONs
│ ├── ime-log-patterns/ IME regex pattern JSONs
│ ├── schema/ JSON Schema definitions
│ ├── scripts/ combine.js
│ └── dist/ Combined output (embedded in Functions)
└── .github/workflows/ CI/CD pipelines
```
**Project References:**
```
Shared ◄── Agent.Core ◄── Agent
Shared ◄── Functions ◄── Functions.Tests
Web (independent – communicates via REST + SignalR)
```
---
## Agent
### Entry Point & Modes
**File:** `src/Agent/AutopilotMonitor.Agent/Program.cs`
Three execution modes:
1. **Normal mode** (default) – Main enrollment monitoring loop
2. **`--install` mode** – Deploys agent via Scheduled Task (Intune package)
3. **`--run-gather-rules` mode** – One-shot offline data collection, then exits
### Startup Sequence (Normal Mode)
1. Single-instance check (prevent duplicate agent processes)
2. `FetchRemoteConfig()` – Backend config with 15s timeout, disk cache fallback
3. `RegisterSessionAsync()` – Register session (5 retries, exponential backoff)
4. `StartWatching()` – Enable FileSystemWatcher for event spool
5. `StartEventCollectors()` – EspAndHelloTracker (always on)
6. `StartOptionalCollectors()` – PerformanceCollector, AgentSelfMetrics, EnrollmentTracker, DesktopArrivalDetector
7. `StartGatherRuleExecutor()` – Backend-defined data collection rules
8. `InitializeAnalyzers()` + `RunStartupAnalyzers()` – Security baseline
### Key Services
| Service | Location | Responsibility |
|---------|----------|----------------|
| `MonitoringService` | `Core/Monitoring/Core/` | Main orchestrator: starts/stops collectors, manages upload loop |
| `BackendApiClient` | `Core/Monitoring/Network/` | HTTP client with mTLS cert + hardware headers |
| `EventSpool` | `Core/Monitoring/Core/` | Offline event storage (JSON files), FileSystemWatcher-based |
| `EnrollmentTracker` | `Core/Monitoring/Tracking/` | Central enrollment state machine, 3 completion paths |
| `ImeLogTracker` | `Core/Monitoring/Tracking/` | Parses IME logs with backend-provided regex patterns |
| `EspAndHelloTracker` | `Core/Monitoring/Collectors/` | Windows event log monitoring (ESP exit, Hello provisioning) |
| `DesktopArrivalDetector` | `Core/Monitoring/Collectors/` | Polls for explorer.exe under real user (non-SYSTEM) |
| `PerformanceCollector` | `Core/Monitoring/Collectors/` | CPU/Memory/Disk metrics |
| `GatherRuleExecutor` | `Core/Monitoring/Collectors/` | Executes backend-defined data collection rules |
| `DiagnosticsPackageService` | `Core/Monitoring/Core/` | Creates ZIP + uploads via short-lived SAS URL |
| `RemoteConfigService` | `Core/Configuration/` | Fetches & caches backend config with disk fallback |
| `SessionPersistence` | `Core/Monitoring/Core/` | Persists session ID, sequence counter, markers |
### Directory Layout (Agent.Core)
```
Monitoring/
├── Analyzers/ Security checks (LocalAdminAnalyzer)
├── Collectors/ Data collectors (ESP, Hello, Performance, GatherRules, Diagnostics)
├── Core/ Orchestration (MonitoringService, EventSpool, SessionPersistence)
├── Network/ API client, emergency reporter, geo-location, network metrics
├── Replay/ Log replay for testing/simulation
└── Tracking/ Enrollment state machine, IME parser, script tracking
```
### Event Collection & Upload
1. Collector emits `EnrollmentEvent` → sequence number auto-assigned (thread-safe Interlocked)
2. Event saved to spool as JSON file: `event_{timestamp}_{sequence}.json`
3. FileSystemWatcher triggers debounce timer (configurable `UploadIntervalSeconds`, default 30s)
4. Batch upload: NDJSON + gzip compression, max 100 events per batch
5. Response handling: `DeviceKillSignal` → self-destruct; `DeviceBlocked` → stop uploads
### Idle Timeout & Lifetime
- **Collector Idle Timeout**: Default 15min (`CollectorIdleTimeoutMinutes`). Tracks `_lastRealEventTime`. "Real" events = everything except `performance_snapshot`, `agent_metrics_snapshot`, and `*_stopped` variants. Idle collectors auto-restart on new activity.
- **Agent Max Lifetime**: Default 360min/6h (`AgentMaxLifetimeMinutes`). Emits `enrollment_failed` with `failureType="agent_timeout"`.
- **Session Age Emergency Break**: 48h absolute max. Checked at startup, triggers cleanup.
### Agent Data Paths
```
%ProgramData%\AutopilotMonitor\
├── session.id, session.seq, session.created
├── whiteglove.complete
├── bootstrap-config.json
├── Logs/agent.log
├── Spool/event_*.json
├── Config/remote-config.json
└── State/
├── enrollment-state.json
├── ime-tracker-state.json
└── enrollment-complete.marker
```
---
## Backend
### Azure Functions Setup
- **Runtime:** .NET 8 Isolated Worker, Azure Functions v4
- **Route Prefix:** `/api`
- **Monitoring:** Application Insights with sampling
- **Entry Point:** `src/Backend/AutopilotMonitor.Functions/Program.cs`
### Endpoints (~60 Functions)
**Agent-to-Cloud (device auth via cert/bootstrap):**
| Route | Method | Purpose |
|-------|--------|---------|
| `/agent/register-session` | POST | Register new enrollment session |
| `/agent/ingest` | POST | Upload events (NDJSON+gzip) |
| `/agent/config` | GET | Fetch agent configuration |
| `/agent/upload-url` | POST | Get short-lived SAS URL for diagnostics |
| `/agent/error` | POST | Report agent errors |
**Bootstrap (pre-MDM auth):**
| Route | Method | Purpose |
|-------|--------|---------|
| `/bootstrap/sessions` | POST/GET | Create/list bootstrap sessions |
| `/bootstrap/sessions/{code}` | DELETE | Revoke bootstrap session |
| `/bootstrap/validate/{code}` | POST | Validate bootstrap code |
| `/bootstrap/register-session` | POST | Register via bootstrap token |
| `/bootstrap/ingest` | POST | Ingest via bootstrap token |
| `/bootstrap/config` | GET | Config via bootstrap token |
**Web Portal (JWT auth):**
| Category | Key Routes |
|----------|------------|
| **Sessions** | `GET /sessions`, `GET /sessions/{id}`, `GET /sessions/{id}/events`, `POST /sessions/{id}/analysis`, `DELETE /sessions/{id}`, `POST /sessions/{id}/mark-failed` |
| **Rules** | CRUD for `/rules/gather`, `/rules/analyze`, `/rules/ime-log-patterns`, `POST /rules/reseed-from-github` |
| **Config** | `GET/POST /global/config`, `GET/POST /config/{tenantId}`, `POST /config/{tenantId}/security-bypass` |
| **Auth** | `GET /auth/me`, `GET /auth/is-galactic-admin`, CRUD `/auth/galactic-admins` |
| **Tenants** | CRUD `/tenants/{id}/admins`, `POST /tenants/{id}/offboard` |
| **Devices** | `POST /devices/block`, `DELETE /devices/block/{serial}`, `GET /devices/blocked` |
| **Reports** | `POST /sessions/{id}/report`, `GET /galactic/session-reports` |
| **Metrics** | `GET /metrics/usage`, `GET /metrics/app`, `GET /metrics/geographic`, `GET /stats/platform` |
| **Galactic** | `/galactic/metrics/*`, `/galactic/audit/logs`, `/galactic/session-reports` |
| **SignalR** | `POST /realtime/negotiate`, `POST /realtime/groups/join`, `POST /realtime/groups/leave` |
| **Health** | `GET /health`, `GET /health/detailed` |
**Timer Trigger:**
- `DailyMaintenanceFunction` – Every 2 hours (`0 0 */2 * * *`): stale session detection, metrics aggregation, data cleanup
### Key Backend Services
| Service | Responsibility |
|---------|----------------|
| `TableStorageService` | Core data access for all 21 Azure Tables (split across 5 files) |
| `TenantConfigurationService` | Per-tenant config with 5-min cache |
| `AdminConfigurationService` | Global config with 5-min cache, syncs rate limits to tenants |
| `RateLimitService` | In-memory sliding window rate limiting (1-min window) |
| `SecurityValidator` | Centralized request validation (cert → rate limit → hardware → APV) |
| `RuleEngine` | Server-side analyze rule evaluation with confidence scoring |
| `MaintenanceService` | Cleanup, metrics aggregation, stale session detection |
| `BootstrapSessionService` | Bootstrap token lifecycle (create, validate, revoke) |
| `BlockedDeviceService` | Device block/kill signal management |
| `SessionReportService` | Report ZIP generation + Blob upload |
| `GraphTokenService` | MS Graph token acquisition for Autopilot device validation |
### Event Processing Pipeline
```
Agent POST /api/agent/ingest (NDJSON+gzip)
│
├─ SecurityValidator.ValidateRequestAsync()
│ ├─ Tenant existence & suspension
│ ├─ Bootstrap token gate (if present)
│ ├─ Certificate validation against Intune CAs
│ ├─ Rate limiting (sliding window)
│ ├─ Hardware whitelist
│ └─ Autopilot device validation (optional)
│
├─ BlockedDeviceService.IsBlockedAsync() → kill/block signal
│
├─ ParseNdjsonGzipRequest() → decompress + parse events
│
├─ TableStorageService.StoreEventsBatchAsync()
│
├─ ClassifyEvents()
│ ├─ Extract geo-location
│ ├─ Track app installs → AppInstallSummaries table
│ └─ Detect enrollment completion/failure
│
├─ UpdateSessionStatusAsync() → merge session row
│
├─ RuleEngine.AnalyzeSessionAsync() (on enrollment end)
│ └─ StoreRuleResultAsync() → RuleResults table
│
└─ SignalR broadcasts:
├─ "eventReceived" → tenant-{tenantId}
├─ "sessionStatusChanged" → tenant-{tenantId}
└─ "ruleResultReceived" → tenant-{tenantId}
```
---
## Web Frontend
### Technology Stack
- **Framework:** Next.js 15.1.6 (App Router)
- **Language:** TypeScript 5.7.3
- **UI:** React 18.2.0 + Tailwind CSS 3.4.17 (dark mode via `class` strategy)
- **Auth:** MSAL.js 3.28 (`@azure/msal-browser` + `@azure/msal-react`)
- **Real-time:** `@microsoft/signalr` 10.0.0
- **Maps:** Leaflet 1.9.4 + react-leaflet
- **Compression:** fflate 0.8.2
### Page Routes
| Path | Purpose | Auth | Role |
|------|---------|------|------|
| `/` | Landing page with platform stats | Public | — |
| `/dashboard` | Session list, real-time updates | Yes | Any |
| `/sessions/[sessionId]` | Session detail, event timeline, analysis | Yes | Any |
| `/diagnosis/[sessionId]` | Rule analysis results | Yes | Any |
| `/settings` | Tenant configuration (10+ sections) | Yes | Tenant Admin |
| `/admin-configuration` | Global admin settings | Yes | Galactic Admin |
| `/gather-rules` | Gather rule CRUD | Yes | Tenant Admin |
| `/analyze-rules` | Analyze rule CRUD | Yes | Tenant Admin |
| `/ime-log-patterns` | IME pattern management | Yes | Tenant Admin |
| `/fleet-health` | App metrics, fleet analytics | Yes | Tenant Admin |
| `/usage-metrics` | Tenant usage analytics | Yes | Tenant Admin |
| `/platform-metrics` | Platform-wide metrics | Yes | Galactic Admin |
| `/audit` | Audit log viewer | Yes | Tenant Admin |
| `/health-check` | Backend health status | Yes | Galactic Admin |
| `/geographic-performance` | Geo map of deployments | Yes | Galactic Admin |
| `/docs/[section]` | Documentation | Public | — |
### State Management
React Context API (no Redux/Zustand):
| Context | Purpose |
|---------|---------|
| `AuthContext` | MSAL + user info + role detection (galactic/tenant admin) |
| `SignalRContext` | WebSocket connection, group subscriptions, auto-reconnect |
| `TenantContext` | Current tenant ID (persisted to localStorage) |
| `NotificationContext` | Toast notifications with auto-dismiss + deduplication |
| `ThemeContext` | Dark mode toggle (localStorage) |
### API Communication
- `lib/authenticatedFetch.ts` – Wraps `fetch()` with Bearer token, 401 retry with token refresh
- `hooks/useAuthenticatedFetch.ts` – React hook with loading/error state
- Tenant isolation: All endpoints append `?tenantId={id}`
### Key UI Components
| Component | Location | Purpose |
|-----------|----------|---------|
| `SessionTable` | `dashboard/components/` | Paginated, filterable, sortable session list |
| `EventTimeline` | `sessions/[id]/components/` | Phase-grouped event visualization |
| `PhaseTimeline` | `sessions/[id]/components/` | Visual phase progress with live activity |
| `ProtectedRoute` | `components/` | Auth guard with role-based access |
| `ScriptExecutions` | `components/` | Script output viewer |
| `PerformanceChart` | `components/` | Time-series metrics chart |
### UI Patterns
- Settings pages use `error` (string|null) + `successMessage` (string|null) state for notifications
- Notifications rendered at top of `<main>` content area
- `"use client"` directive on interactive components
- Expand/collapse sections with guard blocks
- `UnsavedChangesModal` prevents navigation with unsaved form state
---
## Shared Library
**Target:** .NET Standard 2.0 (compatible with both net48 Agent and net8.0 Backend)
### Model Categories
| Namespace | Key Classes | Purpose |
|-----------|-------------|---------|
| `Models/Enrollment/` | `EnrollmentEvent`, `SessionRegistration`, `EnrollmentPhase`, `BootstrapSession` | Core enrollment data |
| `Models/Config/` | `AgentConfigResponse`, `CollectorConfiguration`, `TenantConfiguration`, `AdminConfiguration` | Configuration hierarchy |
| `Models/Rules/` | `GatherRule`, `AnalyzeRule`, `ImeLogPattern`, `RuleResult` | Rules engine |
| `Models/Metrics/` | `UsageMetrics`, `PlatformStats`, `AppInstallSummary` | Analytics |
| `Models/Diagnostics/` | `DiagnosticsLogPath`, `AgentErrorReport` | Diagnostics collection |
| `ApiModels.cs` | Request/Response pairs for all endpoints | API contracts |
| `Constants.cs` | Table names, API endpoints, event types, defaults | Shared constants |
### Key Enums
- **`EnrollmentPhase`**: Unknown(-1), Start(0), DevicePreparation(1), DeviceSetup(2), AppsDevice(3), AccountSetup(4), AppsUser(5), FinalizingSetup(6), Complete(7), Failed(99)
- **`Severity`**: Debug(0), Info(1), Warning(2), Error(3), Critical(4)
- **50+ Event Types**: `phase_transition`, `app_install_completed`, `enrollment_complete`, `enrollment_failed`, `esp_state_change`, `performance_snapshot`, `script_completed`, `gather_result`, etc.
---
## Data Model
### Azure Table Storage (21 Tables)
| Table | PartitionKey | RowKey | Purpose |
|-------|-------------|--------|---------|
| `Sessions` | TenantId | SessionId | Enrollment sessions |
| `EnrollmentEvents` | TenantId | Timestamp_EventId | Individual events |
| `AdminConfiguration` | "GlobalConfig" | "config" | Platform-wide settings |
| `TenantConfiguration` | TenantId | "config" | Per-tenant settings |
| `GatherRules` | TenantId | RuleId | Data collection rules |
| `AnalyzeRules` | TenantId | RuleId | Issue detection rules |
| `ImeLogPatterns` | TenantId | PatternId | IME log regex patterns |
| `RuleResults` | TenantId | SessionId_RuleId | Analysis findings |
| `UsageMetrics` | TenantId | MetricDate | Daily usage snapshots |
| `AppInstallSummaries` | TenantId | SessionId_AppName | Per-app install data |
| `PlatformStats` | "Global" | "stats" | Cumulative platform stats |
| `AuditLogs` | TenantId | Timestamp_Id | Admin action audit trail |
| `BootstrapSessions` | TenantId / "CodeLookup" | ShortCode | OOBE bootstrap tokens |
| `BlockedDevices` | TenantId | SerialNumber | Blocked devices |
| `SessionReports` | TenantId | ReportId | User-submitted reports |
| `GalacticAdmins` | "GalacticAdmins" | UPN | Platform-level admins |
| `TenantAdmins` | TenantId | UPN | Tenant-level admins |
| `PreviewWhitelist` | "Preview" | TenantId | Preview access gate |
| `UserActivity` | TenantId | UserId | User login tracking |
| `RuleStates` | TenantId | RuleId | Rule enable/disable state |
| `PreviewConfig` | "Preview" | "config" | Preview feature config |
### Azure Blob Storage
- **Diagnostics container**: Agent-uploaded ZIP packages (`AgentDiagnostics-{sessionId}-{ts}.zip`)
- **Session reports container**: User-submitted report ZIPs
- **Platform stats blob**: Cached JSON for landing page
### Entity Relationships
```
Session (1) ──► (N) EnrollmentEvent
Session (1) ──► (N) RuleResult
Session (1) ──► (N) AppInstallSummary
Session (1) ──► (0..1) SessionReport
Session (N) ◄── (1) TenantConfiguration
TenantConfiguration (N) ◄── (1) AdminConfiguration (inherits defaults)
GatherRule ──► (agent executes) ──► EnrollmentEvent (gather_result)
AnalyzeRule ──► (backend evaluates) ──► RuleResult
ImeLogPattern ──► (agent matches) ──► EnrollmentEvent (various types)
```
---
## Security Architecture
### Authentication Layers
**Agent → Backend (device auth):**
1. **MDM Client Certificate** (primary)
- Agent discovers Intune MDM cert in LocalMachine\My store
- Sent via TLS (mTLS), forwarded as `X-ARR-ClientCert` by Azure App Service
- Validated against embedded Intune intermediate + root CA certificates
2. **Bootstrap Token** (pre-MDM, OOBE)
- Admin creates time-limited token+code via web UI
- Agent sends as `X-Bootstrap-Token` header
- Bypasses cert/rate/hardware validation
3. **Hardware Headers** (supplementary)
- `X-Device-SerialNumber`, `X-Device-Manufacturer`, `X-Device-Model`
- Used for whitelist validation and device identification
**Web → Backend (user auth):**
- Microsoft Entra ID multi-tenant JWT via `AuthenticationMiddleware`
- Dynamic OIDC metadata per tenant (cached 24h)
- Claims: `tid` (tenant), `upn` (user), `oid` (object ID)
### Validation Pipeline (per agent request)
```
ValidateSecurityAsync() (SecurityValidationExtensions.cs)
├─ 1. Tenant existence & suspension check (cheapest first)
├─ 2. Bootstrap token gate (if present → short-circuit)
├─ 3. Certificate validation against Intune CA chain
├─ 4. Rate limiting (sliding window, 1-min, per-device)
├─ 5. Hardware whitelist check (optional per tenant)
└─ 6. Autopilot device validation via MS Graph (optional per tenant)
```
### Tenant Data Isolation
- All Table Storage queries filtered by `PartitionKey = TenantId`
- SignalR groups: `tenant-{tenantId}` for scoped broadcasts
- JWT `tid` claim determines tenant for web requests
### Roles
| Role | Scope | Capabilities |
|------|-------|-------------|
| **Galactic Admin** | Platform-wide | Global config, all tenants, platform metrics, health checks |
| **Tenant Admin** | Single tenant | Tenant config, rules, admin management, device blocking |
| **User** | Single tenant | Read-only dashboard, session detail view |
---
## Real-Time Communication
### SignalR Integration
- **Hub:** `autopilotmonitor`
- **Transport:** WebSocket with auto-reconnect (0s, 2s, 10s, 30s backoff)
- **Token factory:** Fresh JWT per connection attempt
### Groups & Events
| Group | Events | Triggered By |
|-------|--------|-------------|
| `tenant-{tenantId}` | `newSession`, `sessionStatusChanged`, `eventReceived`, `ruleResultReceived` | Event ingestion, session registration |
| `galactic-admins` | `newSession` (all tenants), platform stats updates | Cross-tenant broadcasts |
### Client-Side (SignalRContext)
- Auto-rejoin groups after reconnect
- Components subscribe on mount, unsubscribe on unmount
- Connection state exposed for UI indicators
---
## Rules Engine
### Three Rule Types
| Type | Execution | Purpose |
|------|-----------|---------|
| **Gather Rules** | Agent-side | Collect data from registry, WMI, event logs, files, commands |
| **Analyze Rules** | Backend-side | Detect issues in enrollment events with confidence scoring |
| **IME Log Patterns** | Agent-side | Parse IME/AppWorkload/HealthScripts logs with regex |
### Gather Rules
- **Collector Types:** `registry`, `wmi`, `eventlog`, `file`, `command_allowlisted`, `logparser`
- **Triggers:** `startup`, `interval`, `phase_change`, `on_event`
- **Security:** Command allowlist enforced by `GatherRuleGuards`
- **Output:** Emits `gather_result` event with collected data
### Analyze Rules
- **Conditions:** Match events by source, signal, operator, value, with event correlation
- **Confidence Scoring:** `BaseConfidence` + `ConfidenceFactors` (signal × weight), threshold at 40
- **Output:** `RuleResult` with explanation, remediation steps, related docs
- **Trigger:** Evaluated after enrollment completion/failure
### IME Log Patterns
- **Categories:** `always` (all phases), `currentPhase`, `otherPhases`
- **Actions:** 50+ (e.g., `setCurrentApp`, `updateStateInstalled`, `espPhaseDetected`)
- **Regex:** Named capture groups, `{GUID}` placeholder for Intune policy IDs
- **Tracked Logs:** `IntuneManagementExtension*.log`, `AppWorkload*.log`, `AgentExecutor*.log`, `HealthScripts*.log`
### Rule Distribution
1. Individual JSON files in `rules/gather/`, `rules/analyze/`, `rules/ime-log-patterns/`
2. GitHub Actions validates against JSON Schema + combines into `rules/dist/`
3. Combined files embedded as resources in Functions assembly
4. Served to agents via `/api/agent/config`, manageable via web UI per tenant
---
## Session Lifecycle
### Three Completion Paths
```
Path 1: IME Pattern Completion
IME logs show all apps completed → Hello wait → enrollment_complete
Path 2: ESP Exit + Hello (Composite)
ESP final exit (event 62407) → Hello wait (300s) → enrollment_complete
Path 3: Desktop Arrival (No-ESP / WDP v2)
explorer.exe detected under real user → Hello wait → enrollment_complete
```
### ESP & Hello Tracking
- **ESP Events:** Shell-Core event log (62404=Hello wizard start, 62407=ESP exit/WhiteGlove)
- **Hello Events:** User Device Registration log (300=NGC success, 301=NGC failure)
- **Hello Wait:** 30s for wizard start → 300s for completion → timeout
- **Policy Check:** WHfB policy registry poll every 10s; skip Hello wait if not configured
### Failure Detection
- **Terminal failures** (Failure, Abort, WhiteGlove_Failed) → immediate `enrollment_failed`
- **Recoverable failures** (Timeout) → 60s grace period before marking failed
- **Auth failures** → circuit breaker (max 5 attempts or configurable timeout)
- **Device-Only ESP:** 5-min timer after DeviceSetup exit; if no AccountSetup → device-only classification
### WhiteGlove (Pre-Provisioning)
- Part 1: `whiteglove_complete` → persist state, exit gracefully (no self-destruct, session preserved)
- Part 2: Agent restarts on next boot, detects `whiteglove.complete` marker → `whiteglove_resumed`
- Session survives across reboot; sequence counter persisted
### State Persistence (Crash Recovery)
| File | Purpose |
|------|---------|
| `enrollment-state.json` | ESP flags, Hello state, completion signals |
| `ime-tracker-state.json` | Phase order, seen apps, file positions |
| `session.id` / `session.seq` | Session identity + event sequence counter |
| `enrollment-complete.marker` | Cleanup retry flag if previous cleanup failed |
### Cleanup & Self-Destruct
1. Stop collectors, run shutdown analyzers
2. Drain event spool, final upload
3. Upload diagnostics ZIP (if configured)
4. Remove Scheduled Task, delete binaries/config/spool
5. Optionally reboot device (`RebootOnComplete`)
---
## Configuration Hierarchy
```
AdminConfiguration (global, single row in Azure Table)
│ GlobalRateLimitRequestsPerMinute (default 100)
│ CollectorIdleTimeoutMinutes (default 15)
│ AgentMaxLifetimeMinutes (default 360)
│ DiagnosticsGlobalLogPathsJson
│
└──► TenantConfiguration (per tenant, inherits/overrides)
│ Rate limiting (override or inherit global)
│ Hardware whitelist, Autopilot device validation
│ Collector intervals, Hello timeout
│ Diagnostics: UploadEnabled, LogPathsJson
│ Auth circuit breaker settings
│ Teams/Telegram notifications
│ Bootstrap token enablement
│
└──► AgentConfigResponse (delivered to agent via /api/agent/config)
│ ConfigVersion (currently 14)
│ CollectorConfiguration (nested)
│ AnalyzerConfiguration (nested)
│ GatherRules[] (merged built-in + tenant)
│ ImeLogPatterns[] (merged built-in + tenant)
│ DiagnosticsLogPaths[] (merged global + tenant)
└ Various flags and intervals
```
**Caching:** Both admin and tenant configs cached 5 minutes in-memory (`IMemoryCache`).
---
## Diagnostics & Upload
### Architecture (Post-Refactor)
- **Old:** Long-lived SAS URL stored in agent config → device stores in `remote-config.json`
- **New:** `DiagnosticsUploadEnabled` boolean; agent calls `POST /api/agent/upload-url` just before upload
- SAS URL never stored on device or in config, kept in memory only
### Upload Flow
1. Agent creates ZIP: `sessioninfo.txt` + agent logs + IME logs + configured paths
2. Agent calls `POST /api/agent/upload-url` → receives short-lived SAS URL (1h)
3. Agent uploads via `PUT {blobUrl}` with `x-ms-blob-type: BlockBlob`
4. 3 retries with exponential backoff (2s, 4s, 8s); 401/403 = non-retryable
### Diagnostics Log Paths
- Global paths: `AdminConfiguration.DiagnosticsGlobalLogPathsJson` (built-in, `IsBuiltIn=true`)
- Tenant paths: `TenantConfiguration.DiagnosticsLogPathsJson` (custom, `IsBuiltIn=false`)
- Merged list delivered to agent; security validated by `DiagnosticsPathGuards`
### Upload Modes
| Mode | Behavior |
|------|----------|
| `Off` (default) | Disabled |
| `Always` | Upload on both success and failure |
| `OnFailure` | Upload only on enrollment failure |
---
## Infrastructure & Deployment
### Azure Resources
| Resource | Purpose |
|----------|---------|
| Azure Functions (Isolated Worker) | REST API, event processing, timer triggers |
| Azure Table Storage | 21 tables for sessions, events, config, rules, metrics |
| Azure Blob Storage | Diagnostics ZIPs, session reports, platform stats cache |
| Azure SignalR Service | WebSocket hub for real-time updates |
| Azure Static Web Apps | Next.js frontend hosting |
| Application Insights | Logging, telemetry, performance monitoring |
| Microsoft Entra ID | Multi-tenant OIDC authentication |
### CI/CD (GitHub Actions)
| Workflow | Trigger | Purpose |
|----------|---------|---------|
| `azure-static-web-apps-*.yml` | Push to main (Web changes) | Build + deploy Next.js to Azure Static Web Apps |
| `combine-rules.yml` | Changes to `rules/`, `schema/`, `scripts/` | Validate rules against JSON Schema, combine into `dist/`, auto-commit |
### Environment Variables (Web)
```
NEXT_PUBLIC_ENTRA_CLIENT_ID # App registration client ID
NEXT_PUBLIC_ENTRA_REDIRECT_URI # Auth redirect (localhost:3000 dev)
NEXT_PUBLIC_ENTRA_POST_LOGOUT_REDIRECT_URI
NEXT_PUBLIC_API_BASE_URL # Backend URL (localhost:7071 dev)
NEXT_PUBLIC_PLATFORM_STATS_MANIFEST_URL
```
---
## Build & Development
### Build Commands
```bash
# .NET solution (Agent + Backend + Shared)
dotnet build AutopilotMonitor.sln --nologo -v quiet
# Backend tests
dotnet test src/Backend/AutopilotMonitor.Functions.Tests/
# Web frontend
cd src/Web/autopilot-monitor-web
npm install
npm run dev # Development server (localhost:3000)
npm run build # Production build
npx tsc --noEmit # Type checking only
# Rules validation + combine
cd rules && node scripts/combine.js
```
### Agent CLI Arguments
```
--install Deploy via Scheduled Task (Intune package)
--run-gather-rules One-shot data collection, then exit
--console Enable console output
--cert-thumbprint {thumb} Override cert search
--tenant-id {id} Override registry tenant
--api-url {url} Override API endpoint
--bootstrap-token {token} Pre-MDM bootstrap auth
--ime-log-path {path} Override IME log folder
--replay-log-dir {path} Enable log replay mode
--replay-speed-factor {n} Compression factor (default 50)
--no-auth Disable cert auth
--no-cleanup Disable self-destruct
--reboot-on-complete Trigger reboot after enrollment
--new-session Start fresh session
--keep-logfile Preserve logs after cleanup
```
### Key Design Conventions
| Convention | Details |
|------------|---------|
| Agent endpoint security | All agent endpoints use `req.ValidateSecurityAsync()` from `SecurityValidationExtensions.cs` |
| ConfigVersion | Tracks agent capability level (currently 14 = script execution tracking) |
| Phase progression | Forward-only: DeviceSetup(1) → AccountSetup(2), no backward transitions |
| Phase isolation | App IDs seen in earlier phases are ignored in later phases (IME tracker) |
| Completion throttling | Max 1 `completion_check` event per source per minute |
| Sequence persistence | Saved every 50 events + on critical events; crash recovery uses spool ceiling |
| Settings UI | `error` + `successMessage` state for notifications at top of `<main>` |
| Maintenance timer | Runs every 2 hours (not daily, despite function name) |
| Agent versioning | Auto-incremented: 1.0.{BuildNumber} |
+15
View File
@@ -21,6 +21,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AutopilotMonitor.Functions"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AutopilotMonitor.Functions.Tests", "src\Backend\AutopilotMonitor.Functions.Tests\AutopilotMonitor.Functions.Tests.csproj", "{E3E0A5D8-4899-4F41-9D5F-CB858EF9F091}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AutopilotMonitor.SummaryDialog", "src\Agent\AutopilotMonitor.SummaryDialog\AutopilotMonitor.SummaryDialog.csproj", "{B1C2D3E4-F5A6-4B7C-8D9E-0F1A2B3C4D5E}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -91,6 +93,18 @@ Global
{E3E0A5D8-4899-4F41-9D5F-CB858EF9F091}.Release|x64.Build.0 = Release|Any CPU
{E3E0A5D8-4899-4F41-9D5F-CB858EF9F091}.Release|x86.ActiveCfg = Release|Any CPU
{E3E0A5D8-4899-4F41-9D5F-CB858EF9F091}.Release|x86.Build.0 = Release|Any CPU
{B1C2D3E4-F5A6-4B7C-8D9E-0F1A2B3C4D5E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B1C2D3E4-F5A6-4B7C-8D9E-0F1A2B3C4D5E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B1C2D3E4-F5A6-4B7C-8D9E-0F1A2B3C4D5E}.Debug|x64.ActiveCfg = Debug|Any CPU
{B1C2D3E4-F5A6-4B7C-8D9E-0F1A2B3C4D5E}.Debug|x64.Build.0 = Debug|Any CPU
{B1C2D3E4-F5A6-4B7C-8D9E-0F1A2B3C4D5E}.Debug|x86.ActiveCfg = Debug|Any CPU
{B1C2D3E4-F5A6-4B7C-8D9E-0F1A2B3C4D5E}.Debug|x86.Build.0 = Debug|Any CPU
{B1C2D3E4-F5A6-4B7C-8D9E-0F1A2B3C4D5E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B1C2D3E4-F5A6-4B7C-8D9E-0F1A2B3C4D5E}.Release|Any CPU.Build.0 = Release|Any CPU
{B1C2D3E4-F5A6-4B7C-8D9E-0F1A2B3C4D5E}.Release|x64.ActiveCfg = Release|Any CPU
{B1C2D3E4-F5A6-4B7C-8D9E-0F1A2B3C4D5E}.Release|x64.Build.0 = Release|Any CPU
{B1C2D3E4-F5A6-4B7C-8D9E-0F1A2B3C4D5E}.Release|x86.ActiveCfg = Release|Any CPU
{B1C2D3E4-F5A6-4B7C-8D9E-0F1A2B3C4D5E}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -101,6 +115,7 @@ Global
{096B6098-F141-7114-B090-72D1899D8853} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{4AF25FD4-6BCC-44F1-BDFB-E10DA9CF620B} = {096B6098-F141-7114-B090-72D1899D8853}
{7252A85F-60EC-4CFA-B3A6-A204241FFBBC} = {096B6098-F141-7114-B090-72D1899D8853}
{B1C2D3E4-F5A6-4B7C-8D9E-0F1A2B3C4D5E} = {096B6098-F141-7114-B090-72D1899D8853}
{FE360695-3F2B-1049-3887-35FBB5135923} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{4BA82F07-39A9-4B80-A12D-3D4E4ACE6682} = {FE360695-3F2B-1049-3887-35FBB5135923}
{E3E0A5D8-4899-4F41-9D5F-CB858EF9F091} = {FE360695-3F2B-1049-3887-35FBB5135923}
@@ -174,6 +174,22 @@ namespace AutopilotMonitor.Agent.Core.Configuration
/// </summary>
public bool UseBootstrapTokenAuth { get; set; }
/// <summary>
/// Whether to show a visual enrollment summary dialog to the end user.
/// </summary>
public bool ShowEnrollmentSummary { get; set; } = false;
/// <summary>
/// Auto-close timeout in seconds for the enrollment summary dialog.
/// 0 = no auto-close. Default: 60
/// </summary>
public int EnrollmentSummaryTimeoutSeconds { get; set; } = 60;
/// <summary>
/// Optional URL to a branding image for the enrollment summary dialog banner.
/// </summary>
public string EnrollmentSummaryBrandingImageUrl { get; set; }
/// <summary>
/// Whether diagnostics upload is configured for this tenant.
/// When true, the agent requests a short-lived upload URL from the backend just before uploading.
@@ -136,8 +136,13 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core
return; // Don't continue with normal event processing
}
// No self-destruct/reboot — still upload diagnostics if configured
Task.Run(() => UploadDiagnosticsWithTimelineEvents(enrollmentSucceeded));
// No self-destruct/reboot — still upload diagnostics and optionally show summary
Task.Run(async () =>
{
await UploadDiagnosticsWithTimelineEvents(enrollmentSucceeded);
if (_configuration.ShowEnrollmentSummary)
LaunchEnrollmentSummaryDialog();
});
}
// Immediate upload for:
@@ -439,6 +444,10 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core
// Step 2.5: Upload diagnostics package (must complete before self-destruct/reboot)
await UploadDiagnosticsWithTimelineEvents(enrollmentSucceeded);
// Step 2.75: Launch enrollment summary dialog (fire-and-forget, runs independently)
if (_configuration.ShowEnrollmentSummary)
LaunchEnrollmentSummaryDialog();
// Step 3: Self-destruct (removes Scheduled Task + files) or reboot-only
if (_configuration.SelfDestructOnComplete)
{
@@ -544,6 +553,91 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core
}
}
/// <summary>
/// Copies the summary dialog EXE + dependencies + final-status.json to a temp folder
/// and launches it in the user's desktop session via CreateProcessAsUser.
/// Fire-and-forget: the agent does NOT wait for the dialog to exit.
/// The dialog self-deletes its temp folder when closed.
/// </summary>
private void LaunchEnrollmentSummaryDialog()
{
try
{
var agentDir = Path.GetDirectoryName(
System.Reflection.Assembly.GetExecutingAssembly().Location) ?? string.Empty;
var dialogExe = Path.Combine(agentDir, "AutopilotMonitor.SummaryDialog.exe");
if (!File.Exists(dialogExe))
{
_logger.Warning($"Summary dialog EXE not found at {dialogExe} — skipping");
return;
}
var statusFile = Path.Combine(
Environment.ExpandEnvironmentVariables(@"%ProgramData%\AutopilotMonitor\State"),
"final-status.json");
if (!File.Exists(statusFile))
{
_logger.Warning($"final-status.json not found at {statusFile} — skipping summary dialog");
return;
}
// Create temp folder for the dialog (survives agent self-destruct)
var tempDir = Path.Combine(Path.GetTempPath(),
"AutopilotMonitor-Summary-" + Guid.NewGuid().ToString("N").Substring(0, 8));
Directory.CreateDirectory(tempDir);
// Copy dialog EXE + Newtonsoft.Json.dll + final-status.json to temp
var tempDialogExe = Path.Combine(tempDir, "AutopilotMonitor.SummaryDialog.exe");
File.Copy(dialogExe, tempDialogExe);
var jsonDll = Path.Combine(agentDir, "Newtonsoft.Json.dll");
if (File.Exists(jsonDll))
File.Copy(jsonDll, Path.Combine(tempDir, "Newtonsoft.Json.dll"));
var tempStatusFile = Path.Combine(tempDir, "final-status.json");
File.Copy(statusFile, tempStatusFile);
// Build command line arguments
var args = $"--status-file \"{tempStatusFile}\" --timeout {_configuration.EnrollmentSummaryTimeoutSeconds}";
if (!string.IsNullOrEmpty(_configuration.EnrollmentSummaryBrandingImageUrl))
args += $" --branding-url \"{_configuration.EnrollmentSummaryBrandingImageUrl}\"";
_logger.Info($"Launching enrollment summary dialog: {tempDialogExe} {args}");
// Emit timeline event
EmitEvent(new EnrollmentEvent
{
SessionId = _configuration.SessionId,
TenantId = _configuration.TenantId,
Timestamp = DateTime.UtcNow,
EventType = "enrollment_summary_shown",
Severity = EventSeverity.Info,
Source = "MonitoringService",
Phase = EnrollmentPhase.Complete,
Message = "Launching enrollment summary dialog to user"
});
// Best-effort upload of the timeline event
try { UploadEventsAsync().Wait(TimeSpan.FromSeconds(5)); } catch { }
// Launch in user session (fire-and-forget)
var launched = UserSessionProcessLauncher.LaunchInUserSession(tempDialogExe, args, _logger);
if (!launched)
{
_logger.Warning("Could not launch summary dialog in user session (no interactive session found)");
// Clean up temp folder since dialog won't run
try { Directory.Delete(tempDir, true); } catch { }
}
}
catch (Exception ex)
{
_logger.Warning($"Failed to launch enrollment summary dialog: {ex.Message}");
// Non-fatal — continue with self-destruct/reboot
}
}
public void Dispose()
{
_uploadTimer?.Dispose();
@@ -329,11 +329,16 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core
_logger.SetLogLevel(remoteLogLevel);
}
_configuration.ShowEnrollmentSummary = config.ShowEnrollmentSummary;
_configuration.EnrollmentSummaryTimeoutSeconds = config.EnrollmentSummaryTimeoutSeconds;
_configuration.EnrollmentSummaryBrandingImageUrl = config.EnrollmentSummaryBrandingImageUrl;
_logger.Info("Applied runtime settings from remote config");
_logger.Info($" uploadIntervalSeconds={_configuration.UploadIntervalSeconds}, selfDestructOnComplete={_configuration.SelfDestructOnComplete}, keepLogFile={_configuration.KeepLogFile}");
_logger.Info($" enableGeoLocation={_configuration.EnableGeoLocation}, imeMatchLogPath={_configuration.ImeMatchLogPath ?? "(disabled)"}");
_logger.Info($" maxAuthFailures={_configuration.MaxAuthFailures}, authFailureTimeoutMinutes={_configuration.AuthFailureTimeoutMinutes}");
_logger.Info($" logLevel={_configuration.LogLevel}, rebootOnComplete={_configuration.RebootOnComplete}, maxBatchSize={_configuration.MaxBatchSize}");
_logger.Info($" showEnrollmentSummary={_configuration.ShowEnrollmentSummary}, summaryTimeoutSeconds={_configuration.EnrollmentSummaryTimeoutSeconds}");
}
/// <summary>
@@ -0,0 +1,326 @@
using System;
using System.Diagnostics;
using System.Management;
using System.Runtime.InteropServices;
using AutopilotMonitor.Agent.Core.Logging;
namespace AutopilotMonitor.Agent.Core.Monitoring.Core
{
/// <summary>
/// Launches a process in the logged-in user's desktop session from a SYSTEM context.
/// Uses CreateProcessAsUser with a token duplicated from explorer.exe.
/// </summary>
internal static class UserSessionProcessLauncher
{
/// <summary>
/// System/service account names that should NOT be considered real users.
/// </summary>
private static readonly string[] ExcludedUserNames =
{
"SYSTEM", "LOCAL SERVICE", "NETWORK SERVICE",
"DefaultUser0", "DefaultUser1", "defaultuser0", "defaultuser1"
};
/// <summary>
/// Launches the specified executable in the logged-in user's desktop session.
/// Returns true if the process was launched successfully, false otherwise.
/// This is fire-and-forget — the caller does not wait for the process to exit.
/// </summary>
public static bool LaunchInUserSession(string exePath, string arguments, AgentLogger logger)
{
IntPtr userToken = IntPtr.Zero;
IntPtr duplicatedToken = IntPtr.Zero;
IntPtr environment = IntPtr.Zero;
try
{
// Find explorer.exe in a non-zero session owned by a real user
var explorerProcess = FindExplorerProcess(logger);
if (explorerProcess == null)
{
logger.Warning("UserSessionProcessLauncher: No explorer.exe found in user session — cannot launch dialog");
return false;
}
using (explorerProcess)
{
logger.Info($"UserSessionProcessLauncher: Found explorer.exe PID {explorerProcess.Id} in session {explorerProcess.SessionId}");
// Open the token of explorer.exe
if (!NativeMethods.OpenProcessToken(explorerProcess.Handle,
NativeMethods.TOKEN_DUPLICATE | NativeMethods.TOKEN_QUERY, out userToken))
{
logger.Warning($"UserSessionProcessLauncher: OpenProcessToken failed (error {Marshal.GetLastWin32Error()})");
return false;
}
// Duplicate the token as a primary token for CreateProcessAsUser
var sa = new NativeMethods.SECURITY_ATTRIBUTES();
sa.nLength = Marshal.SizeOf(sa);
if (!NativeMethods.DuplicateTokenEx(userToken,
NativeMethods.MAXIMUM_ALLOWED, ref sa,
NativeMethods.SECURITY_IMPERSONATION_LEVEL.SecurityIdentification,
NativeMethods.TOKEN_TYPE.TokenPrimary, out duplicatedToken))
{
logger.Warning($"UserSessionProcessLauncher: DuplicateTokenEx failed (error {Marshal.GetLastWin32Error()})");
return false;
}
// Create an environment block for the user
if (!NativeMethods.CreateEnvironmentBlock(out environment, duplicatedToken, false))
{
logger.Warning($"UserSessionProcessLauncher: CreateEnvironmentBlock failed (error {Marshal.GetLastWin32Error()})");
// Non-fatal: we can proceed without it, but env vars may be wrong
environment = IntPtr.Zero;
}
// Set up startup info to target the user's desktop
var si = new NativeMethods.STARTUPINFO();
si.cb = Marshal.SizeOf(si);
si.lpDesktop = @"WinSta0\Default";
var creationFlags = NativeMethods.CREATE_NEW_CONSOLE;
if (environment != IntPtr.Zero)
creationFlags |= NativeMethods.CREATE_UNICODE_ENVIRONMENT;
var commandLine = $"\"{exePath}\" {arguments}";
var procSa = new NativeMethods.SECURITY_ATTRIBUTES();
procSa.nLength = Marshal.SizeOf(procSa);
var threadSa = new NativeMethods.SECURITY_ATTRIBUTES();
threadSa.nLength = Marshal.SizeOf(threadSa);
if (!NativeMethods.CreateProcessAsUser(
duplicatedToken,
null, // lpApplicationName (null, use command line)
commandLine, // lpCommandLine
ref procSa,
ref threadSa,
false, // bInheritHandles
creationFlags,
environment,
null, // lpCurrentDirectory (inherit)
ref si,
out var pi))
{
logger.Warning($"UserSessionProcessLauncher: CreateProcessAsUser failed (error {Marshal.GetLastWin32Error()})");
return false;
}
// Close the process and thread handles — we don't need them (fire-and-forget)
NativeMethods.CloseHandle(pi.hProcess);
NativeMethods.CloseHandle(pi.hThread);
logger.Info($"UserSessionProcessLauncher: Successfully launched process PID {pi.dwProcessId} in user session");
return true;
}
}
catch (Exception ex)
{
logger.Warning($"UserSessionProcessLauncher: Exception: {ex.Message}");
return false;
}
finally
{
if (environment != IntPtr.Zero)
NativeMethods.DestroyEnvironmentBlock(environment);
if (duplicatedToken != IntPtr.Zero)
NativeMethods.CloseHandle(duplicatedToken);
if (userToken != IntPtr.Zero)
NativeMethods.CloseHandle(userToken);
}
}
/// <summary>
/// Finds an explorer.exe process running in a non-SYSTEM user session.
/// Returns the Process handle, or null if none found.
/// </summary>
private static Process FindExplorerProcess(AgentLogger logger)
{
try
{
var explorerProcesses = Process.GetProcessesByName("explorer");
foreach (var proc in explorerProcesses)
{
try
{
if (proc.SessionId == 0)
{
proc.Dispose();
continue;
}
var owner = GetProcessOwner(proc.Id);
if (owner == null || IsExcludedUser(owner))
{
proc.Dispose();
continue;
}
// Found a valid user-session explorer.exe
// Dispose the others
foreach (var other in explorerProcesses)
{
if (other.Id != proc.Id)
{
try { other.Dispose(); } catch { }
}
}
return proc;
}
catch
{
proc.Dispose();
}
}
}
catch (Exception ex)
{
logger.Debug($"UserSessionProcessLauncher: Error finding explorer.exe: {ex.Message}");
}
return null;
}
private static string GetProcessOwner(int processId)
{
try
{
using (var searcher = new ManagementObjectSearcher(
$"SELECT * FROM Win32_Process WHERE ProcessId = {processId}"))
{
foreach (ManagementObject obj in searcher.Get())
{
var outParams = new object[2];
var result = (uint)obj.InvokeMethod("GetOwner", outParams);
if (result == 0)
{
var user = outParams[0]?.ToString();
var domain = outParams[1]?.ToString();
return string.IsNullOrEmpty(domain) ? user : $"{domain}\\{user}";
}
}
}
}
catch { }
return null;
}
private static bool IsExcludedUser(string fullUserName)
{
if (string.IsNullOrEmpty(fullUserName))
return true;
var userName = fullUserName;
var backslashIndex = fullUserName.LastIndexOf('\\');
if (backslashIndex >= 0 && backslashIndex < fullUserName.Length - 1)
userName = fullUserName.Substring(backslashIndex + 1);
foreach (var excluded in ExcludedUserNames)
{
if (string.Equals(userName, excluded, StringComparison.OrdinalIgnoreCase))
return true;
}
if (userName.StartsWith("DefaultUser", StringComparison.OrdinalIgnoreCase))
return true;
return false;
}
/// <summary>
/// Win32 P/Invoke declarations for process creation in user session.
/// </summary>
private static class NativeMethods
{
public const uint TOKEN_DUPLICATE = 0x0002;
public const uint TOKEN_QUERY = 0x0008;
public const uint MAXIMUM_ALLOWED = 0x02000000;
public const uint CREATE_NEW_CONSOLE = 0x00000010;
public const uint CREATE_UNICODE_ENVIRONMENT = 0x00000400;
public enum SECURITY_IMPERSONATION_LEVEL
{
SecurityAnonymous,
SecurityIdentification,
SecurityImpersonation,
SecurityDelegation
}
public enum TOKEN_TYPE
{
TokenPrimary = 1,
TokenImpersonation
}
[StructLayout(LayoutKind.Sequential)]
public struct SECURITY_ATTRIBUTES
{
public int nLength;
public IntPtr lpSecurityDescriptor;
public bool bInheritHandle;
}
[StructLayout(LayoutKind.Sequential)]
public struct STARTUPINFO
{
public int cb;
public string lpReserved;
public string lpDesktop;
public string lpTitle;
public int dwX;
public int dwY;
public int dwXSize;
public int dwYSize;
public int dwXCountChars;
public int dwYCountChars;
public int dwFillAttribute;
public int dwFlags;
public short wShowWindow;
public short cbReserved2;
public IntPtr lpReserved2;
public IntPtr hStdInput;
public IntPtr hStdOutput;
public IntPtr hStdError;
}
[StructLayout(LayoutKind.Sequential)]
public struct PROCESS_INFORMATION
{
public IntPtr hProcess;
public IntPtr hThread;
public int dwProcessId;
public int dwThreadId;
}
[DllImport("advapi32.dll", SetLastError = true)]
public static extern bool OpenProcessToken(IntPtr processHandle, uint desiredAccess, out IntPtr tokenHandle);
[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
public static extern bool DuplicateTokenEx(
IntPtr hExistingToken, uint dwDesiredAccess,
ref SECURITY_ATTRIBUTES lpTokenAttributes,
SECURITY_IMPERSONATION_LEVEL impersonationLevel,
TOKEN_TYPE tokenType, out IntPtr phNewToken);
[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
public static extern bool CreateProcessAsUser(
IntPtr hToken, string lpApplicationName, string lpCommandLine,
ref SECURITY_ATTRIBUTES lpProcessAttributes,
ref SECURITY_ATTRIBUTES lpThreadAttributes,
bool bInheritHandles, uint dwCreationFlags,
IntPtr lpEnvironment, string lpCurrentDirectory,
ref STARTUPINFO lpStartupInfo, out PROCESS_INFORMATION lpProcessInformation);
[DllImport("userenv.dll", SetLastError = true)]
public static extern bool CreateEnvironmentBlock(out IntPtr lpEnvironment, IntPtr hToken, bool bInherit);
[DllImport("userenv.dll", SetLastError = true)]
public static extern bool DestroyEnvironmentBlock(IntPtr lpEnvironment);
[DllImport("kernel32.dll", SetLastError = true)]
public static extern bool CloseHandle(IntPtr hObject);
}
}
}
@@ -1 +1 @@
271
282
@@ -0,0 +1,7 @@
<Application x:Class="AutopilotMonitor.SummaryDialog.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
StartupUri="MainWindow.xaml">
<Application.Resources>
</Application.Resources>
</Application>
@@ -0,0 +1,94 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Windows;
namespace AutopilotMonitor.SummaryDialog
{
public partial class App : Application
{
internal static string StatusFilePath { get; private set; }
internal static int TimeoutSeconds { get; private set; } = 60;
internal static string BrandingImageUrl { get; private set; }
internal static bool? ForceTheme { get; private set; } // true=dark, false=light, null=auto
protected override void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
ParseArguments(e.Args);
}
protected override void OnExit(ExitEventArgs e)
{
base.OnExit(e);
SelfCleanup();
}
private void ParseArguments(string[] args)
{
for (int i = 0; i < args.Length; i++)
{
switch (args[i].ToLowerInvariant())
{
case "--status-file":
if (i + 1 < args.Length) StatusFilePath = args[++i];
break;
case "--timeout":
if (i + 1 < args.Length && int.TryParse(args[++i], out var timeout))
TimeoutSeconds = timeout;
break;
case "--branding-url":
if (i + 1 < args.Length) BrandingImageUrl = args[++i];
break;
case "--dark-theme":
ForceTheme = true;
break;
case "--light-theme":
ForceTheme = false;
break;
}
}
if (string.IsNullOrEmpty(StatusFilePath) || !File.Exists(StatusFilePath))
{
// Still show dialog with a fallback message
}
}
/// <summary>
/// Self-cleanup: delete the temp folder this EXE was launched from.
/// Uses a cmd.exe script that waits for our process to exit, then deletes the folder.
/// </summary>
private void SelfCleanup()
{
try
{
var exePath = System.Reflection.Assembly.GetExecutingAssembly().Location;
var exeDir = Path.GetDirectoryName(exePath);
// Only self-cleanup if running from a temp folder
var tempRoot = Path.GetTempPath().TrimEnd('\\');
if (exeDir == null || !exeDir.StartsWith(tempRoot, StringComparison.OrdinalIgnoreCase))
return;
var pid = Process.GetCurrentProcess().Id;
// Wait for our process to exit, then delete the temp folder
var script = $@"/c tasklist /fi ""PID eq {pid}"" 2>nul | find ""{pid}"" >nul && (ping 127.0.0.1 -n 3 >nul) & rd /s /q ""{exeDir}""";
var psi = new ProcessStartInfo
{
FileName = "cmd.exe",
Arguments = script,
WindowStyle = ProcessWindowStyle.Hidden,
CreateNoWindow = true,
UseShellExecute = false
};
Process.Start(psi);
}
catch
{
// Best-effort cleanup, ignore errors
}
}
}
}
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net48</TargetFramework>
<LangVersion>latest</LangVersion>
<UseWPF>true</UseWPF>
<AssemblyName>AutopilotMonitor.SummaryDialog</AssemblyName>
<RootNamespace>AutopilotMonitor.SummaryDialog</RootNamespace>
</PropertyGroup>
<ItemGroup>
<Reference Include="System.Net.Http" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
</ItemGroup>
</Project>
@@ -0,0 +1,218 @@
<Window x:Class="AutopilotMonitor.SummaryDialog.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Autopilot Enrollment Summary"
Width="540" Height="640"
MinWidth="540" MinHeight="400"
ResizeMode="NoResize"
WindowStartupLocation="CenterScreen"
Topmost="True"
WindowStyle="None"
AllowsTransparency="True"
Background="Transparent"
Loaded="Window_Loaded">
<Window.Resources>
<!-- Light Theme Colors (defaults, overridden in code-behind for dark) -->
<SolidColorBrush x:Key="WindowBackground" Color="#FFFFFF"/>
<SolidColorBrush x:Key="TitleBarBackground" Color="#F9FAFB"/>
<SolidColorBrush x:Key="BorderColor" Color="#E5E7EB"/>
<SolidColorBrush x:Key="PrimaryText" Color="#111827"/>
<SolidColorBrush x:Key="SecondaryText" Color="#6B7280"/>
<SolidColorBrush x:Key="SuccessColor" Color="#059669"/>
<SolidColorBrush x:Key="SuccessBackground" Color="#ECFDF5"/>
<SolidColorBrush x:Key="ErrorColor" Color="#DC2626"/>
<SolidColorBrush x:Key="ErrorBackground" Color="#FEF2F2"/>
<SolidColorBrush x:Key="ProgressBackground" Color="#E5E7EB"/>
<SolidColorBrush x:Key="ProgressSuccess" Color="#059669"/>
<SolidColorBrush x:Key="ProgressError" Color="#DC2626"/>
<SolidColorBrush x:Key="SectionBackground" Color="#F9FAFB"/>
<SolidColorBrush x:Key="ButtonBackground" Color="#F3F4F6"/>
<SolidColorBrush x:Key="ButtonHover" Color="#E5E7EB"/>
<SolidColorBrush x:Key="ButtonText" Color="#374151"/>
<SolidColorBrush x:Key="CloseButtonHover" Color="#FEE2E2"/>
<SolidColorBrush x:Key="PhaseHeaderText" Color="#374151"/>
<SolidColorBrush x:Key="AppItemHover" Color="#F3F4F6"/>
</Window.Resources>
<Grid>
<!-- Shadow layer (separate so clipping the content border doesn't cut the shadow) -->
<Border CornerRadius="12" Background="{DynamicResource WindowBackground}">
<Border.Effect>
<DropShadowEffect BlurRadius="24" ShadowDepth="4" Opacity="0.15" Color="#000000"/>
</Border.Effect>
</Border>
<!-- Content layer (clipped to rounded corners in code-behind) -->
<Border x:Name="ContentBorder" CornerRadius="12"
Background="{DynamicResource WindowBackground}"
BorderBrush="{DynamicResource BorderColor}" BorderThickness="1">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/> <!-- Branding banner -->
<RowDefinition Height="Auto"/> <!-- Title bar -->
<RowDefinition Height="Auto"/> <!-- Outcome header -->
<RowDefinition Height="*"/> <!-- App list -->
<RowDefinition Height="Auto"/> <!-- Footer -->
</Grid.RowDefinitions>
<!-- Branding Banner (hidden by default, shown if image loads) -->
<Border x:Name="BrandingBanner" Grid.Row="0" Height="80" Visibility="Collapsed"
ClipToBounds="True">
<Image x:Name="BrandingImage" Stretch="UniformToFill"
RenderOptions.BitmapScalingMode="HighQuality"/>
</Border>
<!-- Custom Title Bar -->
<Border Grid.Row="1" Background="{DynamicResource TitleBarBackground}"
CornerRadius="{Binding ElementName=BrandingBanner, Path=Visibility, Converter={x:Null}}"
BorderBrush="{DynamicResource BorderColor}" BorderThickness="0,0,0,1"
MouseLeftButtonDown="TitleBar_MouseLeftButtonDown">
<Border.Style>
<Style TargetType="Border">
<Setter Property="CornerRadius" Value="0"/>
</Style>
</Border.Style>
<Grid Margin="20,12,12,12">
<TextBlock Text="Autopilot Enrollment Summary"
FontFamily="Segoe UI Semibold" FontSize="14"
Foreground="{DynamicResource PrimaryText}"
VerticalAlignment="Center"/>
<Button x:Name="CloseButton" Content="&#xE711;"
FontFamily="Segoe MDL2 Assets" FontSize="12"
HorizontalAlignment="Right" VerticalAlignment="Center"
Width="32" Height="32" Cursor="Hand"
Click="CloseButton_Click">
<Button.Style>
<Style TargetType="Button">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Foreground" Value="{DynamicResource SecondaryText}"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="Bd" Background="{TemplateBinding Background}"
CornerRadius="6" Width="{TemplateBinding Width}" Height="{TemplateBinding Height}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource CloseButtonHover}"/>
<Setter Property="Foreground" Value="{DynamicResource ErrorColor}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</Button.Style>
</Button>
</Grid>
</Border>
<!-- Outcome Section -->
<Border Grid.Row="2" Padding="24,20,24,16"
BorderBrush="{DynamicResource BorderColor}" BorderThickness="0,0,0,1">
<StackPanel HorizontalAlignment="Center">
<!-- Success/Failure Icon -->
<Grid Width="56" Height="56" Margin="0,0,0,12">
<!-- Success Icon -->
<Canvas x:Name="SuccessIcon" Width="56" Height="56" Visibility="Collapsed">
<Ellipse Width="56" Height="56" Fill="{DynamicResource SuccessBackground}"/>
<Ellipse Width="56" Height="56" Stroke="{DynamicResource SuccessColor}" StrokeThickness="2"/>
<Path Data="M 16,28 L 24,36 L 40,20" Stroke="{DynamicResource SuccessColor}"
StrokeThickness="3" StrokeStartLineCap="Round" StrokeEndLineCap="Round" StrokeLineJoin="Round"/>
</Canvas>
<!-- Failure Icon -->
<Canvas x:Name="FailureIcon" Width="56" Height="56" Visibility="Collapsed">
<Ellipse Width="56" Height="56" Fill="{DynamicResource ErrorBackground}"/>
<Ellipse Width="56" Height="56" Stroke="{DynamicResource ErrorColor}" StrokeThickness="2"/>
<Path Data="M 20,20 L 36,36 M 36,20 L 20,36" Stroke="{DynamicResource ErrorColor}"
StrokeThickness="3" StrokeStartLineCap="Round" StrokeEndLineCap="Round"/>
</Canvas>
</Grid>
<TextBlock x:Name="OutcomeText" FontFamily="Segoe UI Semibold" FontSize="20"
Foreground="{DynamicResource PrimaryText}" HorizontalAlignment="Center"
Text="Enrollment Completed"/>
<TextBlock x:Name="DurationText" FontFamily="Segoe UI" FontSize="13"
Foreground="{DynamicResource SecondaryText}" HorizontalAlignment="Center"
Margin="0,6,0,0" Text="Duration: --"/>
</StackPanel>
</Border>
<!-- Applications Section -->
<Grid Grid.Row="3" Margin="24,16,24,0">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/> <!-- Header + progress bar -->
<RowDefinition Height="*"/> <!-- App list -->
</Grid.RowDefinitions>
<!-- Apps Header -->
<StackPanel Grid.Row="0">
<TextBlock FontFamily="Segoe UI Semibold" FontSize="14"
Foreground="{DynamicResource PrimaryText}" Text="Applications" Margin="0,0,0,8"/>
<!-- Progress Bar -->
<Grid Height="8" Margin="0,0,0,4">
<Border Background="{DynamicResource ProgressBackground}" CornerRadius="4"/>
<Border x:Name="ProgressBarSuccess" Background="{DynamicResource ProgressSuccess}"
CornerRadius="4" HorizontalAlignment="Left" Width="0"/>
</Grid>
<TextBlock x:Name="AppSummaryText" FontFamily="Segoe UI" FontSize="12"
Foreground="{DynamicResource SecondaryText}" Margin="0,0,0,12"
Text="0 of 0 apps installed"/>
<!-- No Apps Message -->
<TextBlock x:Name="NoAppsText" FontFamily="Segoe UI" FontSize="13"
Foreground="{DynamicResource SecondaryText}" HorizontalAlignment="Center"
Margin="0,20,0,0" Visibility="Collapsed"
Text="No application data available."/>
</StackPanel>
<!-- App List by Phase -->
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto"
Margin="0,0,-8,0" Padding="0,0,8,0">
<StackPanel x:Name="AppListPanel"/>
</ScrollViewer>
</Grid>
<!-- Footer -->
<Border Grid.Row="4" Padding="24,12,24,16"
BorderBrush="{DynamicResource BorderColor}" BorderThickness="0,1,0,0">
<Grid>
<TextBlock x:Name="CountdownText" FontFamily="Segoe UI" FontSize="12"
Foreground="{DynamicResource SecondaryText}" VerticalAlignment="Center"
HorizontalAlignment="Left" Text=""/>
<Button Content="Close" HorizontalAlignment="Right" Padding="20,8"
Click="CloseButton_Click" Cursor="Hand">
<Button.Style>
<Style TargetType="Button">
<Setter Property="FontFamily" Value="Segoe UI Semibold"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="Foreground" Value="{DynamicResource ButtonText}"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="Bd" Background="{DynamicResource ButtonBackground}"
CornerRadius="6" Padding="{TemplateBinding Padding}"
BorderBrush="{DynamicResource BorderColor}" BorderThickness="1">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource ButtonHover}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</Button.Style>
</Button>
</Grid>
</Border>
</Grid>
</Border>
</Grid>
</Window>
@@ -0,0 +1,485 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Threading;
using AutopilotMonitor.SummaryDialog.Models;
using Microsoft.Win32;
using Newtonsoft.Json;
namespace AutopilotMonitor.SummaryDialog
{
public partial class MainWindow : Window
{
private DispatcherTimer _countdownTimer;
private int _remainingSeconds;
private FinalStatus _status;
// Theme colors
private bool _isDarkTheme;
public MainWindow()
{
InitializeComponent();
}
private async void Window_Loaded(object sender, RoutedEventArgs e)
{
_isDarkTheme = App.ForceTheme ?? DetectDarkTheme();
if (_isDarkTheme)
ApplyDarkTheme();
ApplyScrollBarStyle();
ApplyContentClip();
LoadStatusData();
RenderOutcome();
RenderAppList();
StartCountdown();
// Load branding image (async, best-effort)
if (!string.IsNullOrEmpty(App.BrandingImageUrl))
{
await LoadBrandingImageAsync();
}
}
private void LoadStatusData()
{
try
{
if (!string.IsNullOrEmpty(App.StatusFilePath) && File.Exists(App.StatusFilePath))
{
var json = File.ReadAllText(App.StatusFilePath);
_status = JsonConvert.DeserializeObject<FinalStatus>(json);
}
}
catch
{
_status = null;
}
}
private void RenderOutcome()
{
if (_status == null)
{
OutcomeText.Text = "Enrollment Completed";
DurationText.Text = "Details unavailable";
SuccessIcon.Visibility = Visibility.Visible;
return;
}
var isSuccess = string.Equals(_status.Outcome, "completed", StringComparison.OrdinalIgnoreCase);
if (isSuccess)
{
OutcomeText.Text = "Enrollment Completed Successfully";
SuccessIcon.Visibility = Visibility.Visible;
FailureIcon.Visibility = Visibility.Collapsed;
}
else
{
OutcomeText.Text = "Enrollment Failed";
SuccessIcon.Visibility = Visibility.Collapsed;
FailureIcon.Visibility = Visibility.Visible;
OutcomeText.Foreground = (Brush)FindResource("ErrorColor");
}
// Format duration
var duration = TimeSpan.FromSeconds(_status.AgentUptimeSeconds);
if (duration.TotalHours >= 1)
DurationText.Text = $"Duration: {(int)duration.TotalHours}h {duration.Minutes}m {duration.Seconds}s";
else if (duration.TotalMinutes >= 1)
DurationText.Text = $"Duration: {(int)duration.TotalMinutes} min {duration.Seconds} sec";
else
DurationText.Text = $"Duration: {duration.Seconds} sec";
}
private void RenderAppList()
{
if (_status?.AppSummary == null || _status.AppSummary.TotalApps == 0)
{
NoAppsText.Visibility = Visibility.Visible;
AppSummaryText.Visibility = Visibility.Collapsed;
return;
}
var summary = _status.AppSummary;
// Update progress bar
var progressWidth = summary.TotalApps > 0
? (double)summary.CompletedApps / summary.TotalApps * ProgressBarSuccess.Parent.As<Grid>().ActualWidth
: 0;
// We need to set this after layout, use Dispatcher
Dispatcher.BeginInvoke(DispatcherPriority.Loaded, new Action(() =>
{
var parentGrid = (Grid)ProgressBarSuccess.Parent;
var totalWidth = parentGrid.ActualWidth;
if (totalWidth > 0 && summary.TotalApps > 0)
{
ProgressBarSuccess.Width = (double)summary.CompletedApps / summary.TotalApps * totalWidth;
if (summary.ErrorCount > 0)
ProgressBarSuccess.Background = (Brush)FindResource("ProgressError");
}
}));
// Summary text
if (summary.ErrorCount > 0)
{
AppSummaryText.Text = $"{summary.CompletedApps} of {summary.TotalApps} apps installed, {summary.ErrorCount} failed";
AppSummaryText.Foreground = (Brush)FindResource("ErrorColor");
}
else
{
AppSummaryText.Text = $"{summary.CompletedApps} of {summary.TotalApps} apps installed";
}
// Render phases
if (_status.PackageStatesByPhase != null)
{
foreach (var phase in _status.PackageStatesByPhase)
{
AddPhaseSection(phase.Key, phase.Value);
}
}
}
private void AddPhaseSection(string phaseName, List<PackageInfo> apps)
{
if (apps == null || apps.Count == 0) return;
// Phase header
var header = new TextBlock
{
Text = $"{FormatPhaseName(phaseName)} ({apps.Count})",
FontFamily = new FontFamily("Segoe UI Semibold"),
FontSize = 12,
Foreground = (Brush)FindResource("PhaseHeaderText"),
Margin = new Thickness(0, 8, 0, 6),
TextWrapping = TextWrapping.Wrap
};
AppListPanel.Children.Add(header);
foreach (var app in apps)
{
AppListPanel.Children.Add(CreateAppItem(app));
}
}
private Border CreateAppItem(PackageInfo app)
{
var grid = new Grid();
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(24) });
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
// Status icon
var iconText = new TextBlock
{
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 14,
VerticalAlignment = VerticalAlignment.Center,
HorizontalAlignment = HorizontalAlignment.Center
};
if (app.IsError)
{
iconText.Text = "\uE711"; // X
iconText.Foreground = (Brush)FindResource("ErrorColor");
}
else if (app.IsCompleted)
{
iconText.Text = "\uE73E"; // Checkmark
iconText.Foreground = (Brush)FindResource("SuccessColor");
}
else
{
iconText.Text = "\uE738"; // Dash/circle
iconText.Foreground = (Brush)FindResource("SecondaryText");
}
Grid.SetColumn(iconText, 0);
grid.Children.Add(iconText);
// App name
var nameText = new TextBlock
{
Text = app.AppName ?? "Unknown App",
FontFamily = new FontFamily("Segoe UI"),
FontSize = 13,
Foreground = (Brush)FindResource("PrimaryText"),
VerticalAlignment = VerticalAlignment.Center,
TextTrimming = TextTrimming.CharacterEllipsis,
Margin = new Thickness(4, 0, 8, 0)
};
Grid.SetColumn(nameText, 1);
grid.Children.Add(nameText);
// State label
if (app.IsError)
{
var stateText = new TextBlock
{
Text = "Error",
FontFamily = new FontFamily("Segoe UI"),
FontSize = 12,
Foreground = (Brush)FindResource("ErrorColor"),
VerticalAlignment = VerticalAlignment.Center
};
Grid.SetColumn(stateText, 2);
grid.Children.Add(stateText);
}
var border = new Border
{
Child = grid,
Padding = new Thickness(6, 6, 8, 6),
CornerRadius = new CornerRadius(4),
Margin = new Thickness(0, 1, 0, 1),
Background = Brushes.Transparent
};
// Hover effect
border.MouseEnter += (s, e) => border.Background = (Brush)FindResource("AppItemHover");
border.MouseLeave += (s, e) => border.Background = Brushes.Transparent;
return border;
}
private async System.Threading.Tasks.Task LoadBrandingImageAsync()
{
try
{
using (var client = new HttpClient { Timeout = TimeSpan.FromSeconds(5) })
{
var bytes = await client.GetByteArrayAsync(App.BrandingImageUrl);
var bitmap = new BitmapImage();
using (var ms = new System.IO.MemoryStream(bytes))
{
bitmap.BeginInit();
bitmap.CacheOption = BitmapCacheOption.OnLoad;
bitmap.StreamSource = ms;
bitmap.EndInit();
bitmap.Freeze();
}
BrandingImage.Source = bitmap;
BrandingBanner.Visibility = Visibility.Visible;
}
}
catch
{
// Best-effort: skip banner on any error
BrandingBanner.Visibility = Visibility.Collapsed;
}
}
private void StartCountdown()
{
_remainingSeconds = App.TimeoutSeconds;
if (_remainingSeconds <= 0)
{
CountdownText.Visibility = Visibility.Collapsed;
return;
}
CountdownText.Text = $"Auto-closing in {_remainingSeconds}s";
_countdownTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
_countdownTimer.Tick += (s, e) =>
{
_remainingSeconds--;
if (_remainingSeconds <= 0)
{
_countdownTimer.Stop();
Close();
}
else
{
CountdownText.Text = $"Auto-closing in {_remainingSeconds}s";
}
};
_countdownTimer.Start();
}
private void ApplyDarkTheme()
{
Resources["WindowBackground"] = new SolidColorBrush(Color.FromRgb(0x1F, 0x29, 0x37));
Resources["TitleBarBackground"] = new SolidColorBrush(Color.FromRgb(0x11, 0x18, 0x27));
Resources["BorderColor"] = new SolidColorBrush(Color.FromRgb(0x37, 0x41, 0x51));
Resources["PrimaryText"] = new SolidColorBrush(Color.FromRgb(0xF9, 0xFA, 0xFB));
Resources["SecondaryText"] = new SolidColorBrush(Color.FromRgb(0x9C, 0xA3, 0xAF));
Resources["SuccessColor"] = new SolidColorBrush(Color.FromRgb(0x34, 0xD3, 0x99));
Resources["SuccessBackground"] = new SolidColorBrush(Color.FromRgb(0x06, 0x4E, 0x3B));
Resources["ErrorColor"] = new SolidColorBrush(Color.FromRgb(0xF8, 0x71, 0x71));
Resources["ErrorBackground"] = new SolidColorBrush(Color.FromRgb(0x7F, 0x1D, 0x1D));
Resources["ProgressBackground"] = new SolidColorBrush(Color.FromRgb(0x37, 0x41, 0x51));
Resources["ProgressSuccess"] = new SolidColorBrush(Color.FromRgb(0x34, 0xD3, 0x99));
Resources["ProgressError"] = new SolidColorBrush(Color.FromRgb(0xF8, 0x71, 0x71));
Resources["SectionBackground"] = new SolidColorBrush(Color.FromRgb(0x11, 0x18, 0x27));
Resources["ButtonBackground"] = new SolidColorBrush(Color.FromRgb(0x37, 0x41, 0x51));
Resources["ButtonHover"] = new SolidColorBrush(Color.FromRgb(0x4B, 0x55, 0x63));
Resources["ButtonText"] = new SolidColorBrush(Color.FromRgb(0xF9, 0xFA, 0xFB));
Resources["CloseButtonHover"] = new SolidColorBrush(Color.FromRgb(0x7F, 0x1D, 0x1D));
Resources["PhaseHeaderText"] = new SolidColorBrush(Color.FromRgb(0xD1, 0xD5, 0xDB));
Resources["AppItemHover"] = new SolidColorBrush(Color.FromRgb(0x37, 0x41, 0x51));
}
/// <summary>
/// Applies a slim, modern scrollbar style matching the current theme.
/// Completely re-templates the vertical ScrollBar to remove arrow buttons (Win11 style).
/// </summary>
private void ApplyScrollBarStyle()
{
var trackColor = _isDarkTheme ? "#FF1F2937" : "#FFF9FAFB";
var thumbColor = _isDarkTheme ? "#FF4B5563" : "#FFD1D5DB";
var thumbHoverColor = _isDarkTheme ? "#FF6B7280" : "#FF9CA3AF";
// Build the ScrollBar style via XAML string — this is the most reliable way to
// re-template Track (which uses CLR properties, not DPs, for its children).
var xaml = $@"
<Style xmlns=""http://schemas.microsoft.com/winfx/2006/xaml/presentation""
xmlns:x=""http://schemas.microsoft.com/winfx/2006/xaml""
TargetType=""{{x:Type ScrollBar}}"">
<Setter Property=""Width"" Value=""8""/>
<Setter Property=""MinWidth"" Value=""8""/>
<Setter Property=""Background"" Value=""Transparent""/>
<Setter Property=""BorderThickness"" Value=""0""/>
<Setter Property=""Margin"" Value=""0""/>
<Setter Property=""Padding"" Value=""0""/>
<Setter Property=""Template"">
<Setter.Value>
<ControlTemplate TargetType=""{{x:Type ScrollBar}}"">
<Border Background=""{trackColor}"" CornerRadius=""4"">
<Track x:Name=""PART_Track"" IsDirectionReversed=""True"">
<Track.DecreaseRepeatButton>
<RepeatButton Command=""ScrollBar.PageUpCommand"" Focusable=""False"">
<RepeatButton.Template>
<ControlTemplate TargetType=""RepeatButton"">
<Border Background=""Transparent""/>
</ControlTemplate>
</RepeatButton.Template>
</RepeatButton>
</Track.DecreaseRepeatButton>
<Track.Thumb>
<Thumb MinHeight=""20"">
<Thumb.Template>
<ControlTemplate TargetType=""Thumb"">
<Border x:Name=""ThumbBorder"" Background=""{thumbColor}""
CornerRadius=""3"" Margin=""1,2,1,2""/>
<ControlTemplate.Triggers>
<Trigger Property=""IsMouseOver"" Value=""True"">
<Setter TargetName=""ThumbBorder"" Property=""Background"" Value=""{thumbHoverColor}""/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Thumb.Template>
</Thumb>
</Track.Thumb>
<Track.IncreaseRepeatButton>
<RepeatButton Command=""ScrollBar.PageDownCommand"" Focusable=""False"">
<RepeatButton.Template>
<ControlTemplate TargetType=""RepeatButton"">
<Border Background=""Transparent""/>
</ControlTemplate>
</RepeatButton.Template>
</RepeatButton>
</Track.IncreaseRepeatButton>
</Track>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>";
var style = (Style)System.Windows.Markup.XamlReader.Parse(xaml);
Resources[typeof(System.Windows.Controls.Primitives.ScrollBar)] = style;
}
/// <summary>
/// Clips the inner content Grid to a rounded rectangle so child elements
/// (like the title bar) don't bleed past the outer Border's rounded corners.
/// </summary>
private void ApplyContentClip()
{
// Clip the entire content Border to a rounded rectangle.
// This prevents child backgrounds from bleeding past the rounded corners.
const double radius = 12;
void UpdateClip()
{
if (ContentBorder.ActualWidth > 0 && ContentBorder.ActualHeight > 0)
{
ContentBorder.Clip = new RectangleGeometry(
new Rect(0, 0, ContentBorder.ActualWidth, ContentBorder.ActualHeight),
radius, radius);
}
}
ContentBorder.SizeChanged += (s, args) => UpdateClip();
UpdateClip(); // Set immediately — layout is already complete at Window_Loaded time
}
private static bool DetectDarkTheme()
{
try
{
using (var key = Registry.CurrentUser.OpenSubKey(
@"SOFTWARE\Microsoft\Windows\CurrentVersion\Themes\Personalize"))
{
var value = key?.GetValue("AppsUseLightTheme");
return value is int i && i == 0;
}
}
catch
{
return false;
}
}
private static string FormatPhaseName(string phase)
{
// Convert "DeviceSetup" -> "Device Setup", "AccountSetup" -> "Account Setup"
if (string.IsNullOrEmpty(phase)) return phase;
var result = new System.Text.StringBuilder();
for (int i = 0; i < phase.Length; i++)
{
if (i > 0 && char.IsUpper(phase[i]) && !char.IsUpper(phase[i - 1]))
result.Append(' ');
result.Append(phase[i]);
}
return result.ToString();
}
private void TitleBar_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
if (e.ClickCount == 1)
DragMove();
}
private void CloseButton_Click(object sender, RoutedEventArgs e)
{
_countdownTimer?.Stop();
Close();
}
}
// Extension helper for Parent casting
internal static class FrameworkElementExtensions
{
internal static T As<T>(this DependencyObject obj) where T : class
{
return obj as T;
}
}
}
@@ -0,0 +1,74 @@
using System.Collections.Generic;
using Newtonsoft.Json;
namespace AutopilotMonitor.SummaryDialog.Models
{
public class FinalStatus
{
[JsonProperty("timestamp")]
public string Timestamp { get; set; }
[JsonProperty("outcome")]
public string Outcome { get; set; }
[JsonProperty("completionSource")]
public string CompletionSource { get; set; }
[JsonProperty("helloOutcome")]
public string HelloOutcome { get; set; }
[JsonProperty("enrollmentType")]
public string EnrollmentType { get; set; }
[JsonProperty("agentUptimeSeconds")]
public double AgentUptimeSeconds { get; set; }
[JsonProperty("signalsSeen")]
public List<string> SignalsSeen { get; set; } = new List<string>();
[JsonProperty("appSummary")]
public AppSummary AppSummary { get; set; }
[JsonProperty("packageStatesByPhase")]
public Dictionary<string, List<PackageInfo>> PackageStatesByPhase { get; set; } = new Dictionary<string, List<PackageInfo>>();
}
public class AppSummary
{
[JsonProperty("totalApps")]
public int TotalApps { get; set; }
[JsonProperty("completedApps")]
public int CompletedApps { get; set; }
[JsonProperty("errorCount")]
public int ErrorCount { get; set; }
[JsonProperty("deviceErrors")]
public int DeviceErrors { get; set; }
[JsonProperty("userErrors")]
public int UserErrors { get; set; }
[JsonProperty("appsByPhase")]
public Dictionary<string, int> AppsByPhase { get; set; } = new Dictionary<string, int>();
}
public class PackageInfo
{
[JsonProperty("appName")]
public string AppName { get; set; }
[JsonProperty("state")]
public string State { get; set; }
[JsonProperty("isError")]
public bool IsError { get; set; }
[JsonProperty("isCompleted")]
public bool IsCompleted { get; set; }
[JsonProperty("targeted")]
public string Targeted { get; set; }
}
}
@@ -136,7 +136,7 @@ namespace AutopilotMonitor.Functions.Functions.Config
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new AgentConfigResponse
{
ConfigVersion = 14, // script execution tracking: platform + remediation scripts from IME logs
ConfigVersion = 15, // enrollment summary dialog
UploadIntervalSeconds = 30,
SelfDestructOnComplete = tenantConfig.SelfDestructOnComplete ?? true,
KeepLogFile = tenantConfig.KeepLogFile ?? false,
@@ -147,6 +147,9 @@ namespace AutopilotMonitor.Functions.Functions.Config
LogLevel = tenantConfig.LogLevel ?? "Info",
RebootOnComplete = tenantConfig.RebootOnComplete ?? false,
RebootDelaySeconds = tenantConfig.RebootDelaySeconds ?? 10,
ShowEnrollmentSummary = tenantConfig.ShowEnrollmentSummary ?? false,
EnrollmentSummaryTimeoutSeconds = tenantConfig.EnrollmentSummaryTimeoutSeconds ?? 60,
EnrollmentSummaryBrandingImageUrl = tenantConfig.EnrollmentSummaryBrandingImageUrl,
MaxBatchSize = tenantConfig.MaxBatchSize ?? 100,
DiagnosticsUploadEnabled = !string.IsNullOrEmpty(tenantConfig.DiagnosticsBlobSasUrl),
DiagnosticsUploadMode = tenantConfig.DiagnosticsUploadMode ?? "Off",
@@ -86,6 +86,26 @@ namespace AutopilotMonitor.Shared.Models
/// </summary>
public int RebootDelaySeconds { get; set; } = 10;
/// <summary>
/// Whether to show a visual enrollment summary dialog to the end user
/// after enrollment completes (success or failure).
/// Default: false (opt-in)
/// </summary>
public bool ShowEnrollmentSummary { get; set; } = false;
/// <summary>
/// Auto-close timeout in seconds for the enrollment summary dialog.
/// 0 = no auto-close. Default: 60
/// </summary>
public int EnrollmentSummaryTimeoutSeconds { get; set; } = 60;
/// <summary>
/// Optional URL to a branding image displayed as a banner at the top of the enrollment summary dialog.
/// Expected size: 540 x 80 px. Larger images will be center-cropped.
/// null = no banner.
/// </summary>
public string EnrollmentSummaryBrandingImageUrl { get; set; }
/// <summary>
/// Maximum number of events per upload batch.
/// Default: 100
@@ -223,6 +223,25 @@ namespace AutopilotMonitor.Shared.Models
/// </summary>
public int? MaxBatchSize { get; set; }
/// <summary>
/// Whether to show a visual enrollment summary dialog to the end user
/// after enrollment completes (success or failure).
/// null = use agent default (false).
/// </summary>
public bool? ShowEnrollmentSummary { get; set; }
/// <summary>
/// Auto-close timeout in seconds for the enrollment summary dialog.
/// null = use agent default (60). 0 = no auto-close.
/// </summary>
public int? EnrollmentSummaryTimeoutSeconds { get; set; }
/// <summary>
/// Optional URL to a branding image displayed as a banner at the top of the enrollment summary dialog.
/// Expected size: 540 x 80 px. Larger images will be center-cropped.
/// </summary>
public string EnrollmentSummaryBrandingImageUrl { get; set; }
/// <summary>
/// Whether to show PowerShell script stdout in the web UI.
/// When false, only stderr (error output) is visible for troubleshooting.
@@ -53,52 +53,92 @@ export default function EventTimeline({
userEnrollDuration,
showScriptOutput,
}: EventTimelineProps) {
const [searchQuery, setSearchQuery] = useState("");
const matchesSearch = useMemo(() => {
if (!searchQuery.trim()) return null;
const q = searchQuery.toLowerCase();
return (event: EnrollmentEvent) =>
event.eventType?.toLowerCase().includes(q) ||
event.message?.toLowerCase().includes(q) ||
event.source?.toLowerCase().includes(q);
}, [searchQuery]);
const filterPhaseEvents = (phaseEvents: EnrollmentEvent[]) =>
matchesSearch ? phaseEvents.filter(matchesSearch) : phaseEvents;
return (
<div className="space-y-6">
{/* Severity filters + Expand/Collapse — shared controls above the timeline(s) */}
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs font-medium text-gray-500">Filter:</span>
{(["Debug", "Info", "Warning", "Error", "Critical"] as const).map((sev) => {
const active = severityFilters.has(sev);
const colors: Record<string, { on: string; off: string }> = {
Debug: { on: "bg-gray-200 text-gray-800", off: "bg-gray-50 text-gray-400" },
Info: { on: "bg-blue-100 text-blue-800", off: "bg-gray-50 text-gray-400" },
Warning: { on: "bg-yellow-100 text-yellow-800", off: "bg-gray-50 text-gray-400" },
Error: { on: "bg-red-100 text-red-800", off: "bg-gray-50 text-gray-400" },
Critical: { on: "bg-red-200 text-red-900", off: "bg-gray-50 text-gray-400" },
};
return (
{/* Search + Severity filters + Expand/Collapse — shared controls above the timeline(s) */}
<div className="space-y-2">
{/* Search bar — full width on mobile, fixed width on larger screens */}
<div className="relative w-full sm:w-48">
<svg className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-gray-400 pointer-events-none" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search events..."
className="w-full pl-7 pr-7 py-1 text-xs border border-gray-300 rounded-full focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
/>
{searchQuery && (
<button
key={sev}
onClick={() => toggleSeverityFilter(sev)}
className={`px-2.5 py-1 text-xs font-medium rounded-full transition-colors ${active ? colors[sev].on : colors[sev].off} hover:opacity-80`}
onClick={() => setSearchQuery("")}
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
{sev}
<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>
);
})}
<span className="text-xs text-gray-400">({filteredEvents.length}/{events.length})</span>
<div className="flex gap-1.5 ml-auto">
<button
onClick={expandAll}
title="Expand All"
className="flex items-center gap-1 px-2 py-1 text-xs bg-blue-50 text-blue-700 hover:bg-blue-100 rounded transition-colors"
>
<svg className="w-3.5 h-3.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
<span className="hidden sm:inline">Expand All</span>
</button>
<button
onClick={collapseAll}
title="Collapse All"
className="flex items-center gap-1 px-2 py-1 text-xs bg-gray-50 text-gray-700 hover:bg-gray-100 rounded transition-colors"
>
<svg className="w-3.5 h-3.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 15l7-7 7 7" />
</svg>
<span className="hidden sm:inline">Collapse All</span>
</button>
)}
</div>
{/* Severity filters + Expand/Collapse */}
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs font-medium text-gray-500">Filter:</span>
{(["Debug", "Info", "Warning", "Error", "Critical"] as const).map((sev) => {
const active = severityFilters.has(sev);
const colors: Record<string, { on: string; off: string }> = {
Debug: { on: "bg-gray-200 text-gray-800", off: "bg-gray-50 text-gray-400" },
Info: { on: "bg-blue-100 text-blue-800", off: "bg-gray-50 text-gray-400" },
Warning: { on: "bg-yellow-100 text-yellow-800", off: "bg-gray-50 text-gray-400" },
Error: { on: "bg-red-100 text-red-800", off: "bg-gray-50 text-gray-400" },
Critical: { on: "bg-red-200 text-red-900", off: "bg-gray-50 text-gray-400" },
};
return (
<button
key={sev}
onClick={() => toggleSeverityFilter(sev)}
className={`px-2.5 py-1 text-xs font-medium rounded-full transition-colors ${active ? colors[sev].on : colors[sev].off} hover:opacity-80`}
>
{sev}
</button>
);
})}
<span className="text-xs text-gray-400">({filteredEvents.length}/{events.length})</span>
<div className="flex gap-1.5 ml-auto">
<button
onClick={expandAll}
title="Expand All"
className="flex items-center gap-1 px-2 py-1 text-xs bg-blue-50 text-blue-700 hover:bg-blue-100 rounded transition-colors"
>
<svg className="w-3.5 h-3.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
<span className="hidden sm:inline">Expand All</span>
</button>
<button
onClick={collapseAll}
title="Collapse All"
className="flex items-center gap-1 px-2 py-1 text-xs bg-gray-50 text-gray-700 hover:bg-gray-100 rounded transition-colors"
>
<svg className="w-3.5 h-3.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 15l7-7 7 7" />
</svg>
<span className="hidden sm:inline">Collapse All</span>
</button>
</div>
</div>
</div>
@@ -122,7 +162,7 @@ export default function EventTimeline({
<PhaseSection
key={`pre-${phaseName}`}
phaseName={phaseName}
events={preProvGrouped.eventsByPhase[phaseName]}
events={filterPhaseEvents(preProvGrouped.eventsByPhase[phaseName])}
isExpanded={expandedPhases.has(`pre-${phaseName}`)}
onToggle={() => togglePhase(`pre-${phaseName}`)}
isGalacticAdmin={isGalacticAdmin}
@@ -160,7 +200,7 @@ export default function EventTimeline({
<PhaseSection
key={`user-${phaseName}`}
phaseName={phaseName}
events={userEnrollGrouped.eventsByPhase[phaseName]}
events={filterPhaseEvents(userEnrollGrouped.eventsByPhase[phaseName])}
isExpanded={expandedPhases.has(`user-${phaseName}`)}
onToggle={() => togglePhase(`user-${phaseName}`)}
isGalacticAdmin={isGalacticAdmin}
@@ -199,7 +239,7 @@ export default function EventTimeline({
<PhaseSection
key={phaseName}
phaseName={phaseName}
events={eventsByPhase[phaseName]}
events={filterPhaseEvents(eventsByPhase[phaseName])}
isExpanded={expandedPhases.has(phaseName)}
onToggle={() => togglePhase(phaseName)}
isGalacticAdmin={isGalacticAdmin}
@@ -298,18 +298,18 @@ export default function PhaseTimeline({ currentPhase, completedPhases, events =
/>
</div>
)}
{/* Circle */}
<div className={`relative z-10 w-12 h-12 rounded-full flex items-center justify-center border-2 transition-all font-semibold ${getPhaseColor(status)}`}>
{status === 'completed' ? '✓' : status === 'failed' ? '✕' : phase.id + 1}
</div>
{/* Labels */}
<div className="mt-3 text-center">
<div
className={`text-xs font-medium text-gray-700 whitespace-nowrap${onPhaseClick ? ' cursor-pointer' : ''}`}
onClick={onPhaseClick ? () => onPhaseClick(phase.name) : undefined}
>
{phase.shortName}
{/* Circle + Labels — clickable to scroll to phase in timeline */}
<div
className={onPhaseClick ? 'cursor-pointer' : ''}
onClick={onPhaseClick ? () => onPhaseClick(phase.name) : undefined}
>
<div className={`relative z-10 w-12 h-12 rounded-full flex items-center justify-center border-2 transition-all font-semibold mx-auto ${getPhaseColor(status)}`}>
{status === 'completed' ? '✓' : status === 'failed' ? '✕' : phase.id + 1}
</div>
<div className="mt-3 text-center">
<div className="text-xs font-medium text-gray-700 whitespace-nowrap">
{phase.shortName}
</div>
{(status === 'completed' || status === 'failed') && getPhaseDuration(phase.id) && (
<div className={`mt-0.5 text-[10px] ${
status === 'failed' ? 'text-red-400' :
@@ -328,6 +328,7 @@ export default function PhaseTimeline({ currentPhase, completedPhases, events =
</div>
</div>
)}
</div>
</div>
</div>
);
@@ -737,6 +737,30 @@ export default function SessionDetailPage() {
setExpandedPhases(new Set());
};
const scrollToPhase = (phaseName: string) => {
const id = `phase-${phaseName.replace(/[^a-zA-Z0-9]/g, '-')}`;
// Try both WhiteGlove prefixed and plain ids
const el = document.getElementById(id);
if (el) {
// Expand the phase section if collapsed
setExpandedPhases(prev => {
const newExpanded = new Set(prev);
// For WhiteGlove sessions, the expandedPhases key may be prefixed
if (isWhiteGloveSession) {
newExpanded.add(`pre-${phaseName}`);
newExpanded.add(`user-${phaseName}`);
} else {
newExpanded.add(phaseName);
}
return newExpanded;
});
// Scroll after a tick so the section is expanded
setTimeout(() => {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
}, 50);
}
};
const togglePhase = (phaseName: string) => {
setExpandedPhases(prev => {
const newExpanded = new Set(prev);
@@ -881,6 +905,7 @@ export default function SessionDetailPage() {
sessionStatus={session.status}
enrollmentType={session.enrollmentType}
isPreProvisioned={isWhiteGloveSession}
onPhaseClick={scrollToPhase}
/>
</div>
)}
@@ -23,6 +23,12 @@ interface AgentSettingsSectionProps {
setLogLevel: (value: string) => void;
showScriptOutput: boolean;
setShowScriptOutput: (value: boolean) => void;
showEnrollmentSummary: boolean;
setShowEnrollmentSummary: (value: boolean) => void;
enrollmentSummaryTimeoutSeconds: number;
setEnrollmentSummaryTimeoutSeconds: (value: number) => void;
enrollmentSummaryBrandingImageUrl: string;
setEnrollmentSummaryBrandingImageUrl: (value: string) => void;
}
export default function AgentSettingsSection({
@@ -48,6 +54,12 @@ export default function AgentSettingsSection({
setLogLevel,
showScriptOutput,
setShowScriptOutput,
showEnrollmentSummary,
setShowEnrollmentSummary,
enrollmentSummaryTimeoutSeconds,
setEnrollmentSummaryTimeoutSeconds,
enrollmentSummaryBrandingImageUrl,
setEnrollmentSummaryBrandingImageUrl,
}: AgentSettingsSectionProps) {
return (
<>
@@ -257,6 +269,56 @@ export default function AgentSettingsSection({
</select>
</div>
{/* Enrollment Summary Dialog */}
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-violet-200 transition-colors">
<div className="flex-1">
<p className="font-medium text-gray-900">Show Enrollment Summary</p>
<p className="text-sm text-gray-500">Display a visual enrollment summary dialog to the end user after enrollment completes (success or failure). Requires the SummaryDialog companion to be deployed alongside the agent.</p>
</div>
<button onClick={() => setShowEnrollmentSummary(!showEnrollmentSummary)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors ${showEnrollmentSummary ? 'bg-violet-500' : 'bg-gray-300'}`}>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${showEnrollmentSummary ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
{showEnrollmentSummary && (
<>
{/* Auto-close timeout */}
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-violet-200 transition-colors ml-4">
<div>
<p className="font-medium text-gray-900">Auto-Close Timeout</p>
<p className="text-sm text-gray-500">Seconds until the summary dialog closes automatically. 0 = no auto-close.</p>
</div>
<div className="flex items-center gap-2">
<input
type="number"
min={0}
max={3600}
value={enrollmentSummaryTimeoutSeconds}
onChange={(e) => setEnrollmentSummaryTimeoutSeconds(Math.max(0, parseInt(e.target.value) || 0))}
className="w-20 px-3 py-1.5 border border-gray-300 rounded-lg text-sm text-gray-900 text-right focus:ring-2 focus:ring-violet-500 focus:border-violet-500"
/>
<span className="text-sm text-gray-500 whitespace-nowrap">seconds</span>
</div>
</div>
{/* Branding Image URL */}
<div className="p-4 rounded-lg border border-gray-200 hover:border-violet-200 transition-colors ml-4">
<div className="mb-2">
<p className="font-medium text-gray-900">Branding Image URL</p>
<p className="text-sm text-gray-500">Optional banner image at the top of the summary dialog. Recommended size: 540 x 80 px. Larger images will be center-cropped.</p>
</div>
<input
type="url"
value={enrollmentSummaryBrandingImageUrl}
onChange={(e) => setEnrollmentSummaryBrandingImageUrl(e.target.value)}
placeholder="https://example.com/branding-banner.png"
className="w-full px-3 py-1.5 border border-gray-300 rounded-lg text-sm text-gray-900 focus:ring-2 focus:ring-violet-500 focus:border-violet-500"
/>
</div>
</>
)}
</div>
</div>
</>
@@ -58,6 +58,10 @@ export interface TenantConfiguration {
diagnosticsBlobSasUrl?: string;
diagnosticsUploadMode?: string;
diagnosticsLogPathsJson?: string;
// Enrollment summary dialog
showEnrollmentSummary?: boolean;
enrollmentSummaryTimeoutSeconds?: number;
enrollmentSummaryBrandingImageUrl?: string;
// Script output visibility
showScriptOutput?: boolean;
// Agent analyzer settings
@@ -131,6 +135,9 @@ export default function SettingsPage() {
const [enableImeMatchLog, setEnableImeMatchLog] = useState(false);
const [logLevel, setLogLevel] = useState("Info");
const [showScriptOutput, setShowScriptOutput] = useState(true);
const [showEnrollmentSummary, setShowEnrollmentSummary] = useState(false);
const [enrollmentSummaryTimeoutSeconds, setEnrollmentSummaryTimeoutSeconds] = useState(60);
const [enrollmentSummaryBrandingImageUrl, setEnrollmentSummaryBrandingImageUrl] = useState("");
// Teams notifications state
const [teamsWebhookUrl, setTeamsWebhookUrl] = useState("");
@@ -172,6 +179,9 @@ export default function SettingsPage() {
enableImeMatchLog !== (config.enableImeMatchLog ?? false) ||
logLevel !== (config.logLevel ?? "Info") ||
showScriptOutput !== (config.showScriptOutput ?? true) ||
showEnrollmentSummary !== (config.showEnrollmentSummary ?? false) ||
enrollmentSummaryTimeoutSeconds !== (config.enrollmentSummaryTimeoutSeconds ?? 60) ||
enrollmentSummaryBrandingImageUrl !== (config.enrollmentSummaryBrandingImageUrl ?? "") ||
teamsWebhookUrl !== (config.teamsWebhookUrl ?? "") ||
teamsNotifyOnSuccess !== (config.teamsNotifyOnSuccess ?? true) ||
teamsNotifyOnFailure !== (config.teamsNotifyOnFailure ?? true) ||
@@ -191,7 +201,8 @@ export default function SettingsPage() {
dataRetentionDays, sessionTimeoutHours, enablePerformanceCollector,
performanceCollectorInterval, helloWaitTimeoutSeconds, selfDestructOnComplete, keepLogFile,
rebootOnComplete, rebootDelaySeconds, enableGeoLocation, enableImeMatchLog,
logLevel, showScriptOutput, teamsWebhookUrl, teamsNotifyOnSuccess, teamsNotifyOnFailure,
logLevel, showScriptOutput, showEnrollmentSummary, enrollmentSummaryTimeoutSeconds,
enrollmentSummaryBrandingImageUrl, teamsWebhookUrl, teamsNotifyOnSuccess, teamsNotifyOnFailure,
diagnosticsBlobSasUrl, diagnosticsUploadMode, tenantDiagPaths,
enableLocalAdminAnalyzer, localAdminAllowedAccounts,
]);
@@ -233,6 +244,9 @@ export default function SettingsPage() {
setEnableImeMatchLog(data.enableImeMatchLog ?? false);
setLogLevel(data.logLevel ?? "Info");
setShowScriptOutput(data.showScriptOutput ?? true);
setShowEnrollmentSummary(data.showEnrollmentSummary ?? false);
setEnrollmentSummaryTimeoutSeconds(data.enrollmentSummaryTimeoutSeconds ?? 60);
setEnrollmentSummaryBrandingImageUrl(data.enrollmentSummaryBrandingImageUrl ?? "");
setTeamsWebhookUrl(data.teamsWebhookUrl ?? "");
setTeamsNotifyOnSuccess(data.teamsNotifyOnSuccess ?? true);
setTeamsNotifyOnFailure(data.teamsNotifyOnFailure ?? true);
@@ -454,6 +468,9 @@ export default function SettingsPage() {
enableImeMatchLog,
logLevel,
showScriptOutput,
showEnrollmentSummary,
enrollmentSummaryTimeoutSeconds,
enrollmentSummaryBrandingImageUrl: enrollmentSummaryBrandingImageUrl || undefined,
teamsWebhookUrl: teamsWebhookUrl || undefined,
teamsNotifyOnSuccess,
teamsNotifyOnFailure,
@@ -978,6 +995,12 @@ export default function SettingsPage() {
setLogLevel={setLogLevel}
showScriptOutput={showScriptOutput}
setShowScriptOutput={setShowScriptOutput}
showEnrollmentSummary={showEnrollmentSummary}
setShowEnrollmentSummary={setShowEnrollmentSummary}
enrollmentSummaryTimeoutSeconds={enrollmentSummaryTimeoutSeconds}
setEnrollmentSummaryTimeoutSeconds={setEnrollmentSummaryTimeoutSeconds}
enrollmentSummaryBrandingImageUrl={enrollmentSummaryBrandingImageUrl}
setEnrollmentSummaryBrandingImageUrl={setEnrollmentSummaryBrandingImageUrl}
/>
<AgentAnalyzersSection
File diff suppressed because one or more lines are too long