mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
added optional EnrollmentSummary dialog, better Event Timeline (added search) and Phase Timeline (clickable Phases)
This commit is contained in:
+733
-1
@@ -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} |
|
||||
|
||||
@@ -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.
|
||||
|
||||
+96
-2
@@ -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=""
|
||||
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.
|
||||
|
||||
+84
-44
@@ -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}
|
||||
|
||||
+12
-11
@@ -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
Reference in New Issue
Block a user