From 4982ae893d6efe41236f66a95ddddcf733214565 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Fri, 6 Mar 2026 12:10:20 +0100 Subject: [PATCH] added optional EnrollmentSummary dialog, better Event Timeline (added search) and Phase Timeline (clickable Phases) --- ARCHITECTURE.md | 734 +++++++++++++++++- AutopilotMonitor.sln | 15 + .../Configuration/AgentConfiguration.cs | 16 + .../Core/MonitoringService.EventUpload.cs | 98 ++- .../Monitoring/Core/MonitoringService.cs | 5 + .../Core/UserSessionProcessLauncher.cs | 326 ++++++++ .../AutopilotMonitor.Agent/buildcounter.txt | 2 +- .../AutopilotMonitor.SummaryDialog/App.xaml | 7 + .../App.xaml.cs | 94 +++ .../AutopilotMonitor.SummaryDialog.csproj | 20 + .../MainWindow.xaml | 218 ++++++ .../MainWindow.xaml.cs | 485 ++++++++++++ .../Models/FinalStatus.cs | 74 ++ .../Config/GetAgentConfigFunction.cs | 5 +- .../Models/Config/AgentConfigResponse.cs | 20 + .../Models/Config/TenantConfiguration.cs | 19 + .../[sessionId]/components/EventTimeline.tsx | 128 +-- .../[sessionId]/components/PhaseTimeline.tsx | 23 +- .../app/sessions/[sessionId]/page.tsx | 25 + .../components/AgentSettingsSection.tsx | 62 ++ .../app/settings/page.tsx | 25 +- .../tsconfig.tsbuildinfo | 2 +- 22 files changed, 2341 insertions(+), 62 deletions(-) create mode 100644 src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/UserSessionProcessLauncher.cs create mode 100644 src/Agent/AutopilotMonitor.SummaryDialog/App.xaml create mode 100644 src/Agent/AutopilotMonitor.SummaryDialog/App.xaml.cs create mode 100644 src/Agent/AutopilotMonitor.SummaryDialog/AutopilotMonitor.SummaryDialog.csproj create mode 100644 src/Agent/AutopilotMonitor.SummaryDialog/MainWindow.xaml create mode 100644 src/Agent/AutopilotMonitor.SummaryDialog/MainWindow.xaml.cs create mode 100644 src/Agent/AutopilotMonitor.SummaryDialog/Models/FinalStatus.cs diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index bea85cac..c757c45d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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 `
` 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 `
` | +| Maintenance timer | Runs every 2 hours (not daily, despite function name) | +| Agent versioning | Auto-incremented: 1.0.{BuildNumber} | diff --git a/AutopilotMonitor.sln b/AutopilotMonitor.sln index 402971fc..dbd758be 100644 --- a/AutopilotMonitor.sln +++ b/AutopilotMonitor.sln @@ -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} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Configuration/AgentConfiguration.cs b/src/Agent/AutopilotMonitor.Agent.Core/Configuration/AgentConfiguration.cs index 786f52e5..de171285 100644 --- a/src/Agent/AutopilotMonitor.Agent.Core/Configuration/AgentConfiguration.cs +++ b/src/Agent/AutopilotMonitor.Agent.Core/Configuration/AgentConfiguration.cs @@ -174,6 +174,22 @@ namespace AutopilotMonitor.Agent.Core.Configuration /// public bool UseBootstrapTokenAuth { get; set; } + /// + /// Whether to show a visual enrollment summary dialog to the end user. + /// + public bool ShowEnrollmentSummary { get; set; } = false; + + /// + /// Auto-close timeout in seconds for the enrollment summary dialog. + /// 0 = no auto-close. Default: 60 + /// + public int EnrollmentSummaryTimeoutSeconds { get; set; } = 60; + + /// + /// Optional URL to a branding image for the enrollment summary dialog banner. + /// + public string EnrollmentSummaryBrandingImageUrl { get; set; } + /// /// Whether diagnostics upload is configured for this tenant. /// When true, the agent requests a short-lived upload URL from the backend just before uploading. diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/MonitoringService.EventUpload.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/MonitoringService.EventUpload.cs index 22076c96..867c3cb0 100644 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/MonitoringService.EventUpload.cs +++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/MonitoringService.EventUpload.cs @@ -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 } } + /// + /// 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. + /// + 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(); diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/MonitoringService.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/MonitoringService.cs index 383cf11d..015b8b8f 100644 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/MonitoringService.cs +++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/MonitoringService.cs @@ -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}"); } /// diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/UserSessionProcessLauncher.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/UserSessionProcessLauncher.cs new file mode 100644 index 00000000..bcc8aa09 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/UserSessionProcessLauncher.cs @@ -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 +{ + /// + /// Launches a process in the logged-in user's desktop session from a SYSTEM context. + /// Uses CreateProcessAsUser with a token duplicated from explorer.exe. + /// + internal static class UserSessionProcessLauncher + { + /// + /// System/service account names that should NOT be considered real users. + /// + private static readonly string[] ExcludedUserNames = + { + "SYSTEM", "LOCAL SERVICE", "NETWORK SERVICE", + "DefaultUser0", "DefaultUser1", "defaultuser0", "defaultuser1" + }; + + /// + /// 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. + /// + 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); + } + } + + /// + /// Finds an explorer.exe process running in a non-SYSTEM user session. + /// Returns the Process handle, or null if none found. + /// + 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; + } + + /// + /// Win32 P/Invoke declarations for process creation in user session. + /// + 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); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt index e6a4f00b..e01062f1 100644 --- a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt @@ -1 +1 @@ -271 +282 diff --git a/src/Agent/AutopilotMonitor.SummaryDialog/App.xaml b/src/Agent/AutopilotMonitor.SummaryDialog/App.xaml new file mode 100644 index 00000000..d054089e --- /dev/null +++ b/src/Agent/AutopilotMonitor.SummaryDialog/App.xaml @@ -0,0 +1,7 @@ + + + + diff --git a/src/Agent/AutopilotMonitor.SummaryDialog/App.xaml.cs b/src/Agent/AutopilotMonitor.SummaryDialog/App.xaml.cs new file mode 100644 index 00000000..eb13dc2c --- /dev/null +++ b/src/Agent/AutopilotMonitor.SummaryDialog/App.xaml.cs @@ -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 + } + } + + /// + /// 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. + /// + 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 + } + } + } +} diff --git a/src/Agent/AutopilotMonitor.SummaryDialog/AutopilotMonitor.SummaryDialog.csproj b/src/Agent/AutopilotMonitor.SummaryDialog/AutopilotMonitor.SummaryDialog.csproj new file mode 100644 index 00000000..f3facfc2 --- /dev/null +++ b/src/Agent/AutopilotMonitor.SummaryDialog/AutopilotMonitor.SummaryDialog.csproj @@ -0,0 +1,20 @@ + + + + WinExe + net48 + latest + true + AutopilotMonitor.SummaryDialog + AutopilotMonitor.SummaryDialog + + + + + + + + + + + diff --git a/src/Agent/AutopilotMonitor.SummaryDialog/MainWindow.xaml b/src/Agent/AutopilotMonitor.SummaryDialog/MainWindow.xaml new file mode 100644 index 00000000..d871d926 --- /dev/null +++ b/src/Agent/AutopilotMonitor.SummaryDialog/MainWindow.xaml @@ -0,0 +1,218 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Agent/AutopilotMonitor.SummaryDialog/MainWindow.xaml.cs b/src/Agent/AutopilotMonitor.SummaryDialog/MainWindow.xaml.cs new file mode 100644 index 00000000..572a5e60 --- /dev/null +++ b/src/Agent/AutopilotMonitor.SummaryDialog/MainWindow.xaml.cs @@ -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(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().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 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)); + } + + /// + /// Applies a slim, modern scrollbar style matching the current theme. + /// Completely re-templates the vertical ScrollBar to remove arrow buttons (Win11 style). + /// + 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 = $@" +"; + + var style = (Style)System.Windows.Markup.XamlReader.Parse(xaml); + Resources[typeof(System.Windows.Controls.Primitives.ScrollBar)] = style; + } + + /// + /// 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. + /// + 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(this DependencyObject obj) where T : class + { + return obj as T; + } + } +} diff --git a/src/Agent/AutopilotMonitor.SummaryDialog/Models/FinalStatus.cs b/src/Agent/AutopilotMonitor.SummaryDialog/Models/FinalStatus.cs new file mode 100644 index 00000000..83f84bb2 --- /dev/null +++ b/src/Agent/AutopilotMonitor.SummaryDialog/Models/FinalStatus.cs @@ -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 SignalsSeen { get; set; } = new List(); + + [JsonProperty("appSummary")] + public AppSummary AppSummary { get; set; } + + [JsonProperty("packageStatesByPhase")] + public Dictionary> PackageStatesByPhase { get; set; } = new Dictionary>(); + } + + 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 AppsByPhase { get; set; } = new Dictionary(); + } + + 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; } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs index 1832da8b..b1317860 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs @@ -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", diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Config/AgentConfigResponse.cs b/src/Shared/AutopilotMonitor.Shared/Models/Config/AgentConfigResponse.cs index d650ddca..c1f1064c 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/Config/AgentConfigResponse.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/Config/AgentConfigResponse.cs @@ -86,6 +86,26 @@ namespace AutopilotMonitor.Shared.Models /// public int RebootDelaySeconds { get; set; } = 10; + /// + /// Whether to show a visual enrollment summary dialog to the end user + /// after enrollment completes (success or failure). + /// Default: false (opt-in) + /// + public bool ShowEnrollmentSummary { get; set; } = false; + + /// + /// Auto-close timeout in seconds for the enrollment summary dialog. + /// 0 = no auto-close. Default: 60 + /// + public int EnrollmentSummaryTimeoutSeconds { get; set; } = 60; + + /// + /// 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. + /// + public string EnrollmentSummaryBrandingImageUrl { get; set; } + /// /// Maximum number of events per upload batch. /// Default: 100 diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs b/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs index 40b9bb2c..9ac9aa39 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs @@ -223,6 +223,25 @@ namespace AutopilotMonitor.Shared.Models /// public int? MaxBatchSize { get; set; } + /// + /// Whether to show a visual enrollment summary dialog to the end user + /// after enrollment completes (success or failure). + /// null = use agent default (false). + /// + public bool? ShowEnrollmentSummary { get; set; } + + /// + /// Auto-close timeout in seconds for the enrollment summary dialog. + /// null = use agent default (60). 0 = no auto-close. + /// + public int? EnrollmentSummaryTimeoutSeconds { get; set; } + + /// + /// 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. + /// + public string EnrollmentSummaryBrandingImageUrl { get; set; } + /// /// Whether to show PowerShell script stdout in the web UI. /// When false, only stderr (error output) is visible for troubleshooting. diff --git a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/EventTimeline.tsx b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/EventTimeline.tsx index b39db899..5480eaa1 100644 --- a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/EventTimeline.tsx +++ b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/EventTimeline.tsx @@ -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 (
- {/* Severity filters + Expand/Collapse — shared controls above the timeline(s) */} -
- Filter: - {(["Debug", "Info", "Warning", "Error", "Critical"] as const).map((sev) => { - const active = severityFilters.has(sev); - const colors: Record = { - 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) */} +
+ {/* Search bar — full width on mobile, fixed width on larger screens */} +
+ + + + 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 && ( - ); - })} - ({filteredEvents.length}/{events.length}) -
- - + )} +
+ {/* Severity filters + Expand/Collapse */} +
+ Filter: + {(["Debug", "Info", "Warning", "Error", "Critical"] as const).map((sev) => { + const active = severityFilters.has(sev); + const colors: Record = { + 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 ( + + ); + })} + ({filteredEvents.length}/{events.length}) +
+ + +
@@ -122,7 +162,7 @@ export default function EventTimeline({ togglePhase(`pre-${phaseName}`)} isGalacticAdmin={isGalacticAdmin} @@ -160,7 +200,7 @@ export default function EventTimeline({ togglePhase(`user-${phaseName}`)} isGalacticAdmin={isGalacticAdmin} @@ -199,7 +239,7 @@ export default function EventTimeline({ togglePhase(phaseName)} isGalacticAdmin={isGalacticAdmin} diff --git a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/PhaseTimeline.tsx b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/PhaseTimeline.tsx index c101b187..24dd0f96 100644 --- a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/PhaseTimeline.tsx +++ b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/PhaseTimeline.tsx @@ -298,18 +298,18 @@ export default function PhaseTimeline({ currentPhase, completedPhases, events = />
)} - {/* Circle */} -
- {status === 'completed' ? '✓' : status === 'failed' ? '✕' : phase.id + 1} -
- {/* Labels */} -
-
onPhaseClick(phase.name) : undefined} - > - {phase.shortName} + {/* Circle + Labels — clickable to scroll to phase in timeline */} +
onPhaseClick(phase.name) : undefined} + > +
+ {status === 'completed' ? '✓' : status === 'failed' ? '✕' : phase.id + 1}
+
+
+ {phase.shortName} +
{(status === 'completed' || status === 'failed') && getPhaseDuration(phase.id) && (
)} +
); diff --git a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/page.tsx b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/page.tsx index c8624d9d..e777de33 100644 --- a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/page.tsx +++ b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/page.tsx @@ -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} />
)} diff --git a/src/Web/autopilot-monitor-web/app/settings/components/AgentSettingsSection.tsx b/src/Web/autopilot-monitor-web/app/settings/components/AgentSettingsSection.tsx index 830ef665..74b20f3d 100644 --- a/src/Web/autopilot-monitor-web/app/settings/components/AgentSettingsSection.tsx +++ b/src/Web/autopilot-monitor-web/app/settings/components/AgentSettingsSection.tsx @@ -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({
+ {/* Enrollment Summary Dialog */} +
+
+

Show Enrollment Summary

+

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.

+
+ +
+ + {showEnrollmentSummary && ( + <> + {/* Auto-close timeout */} +
+
+

Auto-Close Timeout

+

Seconds until the summary dialog closes automatically. 0 = no auto-close.

+
+
+ 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" + /> + seconds +
+
+ + {/* Branding Image URL */} +
+
+

Branding Image URL

+

Optional banner image at the top of the summary dialog. Recommended size: 540 x 80 px. Larger images will be center-cropped.

+
+ 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" + /> +
+ + )} +
diff --git a/src/Web/autopilot-monitor-web/app/settings/page.tsx b/src/Web/autopilot-monitor-web/app/settings/page.tsx index e0d5e268..d498a398 100644 --- a/src/Web/autopilot-monitor-web/app/settings/page.tsx +++ b/src/Web/autopilot-monitor-web/app/settings/page.tsx @@ -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} />