Initial commit

This commit is contained in:
Oliver Kieselbach
2026-02-05 06:14:41 +01:00
commit 5690620058
104 changed files with 23845 additions and 0 deletions
+95
View File
@@ -0,0 +1,95 @@
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
[Ll]ogs/
# Visual Studio cache/options
.vs/
.vscode/
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
# Build Results
*.dll
*.exe
*.pdb
*.cache
# NuGet Packages
*.nupkg
*.snupkg
**/packages/*
!**/packages/build/
*.nuget.props
*.nuget.targets
# Azure Functions
local.settings.json
__blobstorage__
__queuestorage__
__azurite_db*__.json
# Node.js / Next.js
node_modules/
.next/
out/
build/
dist/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
.npm
.eslintcache
# Environment variables
.env
.env*.local
*.env
# IDE
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
desktop.ini
# Test results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
*.trx
*.coverage
# Secrets and certificates
*.pfx
*.p12
*.key
*.crt
appsettings.*.json
!appsettings.json
!appsettings.Development.json
# Local development
.local/
*.local.*
temp/
tmp/
+93
View File
@@ -0,0 +1,93 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Shared", "Shared", "{C8E42992-5E42-0C2B-DBFE-AA848D06431C}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AutopilotMonitor.Shared", "src\Shared\AutopilotMonitor.Shared\AutopilotMonitor.Shared.csproj", "{A54780AA-B9BD-4575-A935-2C3E0296B405}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Agent", "Agent", "{096B6098-F141-7114-B090-72D1899D8853}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AutopilotMonitor.Agent.Core", "src\Agent\AutopilotMonitor.Agent.Core\AutopilotMonitor.Agent.Core.csproj", "{4AF25FD4-6BCC-44F1-BDFB-E10DA9CF620B}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AutopilotMonitor.Agent", "src\Agent\AutopilotMonitor.Agent\AutopilotMonitor.Agent.csproj", "{7252A85F-60EC-4CFA-B3A6-A204241FFBBC}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Backend", "Backend", "{FE360695-3F2B-1049-3887-35FBB5135923}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AutopilotMonitor.Functions", "src\Backend\AutopilotMonitor.Functions\AutopilotMonitor.Functions.csproj", "{4BA82F07-39A9-4B80-A12D-3D4E4ACE6682}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{A54780AA-B9BD-4575-A935-2C3E0296B405}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A54780AA-B9BD-4575-A935-2C3E0296B405}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A54780AA-B9BD-4575-A935-2C3E0296B405}.Debug|x64.ActiveCfg = Debug|Any CPU
{A54780AA-B9BD-4575-A935-2C3E0296B405}.Debug|x64.Build.0 = Debug|Any CPU
{A54780AA-B9BD-4575-A935-2C3E0296B405}.Debug|x86.ActiveCfg = Debug|Any CPU
{A54780AA-B9BD-4575-A935-2C3E0296B405}.Debug|x86.Build.0 = Debug|Any CPU
{A54780AA-B9BD-4575-A935-2C3E0296B405}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A54780AA-B9BD-4575-A935-2C3E0296B405}.Release|Any CPU.Build.0 = Release|Any CPU
{A54780AA-B9BD-4575-A935-2C3E0296B405}.Release|x64.ActiveCfg = Release|Any CPU
{A54780AA-B9BD-4575-A935-2C3E0296B405}.Release|x64.Build.0 = Release|Any CPU
{A54780AA-B9BD-4575-A935-2C3E0296B405}.Release|x86.ActiveCfg = Release|Any CPU
{A54780AA-B9BD-4575-A935-2C3E0296B405}.Release|x86.Build.0 = Release|Any CPU
{4AF25FD4-6BCC-44F1-BDFB-E10DA9CF620B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{4AF25FD4-6BCC-44F1-BDFB-E10DA9CF620B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4AF25FD4-6BCC-44F1-BDFB-E10DA9CF620B}.Debug|x64.ActiveCfg = Debug|Any CPU
{4AF25FD4-6BCC-44F1-BDFB-E10DA9CF620B}.Debug|x64.Build.0 = Debug|Any CPU
{4AF25FD4-6BCC-44F1-BDFB-E10DA9CF620B}.Debug|x86.ActiveCfg = Debug|Any CPU
{4AF25FD4-6BCC-44F1-BDFB-E10DA9CF620B}.Debug|x86.Build.0 = Debug|Any CPU
{4AF25FD4-6BCC-44F1-BDFB-E10DA9CF620B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{4AF25FD4-6BCC-44F1-BDFB-E10DA9CF620B}.Release|Any CPU.Build.0 = Release|Any CPU
{4AF25FD4-6BCC-44F1-BDFB-E10DA9CF620B}.Release|x64.ActiveCfg = Release|Any CPU
{4AF25FD4-6BCC-44F1-BDFB-E10DA9CF620B}.Release|x64.Build.0 = Release|Any CPU
{4AF25FD4-6BCC-44F1-BDFB-E10DA9CF620B}.Release|x86.ActiveCfg = Release|Any CPU
{4AF25FD4-6BCC-44F1-BDFB-E10DA9CF620B}.Release|x86.Build.0 = Release|Any CPU
{7252A85F-60EC-4CFA-B3A6-A204241FFBBC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7252A85F-60EC-4CFA-B3A6-A204241FFBBC}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7252A85F-60EC-4CFA-B3A6-A204241FFBBC}.Debug|x64.ActiveCfg = Debug|Any CPU
{7252A85F-60EC-4CFA-B3A6-A204241FFBBC}.Debug|x64.Build.0 = Debug|Any CPU
{7252A85F-60EC-4CFA-B3A6-A204241FFBBC}.Debug|x86.ActiveCfg = Debug|Any CPU
{7252A85F-60EC-4CFA-B3A6-A204241FFBBC}.Debug|x86.Build.0 = Debug|Any CPU
{7252A85F-60EC-4CFA-B3A6-A204241FFBBC}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7252A85F-60EC-4CFA-B3A6-A204241FFBBC}.Release|Any CPU.Build.0 = Release|Any CPU
{7252A85F-60EC-4CFA-B3A6-A204241FFBBC}.Release|x64.ActiveCfg = Release|Any CPU
{7252A85F-60EC-4CFA-B3A6-A204241FFBBC}.Release|x64.Build.0 = Release|Any CPU
{7252A85F-60EC-4CFA-B3A6-A204241FFBBC}.Release|x86.ActiveCfg = Release|Any CPU
{7252A85F-60EC-4CFA-B3A6-A204241FFBBC}.Release|x86.Build.0 = Release|Any CPU
{4BA82F07-39A9-4B80-A12D-3D4E4ACE6682}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{4BA82F07-39A9-4B80-A12D-3D4E4ACE6682}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4BA82F07-39A9-4B80-A12D-3D4E4ACE6682}.Debug|x64.ActiveCfg = Debug|Any CPU
{4BA82F07-39A9-4B80-A12D-3D4E4ACE6682}.Debug|x64.Build.0 = Debug|Any CPU
{4BA82F07-39A9-4B80-A12D-3D4E4ACE6682}.Debug|x86.ActiveCfg = Debug|Any CPU
{4BA82F07-39A9-4B80-A12D-3D4E4ACE6682}.Debug|x86.Build.0 = Debug|Any CPU
{4BA82F07-39A9-4B80-A12D-3D4E4ACE6682}.Release|Any CPU.ActiveCfg = Release|Any CPU
{4BA82F07-39A9-4B80-A12D-3D4E4ACE6682}.Release|Any CPU.Build.0 = Release|Any CPU
{4BA82F07-39A9-4B80-A12D-3D4E4ACE6682}.Release|x64.ActiveCfg = Release|Any CPU
{4BA82F07-39A9-4B80-A12D-3D4E4ACE6682}.Release|x64.Build.0 = Release|Any CPU
{4BA82F07-39A9-4B80-A12D-3D4E4ACE6682}.Release|x86.ActiveCfg = Release|Any CPU
{4BA82F07-39A9-4B80-A12D-3D4E4ACE6682}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{C8E42992-5E42-0C2B-DBFE-AA848D06431C} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{A54780AA-B9BD-4575-A935-2C3E0296B405} = {C8E42992-5E42-0C2B-DBFE-AA848D06431C}
{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}
{FE360695-3F2B-1049-3887-35FBB5135923} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{4BA82F07-39A9-4B80-A12D-3D4E4ACE6682} = {FE360695-3F2B-1049-3887-35FBB5135923}
EndGlobalSection
EndGlobal
+251
View File
@@ -0,0 +1,251 @@
# Contributing to Autopilot Monitor
Thank you for your interest in contributing to Autopilot Monitor! This document provides guidelines and instructions for contributing.
## Code of Conduct
Be respectful, professional, and constructive. We're all here to make Autopilot deployments better.
## How Can I Contribute?
### Reporting Bugs
Before submitting a bug report:
1. Check existing GitHub issues
2. Verify you're using the latest version
3. Collect relevant logs and error messages
**Good Bug Report Includes**:
- Clear description of the issue
- Steps to reproduce
- Expected vs actual behavior
- Environment details (OS version, .NET version, etc.)
- Relevant log excerpts
- Screenshots if applicable
### Suggesting Features
We love feature ideas! Before submitting:
1. Check if it's already been suggested
2. Consider if it fits the project scope
3. Think about implementation complexity
**Good Feature Request Includes**:
- Clear description of the feature
- Use case / problem it solves
- Proposed solution (if you have one)
- Alternative approaches considered
### Contributing Code
#### Development Setup
1. Fork the repository
2. Clone your fork
3. Follow the [Getting Started Guide](docs/getting-started.md)
4. Create a feature branch: `git checkout -b feature/my-feature`
#### Coding Standards
**C# / .NET**
- Follow Microsoft C# coding conventions
- Use meaningful variable and method names
- Add XML documentation comments for public APIs
- Keep methods focused and small (<50 lines preferred)
- Use async/await properly (don't block)
**TypeScript / React**
- Use TypeScript for type safety
- Follow React best practices (hooks, functional components)
- Use meaningful component and variable names
- Keep components small and focused
- Extract reusable logic into custom hooks
**PowerShell**
- Use approved verbs (Get-, Set-, New-, etc.)
- Add comment-based help for scripts
- Use meaningful parameter names
- Include error handling
#### Code Review Process
1. Ensure all tests pass
2. Update documentation if needed
3. Submit a pull request with:
- Clear title and description
- Reference to related issues
- Screenshots (if UI changes)
4. Address review feedback
5. Squash commits before merge (if requested)
### Contributing Rules
The **rule engine** (Phase 2+) is designed to be community-driven. You can contribute rules to help diagnose common issues.
#### Rule Structure
Rules are YAML files in the `rules/` directory:
```yaml
id: NET-001
title: "Proxy Authentication Required"
severity: high
category: network
version: 1.0
author: "Your Name"
description: |
Detects when a proxy requires authentication but SYSTEM account cannot authenticate.
conditions:
- signal: "event_407_detected"
source: "windows_events"
provider: "Microsoft-Windows-WinHttp"
event_id: 407
minimum_occurrences: 1
evidence_selectors:
- type: "log_lines"
source: "ime_log"
pattern: "proxy|407|authentication"
context_lines: 3
explanation: |
The device is behind a proxy that requires authentication, but the SYSTEM
account (used during enrollment) cannot authenticate.
remediation:
- "Configure proxy bypass for *.manage.microsoft.com"
- "Configure proxy bypass for *.microsoftonline.com"
- "Use PAC file with proper exclusions"
related_docs:
- "https://learn.microsoft.com/en-us/mem/intune/enrollment/enrollment-autopilot"
confidence_scoring:
base: 50
factors:
- signal: "winhttp_proxy_configured"
weight: 20
- signal: "enrollment_stalled_at_mdm"
weight: 30
```
#### Submitting Rules
1. Create a new YAML file in the appropriate `rules/` subdirectory
2. Follow the schema above
3. Test your rule against sample data
4. Submit a PR with:
- The rule file
- Test data (anonymized)
- Explanation of how to trigger this condition
## Project Structure
```
Autopilot-Monitor/
├── src/
│ ├── Agent/ # Windows monitoring agent
│ ├── Backend/ # Azure Functions API
│ ├── Shared/ # Common models/contracts
│ └── Web/ # Next.js web UI
├── scripts/
│ ├── Bootstrap/ # Intune deployment scripts
│ └── Deployment/ # Azure deployment scripts
├── rules/ # Community rule definitions
├── docs/ # Documentation
└── tests/ # Tests (to be added)
```
## Testing Guidelines
### Unit Tests
- Test core logic in isolation
- Mock external dependencies
- Aim for >80% code coverage on critical paths
### Integration Tests
- Test API endpoints with real Azure Storage Emulator
- Verify agent can upload to Functions
- Test error handling and retries
### End-to-End Tests
- Test full enrollment simulation (when feasible)
- Verify UI displays data correctly
- Test troubleshooting workflows
## Documentation
When adding features, please update:
- **README.md** - If it affects setup or high-level description
- **docs/getting-started.md** - If it affects the getting started process
- **docs/architecture.md** - If it changes architecture
- **Inline code comments** - For complex logic
- **XML docs** - For public APIs
## Git Workflow
We use a simplified Git workflow:
1. **main** branch - Stable, production-ready code
2. **feature/** branches - New features in development
3. **bugfix/** branches - Bug fixes
4. **hotfix/** branches - Critical production fixes
### Commit Messages
Use clear, descriptive commit messages:
```
[Component] Short description
Longer explanation if needed.
Fixes #123
```
Examples:
- `[Agent] Add support for pre-provisioning scenarios`
- `[Web] Implement session detail page with timeline`
- `[Functions] Fix race condition in event ingestion`
- `[Docs] Update architecture diagram`
## Release Process
Releases follow semantic versioning (MAJOR.MINOR.PATCH):
- **MAJOR**: Breaking changes
- **MINOR**: New features (backwards compatible)
- **PATCH**: Bug fixes
### Release Checklist
- [ ] All tests pass
- [ ] Documentation updated
- [ ] CHANGELOG.md updated
- [ ] Version numbers incremented
- [ ] Tag created: `vX.Y.Z`
- [ ] GitHub release published
- [ ] Deployment artifacts published
## Getting Help
- **Questions**: Open a GitHub Discussion
- **Bugs**: Open a GitHub Issue
- **Feature Ideas**: Open a GitHub Issue with "enhancement" label
- **Security Issues**: Email security@yourcompany.com (do NOT open public issue)
## Recognition
Contributors will be:
- Listed in CONTRIBUTORS.md
- Credited in release notes
- Mentioned in documentation for significant contributions
## License
By contributing, you agree that your contributions will be licensed under the MIT License.
## Thank You!
Your contributions make Autopilot Monitor better for everyone. Thank you for taking the time to contribute!
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Autopilot Monitor Contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+183
View File
@@ -0,0 +1,183 @@
# Quick Start Guide
Get Autopilot Monitor running in 10 minutes!
## Prerequisites Check
Before starting, ensure you have:
- [ ] Windows 10/11
- [ ] .NET Framework 4.8 SDK
- [ ] .NET 8 SDK
- [ ] Node.js 18+
- [ ] Visual Studio 2022 or VS Code
Verify installations:
```bash
dotnet --version # Should show 8.x or higher
node --version # Should show v18.x or higher
```
## Step 1: Build the .NET Projects (2 minutes)
```bash
# Open a terminal in the project root
cd C:\Code\GitHubRepos\Autopilot-Monitor
# Restore all packages
dotnet restore
# Build everything
dotnet build
```
Expected output: `Build succeeded`
## Step 2: Install Azure Storage Emulator (2 minutes)
### Option A: Azurite (Recommended)
```bash
npm install -g azurite
# Start Azurite
azurite --silent --location c:\azurite
```
### Option B: Azure Storage Emulator
If you have the legacy Azure Storage Emulator installed, start it:
```bash
AzureStorageEmulator.exe start
```
## Step 3: Start the Backend API (2 minutes)
Open a new terminal:
```bash
cd src\Backend\AutopilotMonitor.Functions
# Start Azure Functions
func start
```
Expected output:
```
Azure Functions Core Tools
...
Functions:
IngestEvents: [POST] http://localhost:7071/api/events/ingest
RegisterSession: [POST] http://localhost:7071/api/sessions/register
```
## Step 4: Start the Web UI (3 minutes)
Open another terminal:
```bash
cd src\Web\autopilot-monitor-web
# Install dependencies (first time only)
npm install
# Copy environment template
copy .env.local.template .env.local
# Start development server
npm run dev
```
Expected output: `Ready on http://localhost:3000`
## Step 5: Test the Agent (2 minutes)
Open PowerShell **as Administrator**:
```powershell
cd src\Agent\AutopilotMonitor.Agent\bin\Debug\net48
# Run in console mode
.\AutopilotMonitor.Agent.exe --console
```
Expected output:
```
Autopilot Monitor Agent - Console Mode
======================================
Session ID: <some-guid>
API URL: http://localhost:7071
Log Directory: C:\ProgramData\AutopilotMonitor\Logs
Agent is running. Press Enter to stop...
```
## Verify It's Working
1. **Check the Agent Console** - Should show "Agent started" event
2. **Check the Functions Console** - Should show incoming requests
3. **Open Browser** - Navigate to http://localhost:3000
4. **View Dashboard** - You should see the Autopilot Monitor homepage
## What's Next?
### Explore the Code
- [Agent Code](src/Agent/AutopilotMonitor.Agent.Core/) - Monitoring logic
- [Backend API](src/Backend/AutopilotMonitor.Functions/) - Azure Functions
- [Web UI](src/Web/autopilot-monitor-web/) - Next.js dashboard
### Read Documentation
- [Getting Started Guide](docs/getting-started.md) - Detailed setup
- [Architecture Overview](docs/architecture.md) - How it all works
- [Contributing Guide](CONTRIBUTING.md) - How to contribute
### Try It Out
1. Modify the agent to emit custom events
2. Add a new API endpoint
3. Create a new page in the web UI
4. Write a rule for the rule engine (Phase 2)
## Troubleshooting
### Agent won't start
- Run as Administrator
- Check .NET Framework 4.8 is installed
- View logs in `C:\ProgramData\AutopilotMonitor\Logs\`
### Functions won't start
- Ensure Azurite/Storage Emulator is running
- Check `local.settings.json` exists
- Install Azure Functions Core Tools v4
### Web UI shows errors
- Run `npm install` again
- Check `.env.local` exists
- Verify Functions are running on port 7071
### Can't connect to API
- Check Windows Firewall
- Verify Functions console shows the correct URLs
- Try `curl http://localhost:7071/api/sessions/register`
## Next Development Steps
1. **Phase 1 Completion**
- Implement Azure Table Storage integration
- Add real-time session monitoring
- Create session detail page
2. **Phase 2: Intelligence**
- Build rule engine
- Add diagnosis panel
- Implement troubleshooting bundles
3. **Phase 3: Production**
- Deploy to Azure
- Configure Intune deployment
- Set up monitoring and alerts
## Getting Help
- Check [README.md](README.md) for overview
- Read [docs/getting-started.md](docs/getting-started.md) for details
- Open an issue on GitHub
- Review the code - it's well-commented!
---
**You're ready to go!** Start exploring and building. 🚀
+162
View File
@@ -0,0 +1,162 @@
# Autopilot Monitor
Advanced monitoring and troubleshooting solution for Windows Autopilot deployments.
## Overview
Autopilot Monitor provides real-time tracking, intelligent diagnostics, and automated troubleshooting for Windows Autopilot enrollment processes. It consists of:
- **Bootstrap Script** - PowerShell script deployed via Intune that starts monitoring early in the enrollment process
- **Monitoring Agent** - Lightweight .NET Windows Service that collects telemetry and evidence
- **Backend API** - Azure Functions-based ingestion and processing pipeline
- **Web Dashboard** - Next.js application for real-time monitoring and fleet analytics
## Architecture
```
Device Side Azure Backend Web UI
┌──────────────┐ ┌──────────────┐ ┌──────────┐
│ Bootstrap │──deploys──▶ │ │ │ │
│ Script │ │ Azure │◀───────────▶ │ Next.js │
└──────┬───────┘ │ Functions │ │ Web App │
│ │ │ │ │
▼ │ • Ingest │ └──────────┘
┌──────────────┐ │ • Store │
│ Monitoring │──telemetry──▶ │ • Analyze │
│ Agent │ (mTLS) │ │
└──────────────┘ └──────┬───────┘
│
▼
┌──────────────┐
│ Azure Table │
│ Azure Blob │
│ Azure SQL │
└──────────────┘
```
## Project Structure
```
Autopilot-Monitor/
├── src/
│ ├── Agent/ # .NET monitoring agent
│ │ ├── AutopilotMonitor.Agent/ # Windows Service host
│ │ └── AutopilotMonitor.Agent.Core/ # Core monitoring logic
│ ├── Backend/ # Azure Functions
│ │ └── AutopilotMonitor.Functions/
│ ├── Shared/ # Shared models/contracts
│ │ └── AutopilotMonitor.Shared/
│ └── Web/ # Frontend
│ └── autopilot-monitor-web/
├── scripts/
│ ├── Bootstrap/ # Intune deployment scripts
│ └── Deployment/ # Azure deployment scripts
├── rules/ # YAML rule definitions
└── docs/ # Documentation
```
## Phase 1 Goals (Current)
- [x] Project structure setup
- [ ] Bootstrap PowerShell script
- [ ] Basic .NET agent with log tailing
- [ ] Azure Functions ingestion API
- [ ] Session registration and event storage
- [ ] Minimal web UI (session list, timeline)
## Getting Started
### Prerequisites
- .NET Framework 4.8 SDK (for Agent)
- .NET 8 SDK (for Azure Functions)
- Node.js 18+ (for Web UI)
- Azure subscription
- Visual Studio 2022 or VS Code
### Development Setup
1. **Clone and restore packages**
```bash
git clone <your-repo>
cd Autopilot-Monitor
dotnet restore
```
2. **Set up local Azure Functions**
```bash
cd src/Backend/AutopilotMonitor.Functions
cp local.settings.json.template local.settings.json
# Edit local.settings.json with your Azure Storage connection string
```
3. **Set up web frontend**
```bash
cd src/Web/autopilot-monitor-web
npm install
cp .env.local.template .env.local
# Edit .env.local with your API endpoint
```
4. **Build the agent**
```bash
cd src/Agent/AutopilotMonitor.Agent
dotnet build
```
### Running Locally
1. **Start Azure Functions** (in one terminal)
```bash
cd src/Backend/AutopilotMonitor.Functions
func start
```
2. **Start Web UI** (in another terminal)
```bash
cd src/Web/autopilot-monitor-web
npm run dev
```
3. **Test agent** (as Administrator)
```bash
cd src/Agent/AutopilotMonitor.Agent/bin/Debug
.\AutopilotMonitor.Agent.exe --console
```
## Documentation
- [Architecture Overview](docs/architecture.md)
- [Agent Development](docs/agent-development.md)
- [Rule Engine](docs/rule-engine.md)
- [Deployment Guide](docs/deployment.md)
## Contributing
We welcome community contributions! See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
## License
MIT License - see [LICENSE](LICENSE) for details
## Roadmap
### Phase 1: Foundation (Current)
- Bootstrap and basic agent
- Simple ingestion pipeline
- Minimal UI
### Phase 2: Intelligence
- Rule engine
- Diagnosis panel
- Troubleshooting bundles
### Phase 3: Polish
- Fleet dashboard
- User-facing portal
- Multi-tenant support
### Phase 4: Advanced
- Pre-provisioning support
- Custom rule authoring
- Integration APIs
+169
View File
@@ -0,0 +1,169 @@
<#
.SYNOPSIS
Build script for Autopilot Monitor solution.
.DESCRIPTION
Builds all .NET projects and optionally the web UI.
Run this after cloning or when you want to rebuild everything.
.PARAMETER Configuration
Build configuration: Debug or Release (default: Debug)
.PARAMETER IncludeWeb
Also build the web UI (requires Node.js)
.PARAMETER Clean
Clean before building
.EXAMPLE
.\build.ps1
Builds all .NET projects in Debug mode
.EXAMPLE
.\build.ps1 -Configuration Release -IncludeWeb
Builds everything in Release mode including web UI
#>
[CmdletBinding()]
param(
[ValidateSet("Debug", "Release")]
[string]$Configuration = "Debug",
[switch]$IncludeWeb,
[switch]$Clean
)
$ErrorActionPreference = "Stop"
Write-Host "============================================" -ForegroundColor Cyan
Write-Host " Autopilot Monitor Build Script" -ForegroundColor Cyan
Write-Host "============================================" -ForegroundColor Cyan
Write-Host ""
$rootPath = $PSScriptRoot
$slnPath = Join-Path $rootPath "AutopilotMonitor.sln"
# Check prerequisites
Write-Host "[1/4] Checking prerequisites..." -ForegroundColor Yellow
# Check .NET SDK
try {
$dotnetVersion = & dotnet --version
Write-Host " ✓ .NET SDK version: $dotnetVersion" -ForegroundColor Green
}
catch {
Write-Host " ✗ .NET SDK not found. Please install .NET 8 SDK." -ForegroundColor Red
exit 1
}
# Check Node.js if building web
if ($IncludeWeb) {
try {
$nodeVersion = & node --version
Write-Host " ✓ Node.js version: $nodeVersion" -ForegroundColor Green
}
catch {
Write-Host " ✗ Node.js not found. Please install Node.js 18+." -ForegroundColor Red
exit 1
}
}
Write-Host ""
# Clean if requested
if ($Clean) {
Write-Host "[2/4] Cleaning solution..." -ForegroundColor Yellow
& dotnet clean $slnPath -c $Configuration
Write-Host " ✓ Clean completed" -ForegroundColor Green
Write-Host ""
}
else {
Write-Host "[2/4] Skipping clean (use -Clean to clean first)" -ForegroundColor Gray
Write-Host ""
}
# Restore packages
Write-Host "[3/4] Restoring NuGet packages..." -ForegroundColor Yellow
& dotnet restore $slnPath
if ($LASTEXITCODE -ne 0) {
Write-Host " ✗ Restore failed" -ForegroundColor Red
exit 1
}
Write-Host " ✓ Restore completed" -ForegroundColor Green
Write-Host ""
# Build solution
Write-Host "[4/4] Building solution ($Configuration)..." -ForegroundColor Yellow
& dotnet build $slnPath -c $Configuration --no-restore
if ($LASTEXITCODE -ne 0) {
Write-Host " ✗ Build failed" -ForegroundColor Red
exit 1
}
Write-Host " ✓ Build completed" -ForegroundColor Green
Write-Host ""
# Build web if requested
if ($IncludeWeb) {
Write-Host "[BONUS] Building web UI..." -ForegroundColor Yellow
$webPath = Join-Path $rootPath "src\Web\autopilot-monitor-web"
Push-Location $webPath
try {
Write-Host " Installing npm packages..." -ForegroundColor Gray
& npm install --silent
if ($LASTEXITCODE -ne 0) {
Write-Host " ✗ npm install failed" -ForegroundColor Red
exit 1
}
Write-Host " Building Next.js app..." -ForegroundColor Gray
& npm run build
if ($LASTEXITCODE -ne 0) {
Write-Host " ✗ Web build failed" -ForegroundColor Red
exit 1
}
Write-Host " ✓ Web build completed" -ForegroundColor Green
}
finally {
Pop-Location
}
Write-Host ""
}
# Summary
Write-Host "============================================" -ForegroundColor Cyan
Write-Host " Build Completed Successfully!" -ForegroundColor Green
Write-Host "============================================" -ForegroundColor Cyan
Write-Host ""
Write-Host "Output locations:" -ForegroundColor White
Write-Host " Agent: src\Agent\AutopilotMonitor.Agent\bin\$Configuration\net48\" -ForegroundColor Gray
Write-Host " Functions: src\Backend\AutopilotMonitor.Functions\bin\$Configuration\net8.0\" -ForegroundColor Gray
if ($IncludeWeb) {
Write-Host " Web UI: src\Web\autopilot-monitor-web\.next\" -ForegroundColor Gray
}
Write-Host ""
Write-Host "Next steps:" -ForegroundColor White
Write-Host " 1. Start Azurite: azurite --silent --location c:\azurite" -ForegroundColor Gray
Write-Host " 2. Start Functions: cd src\Backend\AutopilotMonitor.Functions && func start" -ForegroundColor Gray
if ($IncludeWeb) {
Write-Host " 3. Start Web UI: cd src\Web\autopilot-monitor-web && npm run dev" -ForegroundColor Gray
Write-Host " 4. Test Agent: cd src\Agent\AutopilotMonitor.Agent\bin\$Configuration\net48 && .\AutopilotMonitor.Agent.exe --console" -ForegroundColor Gray
}
else {
Write-Host " 3. Test Agent: cd src\Agent\AutopilotMonitor.Agent\bin\$Configuration\net48 && .\AutopilotMonitor.Agent.exe --console" -ForegroundColor Gray
}
Write-Host ""
Write-Host "See QUICKSTART.md for detailed instructions." -ForegroundColor Cyan
Write-Host ""
+389
View File
@@ -0,0 +1,389 @@
# Architecture Overview
This document describes the technical architecture of Autopilot Monitor.
## High-Level Architecture
Autopilot Monitor consists of four main components:
```
┌─────────────────────────────────────────────────────────────────────────┐
│ DEVICE SIDE │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────┐ ┌──────────────────────────────────────────┐ │
│ │ Bootstrap.ps1 │────▶│ Monitoring Agent (.NET 4.8 Service) │ │
│ │ (Intune Script) │ │ │ │
│ │ │ │ • Log Watchers │ │
│ │ • Deploy agent │ │ • Event Collectors │ │
│ │ • Create session │ │ • Performance Monitors │ │
│ │ • Set correlation│ │ • Upload Manager │ │
│ └──────────────────┘ └─────────────┬────────────────────────────┘ │
│ │ │
└─────────────────────────────────────────┼───────────────────────────────┘
│ HTTPS (mTLS)
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ AZURE BACKEND │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ Azure Functions (.NET 8 Isolated) │ │
│ │ │ │
│ │ /api/sessions/register │ /api/events/ingest │ /api/bundles/* │ │
│ └────────────────┬────────────────────┬──────────────────┬───────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌─────────────────────┐ ┌──────────────────┐ ┌──────────────────┐ │
│ │ Azure Table │ │ Azure Table │ │ Azure Blob │ │
│ │ (Sessions) │ │ (Events) │ │ (Bundles/Logs) │ │
│ └─────────────────────┘ └──────────────────┘ └──────────────────┘ │
│ │
│ ┌────────────────────────┐ ┌───────────────────────────┐ │
│ │ Azure SignalR Service │──────────▶│ Next.js Web App │ │
│ │ (Real-time updates) │ │ (Static Web App) │ │
│ └────────────────────────┘ └───────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
```
## Component Details
### 1. Bootstrap Script (PowerShell)
**Purpose**: Deploy and configure the monitoring agent early in Autopilot enrollment.
**Responsibilities**:
- Generate unique session ID
- Collect device information
- Store configuration in registry
- Install/start monitoring agent
- Register session with backend
**Deployment**: Intune PowerShell Script (required, runs in Device ESP)
**Key Files**:
- [Install-AutopilotMonitor.ps1](../scripts/Bootstrap/Install-AutopilotMonitor.ps1)
### 2. Monitoring Agent (.NET Framework 4.8)
**Purpose**: Continuously monitor enrollment progress and collect telemetry.
**Architecture**:
```
AutopilotMonitor.Agent (Exe)
├── Program.cs (Entry point, console or service mode)
└── AutopilotMonitorService.cs (Windows Service wrapper)
│
└── AutopilotMonitor.Agent.Core (Library)
├── Monitoring/
│ ├── MonitoringService.cs (Main orchestrator)
│ ├── LogWatcher.cs (Tail IME logs)
│ ├── EventMonitor.cs (Watch Windows Event Log)
│ └── PerformanceMonitor.cs (CPU, memory, disk)
│
├── Storage/
│ └── EventSpool.cs (Offline queue)
│
├── Api/
│ └── BackendApiClient.cs (HTTP client for API calls)
│
├── Configuration/
│ └── AgentConfiguration.cs (Settings)
│
└── Logging/
└── AgentLogger.cs (Simple file logger)
```
**Key Features**:
- Runs as Windows Service (LocalSystem account)
- Offline spool for resilient upload
- Batched uploads (configurable interval)
- Exponential backoff on failures
- Low memory footprint (<50MB)
**Data Flow**:
1. Watchers detect events (logs, event log, performance)
2. Events normalized into `EnrollmentEvent` objects
3. Events added to local spool (file-based queue)
4. Periodic upload timer sends batches to backend
5. On success, events removed from spool
**Key Files**:
- [MonitoringService.cs](../src/Agent/AutopilotMonitor.Agent.Core/Monitoring/MonitoringService.cs)
- [EventSpool.cs](../src/Agent/AutopilotMonitor.Agent.Core/Storage/EventSpool.cs)
- [BackendApiClient.cs](../src/Agent/AutopilotMonitor.Agent.Core/Api/BackendApiClient.cs)
### 3. Backend API (Azure Functions)
**Purpose**: Ingest telemetry, store data, and serve web UI.
**Functions**:
#### RegisterSession
- **Route**: `POST /api/sessions/register`
- **Purpose**: Register a new enrollment session
- **Input**: `SessionRegistration` (device info, session ID)
- **Output**: `RegisterSessionResponse` (success, registered timestamp)
- **Storage**: Azure Table Storage (`sessions` table)
#### IngestEvents
- **Route**: `POST /api/events/ingest`
- **Purpose**: Receive batched events from agents
- **Input**: `IngestEventsRequest` (session ID, list of events)
- **Output**: `IngestEventsResponse` (events processed count)
- **Storage**: Azure Table Storage (`events` table)
- **Features**:
- Compression support (gzip)
- Batch processing
- Deduplication (by event ID)
#### UploadBundle
- **Route**: `POST /api/bundles/upload`
- **Purpose**: Get SAS URL for uploading troubleshooting bundle
- **Input**: `UploadBundleRequest` (session ID, file name)
- **Output**: `UploadBundleResponse` (SAS URL, expiry)
- **Storage**: Azure Blob Storage (`bundles` container)
**Authentication** (Future):
- Phase 1: Function key authentication
- Phase 2+: Client certificate (mTLS) using Intune MDM cert
**Key Files**:
- [RegisterSessionFunction.cs](../src/Backend/AutopilotMonitor.Functions/Functions/RegisterSessionFunction.cs)
- [IngestEventsFunction.cs](../src/Backend/AutopilotMonitor.Functions/Functions/IngestEventsFunction.cs)
### 4. Web Dashboard (Next.js)
**Purpose**: Real-time monitoring and troubleshooting UI.
**Technology Stack**:
- Next.js 15 (React 19)
- TypeScript
- Tailwind CSS
- Azure Static Web Apps (deployment)
**Key Pages** (Planned):
```
/ (Dashboard)
├── Active sessions grid
├── Fleet health metrics
└── Recent failures
/sessions/[id] (Session Detail)
├── Phase timeline
├── Event stream
├── Diagnosis panel
└── Download bundle
/sessions (Session List)
├── Filterable table
├── Search by serial/device name
└── Status indicators
/fleet (Fleet Analytics)
├── Success rate trends
├── Failure reason distribution
├── Model/OS breakdown
└── App install leaderboard
```
**Key Files**:
- [app/page.tsx](../src/Web/autopilot-monitor-web/app/page.tsx) (Dashboard)
- [app/layout.tsx](../src/Web/autopilot-monitor-web/app/layout.tsx) (Root layout)
### 5. Shared Library
**Purpose**: Common models and constants across all .NET projects.
**Key Models**:
- `EnrollmentEvent` - Single telemetry event
- `EnrollmentPhase` - Enrollment phase enumeration
- `SessionRegistration` - Device and session info
- `SessionSummary` - Session overview for UI
- `ApiModels` - Request/response contracts
**Key Files**:
- [Models/](../src/Shared/AutopilotMonitor.Shared/Models/)
- [Constants.cs](../src/Shared/AutopilotMonitor.Shared/Constants.cs)
## Data Models
### Azure Table Storage Schema
#### Sessions Table
| Property | Type | Description |
|----------|------|-------------|
| PartitionKey | string | `{TenantId}` |
| RowKey | string | `{SessionId}` |
| SerialNumber | string | Device serial number |
| Manufacturer | string | Device manufacturer |
| Model | string | Device model |
| DeviceName | string | Computer name |
| StartedAt | DateTime | Enrollment start time (UTC) |
| CompletedAt | DateTime? | Enrollment end time (UTC) |
| CurrentPhase | int | Current enrollment phase |
| Status | string | InProgress, Succeeded, Failed |
| EventCount | int | Total events received |
#### Events Table
| Property | Type | Description |
|----------|------|-------------|
| PartitionKey | string | `{TenantId}_{SessionId}` |
| RowKey | string | `{Timestamp}_{Sequence}` |
| EventId | string | Unique event identifier (GUID) |
| EventType | string | Type of event |
| Severity | int | Debug, Info, Warning, Error, Critical |
| Source | string | Where the event originated |
| Phase | int | Phase during which event occurred |
| Message | string | Human-readable message |
| Data | string | JSON-serialized additional data |
### Azure Blob Storage Structure
```
bundles/
{tenant-id}/
{session-id}/
troubleshooting-bundle.zip
logs/
agent.log
ime.log
events/
events.json
diagnostics/
dsregcmd.txt
network.txt
screenshots/
{tenant-id}/
{session-id}/
{timestamp}.jpg
```
## Security Architecture
### Phase 1 (Current)
- Function key authentication (keys in URL)
- No encryption at rest (using Azure defaults)
- HTTPS for transport
### Phase 2+ (Planned)
- Client certificate authentication (mTLS)
- Leverage Intune MDM device certificate
- No secrets on device
- Per-device authentication
- Azure AD authentication for web UI
- Multi-tenant isolation
- Encryption at rest (Azure Storage encryption)
- PII redaction policies
## Scalability Considerations
### Current Capacity (Phase 1)
- ~1,000 devices/month: ~$15/month Azure cost
- ~10,000 devices/month: ~$50-100/month Azure cost
### Storage Scaling
**Azure Table Storage**:
- Massively scalable (billions of entities)
- Auto-sharding by PartitionKey
- PartitionKey strategy: `{TenantId}` for sessions, `{TenantId}_{SessionId}` for events
- Supports 20,000 entities/sec per table
**Azure Blob Storage**:
- Unlimited storage
- Auto-tiering (Hot → Cool → Archive)
- Lifecycle policies for cost optimization
### Compute Scaling
**Azure Functions**:
- Consumption plan: Auto-scale to demand
- Isolated worker process model (better performance)
- Can handle 1000s of concurrent requests
**Web UI (Static Web Apps)**:
- Global CDN distribution
- Scales automatically
- Near-zero cost for read operations
## Monitoring and Observability
### Application Insights Integration
All components log to Azure Application Insights:
- **Agent**: Custom telemetry via API
- **Functions**: Built-in integration
- **Web**: Custom events and page views
### Metrics to Track
- Event ingestion rate (events/sec)
- API response times (p50, p95, p99)
- Failed uploads (count, reasons)
- Storage utilization (GB, transactions)
- Active sessions (count)
- Session success rate (%)
## Cost Optimization Strategies
1. **Use Azure Table Storage** instead of Cosmos DB (99% cheaper)
2. **Batch events** before upload (reduce transactions)
3. **Compress payloads** (reduce bandwidth)
4. **Lifecycle policies** for blob storage (hot → cool → archive)
5. **Summarize aggressively** (don't store every raw log line)
6. **Cache static content** (CDN for web UI)
7. **Use consumption plans** (pay per use, not reserved capacity)
## Future Architecture Enhancements
### Phase 2: Intelligence
- Rule engine (YAML-based, server-side evaluation)
- Real-time SignalR for live updates
- Troubleshooting bundle generation
### Phase 3: Advanced
- Pre-provisioning support
- Custom rule authoring UI
- REST API for integrations
- Webhook notifications
- Teams/Slack integration
### Phase 4: Enterprise
- Multi-tenant SaaS model
- Tenant-level isolation
- RBAC and permissions
- Audit logging
- Compliance controls (GDPR, etc.)
## Development Best Practices
### Code Organization
- Keep shared models in `AutopilotMonitor.Shared`
- Agent logic in `AutopilotMonitor.Agent.Core` (testable)
- Thin wrappers for service/console in `AutopilotMonitor.Agent`
- Stateless Azure Functions (no local state)
### Error Handling
- Agent: Never crash, always spool locally
- Functions: Return proper HTTP status codes
- Web: Graceful degradation, show cached data
### Testing
- Unit tests for core logic
- Integration tests for API endpoints
- End-to-end tests for critical flows
- Load testing for scalability validation
### Deployment
- CI/CD with GitHub Actions
- Separate environments (dev, staging, prod)
- Infrastructure as Code (Bicep/ARM templates)
- Automated rollback on failures
+246
View File
@@ -0,0 +1,246 @@
# Getting Started with Autopilot Monitor
This guide will help you set up and run Autopilot Monitor locally for development and testing.
## Prerequisites
### Required
- Windows 10/11 (for agent development and testing)
- .NET Framework 4.8 SDK
- .NET 8 SDK
- Visual Studio 2022 or VS Code with C# extensions
- Node.js 18+ and npm
- Azure Storage Emulator or Azurite (for local storage)
- Azure Functions Core Tools v4
### Optional
- Git
- PowerShell 5.1 or later
- Microsoft Intune tenant (for production deployment)
## Quick Start (5 Minutes)
### 1. Clone and Build
```bash
# Clone the repository
git clone <your-repo-url>
cd Autopilot-Monitor
# Restore and build all .NET projects
dotnet restore
dotnet build
```
### 2. Start Local Storage
Install and start Azurite (Azure Storage Emulator):
```bash
npm install -g azurite
azurite --silent --location c:\azurite --debug c:\azurite\debug.log
```
Or use the Azure Storage Emulator if you have it installed.
### 3. Start Azure Functions Backend
```bash
cd src/Backend/AutopilotMonitor.Functions
func start
```
The API will be available at `http://localhost:7071`
### 4. Start Web UI
```bash
cd src/Web/autopilot-monitor-web
# Install dependencies (first time only)
npm install
# Copy environment template
cp .env.local.template .env.local
# Start development server
npm run dev
```
The web UI will be available at `http://localhost:3000`
### 5. Test the Agent
Open a new PowerShell window **as Administrator**:
```powershell
cd src/Agent/AutopilotMonitor.Agent/bin/Debug/net48
.\AutopilotMonitor.Agent.exe --console
```
You should see the agent start and begin sending telemetry to the backend.
## Detailed Setup
### Backend Configuration
The Azure Functions use `local.settings.json` for configuration. This file is created from the template:
```json
{
"IsEncrypted": false,
"Values": {
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
"FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated",
"AzureTableStorageConnectionString": "UseDevelopmentStorage=true",
"AzureBlobStorageConnectionString": "UseDevelopmentStorage=true"
}
}
```
For production, replace `UseDevelopmentStorage=true` with your Azure Storage connection strings.
### Agent Configuration
The agent reads configuration from the Windows Registry (set by the bootstrap script) or falls back to defaults. For testing, you can modify [Program.cs](../src/Agent/AutopilotMonitor.Agent/Program.cs):
```csharp
return new AgentConfiguration
{
ApiBaseUrl = "http://localhost:7071",
SessionId = Guid.NewGuid().ToString(),
TenantId = "test-tenant",
// ... other settings
};
```
### Web UI Configuration
Create `.env.local` in the web project:
```env
NEXT_PUBLIC_API_BASE_URL=http://localhost:7071
```
This tells the web UI where to find the backend API.
## Testing End-to-End
### 1. Verify Backend is Running
Open a browser and navigate to:
```
http://localhost:7071/api/sessions/register
```
You should see a response (even if it's an error, it confirms the function is running).
### 2. Run the Agent
Start the agent in console mode (as Administrator):
```powershell
cd src/Agent/AutopilotMonitor.Agent/bin/Debug/net48
.\AutopilotMonitor.Agent.exe --console
```
### 3. Check Logs
Agent logs are written to:
```
C:\ProgramData\AutopilotMonitor\Logs\agent_YYYYMMDD.log
```
Backend logs appear in the Functions console output.
### 4. View in Web UI
Open `http://localhost:3000` and you should see the dashboard (currently shows placeholder data in Phase 1).
## Common Issues
### Azure Functions won't start
**Error**: `Missing value for AzureWebJobsStorage`
**Solution**: Ensure Azurite or Azure Storage Emulator is running, or update `local.settings.json` with valid connection strings.
### Agent can't connect to API
**Error**: `Error uploading events: The remote name could not be resolved`
**Solution**:
1. Verify Azure Functions is running at `http://localhost:7071`
2. Check Windows Firewall isn't blocking the connection
3. Update agent configuration with correct API URL
### Web UI shows blank page
**Solution**:
1. Check browser console for errors
2. Verify `npm run dev` completed without errors
3. Check `.env.local` exists and has correct API URL
### Permission Errors
Many agent operations require Administrator privileges:
- Reading certain event logs
- Writing to Program Files
- Installing as a Windows Service
Always run the agent as Administrator during development.
## Development Workflow
### Typical Development Session
1. **Start infrastructure** (Azurite, Functions)
```bash
# Terminal 1: Azurite
azurite --silent --location c:\azurite
# Terminal 2: Functions
cd src/Backend/AutopilotMonitor.Functions
func start
# Terminal 3: Web UI
cd src/Web/autopilot-monitor-web
npm run dev
```
2. **Make changes** to agent, backend, or web code
3. **Test changes**
- Agent: Rebuild and run in console mode
- Backend: Hot reload should pick up changes
- Web: Hot reload should pick up changes
4. **Debug**
- Agent: Attach Visual Studio debugger or use console output
- Backend: Use Functions console logs or attach debugger
- Web: Use browser DevTools
### Building for Release
```bash
# Build all projects in Release mode
dotnet build -c Release
# Build web UI for production
cd src/Web/autopilot-monitor-web
npm run build
```
## Next Steps
- Read the [Architecture Overview](architecture.md)
- Learn about [Deploying to Azure](deployment.md)
- Explore the [API Documentation](api-reference.md)
- Understand the [Rule Engine](rule-engine.md) (Phase 2)
## Getting Help
- Check the [README](../README.md) for general information
- Review logs in `C:\ProgramData\AutopilotMonitor\Logs\`
- Check Azure Functions output for backend errors
- Use browser DevTools for web UI issues
+139
View File
@@ -0,0 +1,139 @@
# Network Rule: Proxy Authentication Failure
# This is an example rule for the Phase 2 rule engine
id: NET-001
title: "Proxy Authentication Required"
severity: high
category: network
version: 1.0
author: "Autopilot Monitor Team"
description: |
Detects when a device is behind a proxy that requires authentication,
but the SYSTEM account (used during Autopilot enrollment) cannot authenticate.
This is one of the most common causes of enrollment failures in enterprise networks.
conditions:
# Primary signal: HTTP 407 errors from WinHTTP
- signal: "event_407_detected"
source: "windows_events"
provider: "Microsoft-Windows-WinHttp"
event_id: 407
minimum_occurrences: 1
# Supporting signal: Proxy is configured
- signal: "winhttp_proxy_configured"
source: "registry"
path: "HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Internet Settings"
value: "ProxyEnable"
expected: 1
# Supporting signal: Enrollment stalled at MDM phase
- signal: "enrollment_stalled_at_mdm"
source: "phase_tracking"
phase: "MdmEnrollment"
duration_exceeds_seconds: 300
evidence_selectors:
# Collect relevant log lines from IME log
- type: "log_lines"
source: "ime_log"
pattern: "proxy|407|authentication|ProxyAuthenticationRequired"
context_lines: 3
max_lines: 20
# Collect proxy configuration from registry
- type: "registry_value"
path: "HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Internet Settings"
values:
- "ProxyServer"
- "ProxyEnable"
- "ProxyOverride"
# Collect WinHTTP events
- type: "windows_event"
provider: "Microsoft-Windows-WinHttp"
event_ids: [407, 12029, 12032]
max_events: 10
explanation: |
The device is configured to use a proxy server that requires authentication.
During Autopilot enrollment, many operations run under the SYSTEM account,
which cannot provide user credentials to authenticate with the proxy.
This causes connections to Microsoft Intune endpoints to fail, resulting in
enrollment timeouts or failures.
Common indicators:
- HTTP 407 (Proxy Authentication Required) errors in logs
- Enrollment stalls at the MDM Enrollment phase
- WinHTTP errors about proxy authentication
remediation:
- title: "Configure proxy bypass"
steps:
- "Add *.manage.microsoft.com to proxy bypass list"
- "Add *.microsoftonline.com to proxy bypass list"
- "Add enterpriseregistration.windows.net to bypass list"
- title: "Use PAC file"
steps:
- "Create/update PAC file to bypass proxy for Microsoft endpoints"
- "Deploy via GPO or Intune"
- title: "Use authenticated proxy with certificate"
steps:
- "Configure proxy to trust device certificates"
- "Use certificate-based authentication instead of user credentials"
related_docs:
- url: "https://learn.microsoft.com/en-us/mem/intune/enrollment/windows-enrollment-network-requirements"
title: "Intune network requirements"
- url: "https://learn.microsoft.com/en-us/mem/autopilot/networking-requirements"
title: "Autopilot networking requirements"
confidence_scoring:
# Base confidence if just HTTP 407 is detected
base: 50
# Additional confidence based on other signals
factors:
# Proxy is actually configured (not direct connection)
- signal: "winhttp_proxy_configured"
weight: 20
# Enrollment is actually stalled (not just slow)
- signal: "enrollment_stalled_at_mdm"
weight: 30
# Multiple 407 errors (not just one transient issue)
- signal: "multiple_407_errors"
condition: "count >= 5"
weight: 10
tags:
- network
- proxy
- authentication
- common
- mdm-enrollment
# Test data (for validation)
test_cases:
- name: "Proxy auth with bypass configured"
expected: false
evidence:
- event_407_detected: false
- winhttp_proxy_configured: true
- name: "Direct connection with 407"
expected: false
evidence:
- event_407_detected: true
- winhttp_proxy_configured: false
- name: "Classic proxy auth failure"
expected: true
evidence:
- event_407_detected: true
- winhttp_proxy_configured: true
- enrollment_stalled_at_mdm: true
@@ -0,0 +1,192 @@
<#
.SYNOPSIS
Bootstrap script to deploy and start the Autopilot Monitor agent.
.DESCRIPTION
This script is designed to be deployed via Intune as a PowerShell Script during Autopilot.
It runs VERY EARLY in the enrollment process (first Intune action) and:
1. Creates a unique session ID for this enrollment
2. Downloads the monitoring agent binaries
3. Configures the agent (filesystem only - NO registry)
4. Registers agent as Scheduled Task (runs on computer startup)
5. Agent self-destructs when enrollment completes
.PARAMETER ApiBaseUrl
The base URL of the backend API (default: http://localhost:7071 for local testing)
Production example: https://autopilot-api.azurewebsites.net
.PARAMETER AgentDownloadUrl
URL to download the agent binaries from (ZIP file)
.EXAMPLE
.\Install-AutopilotMonitor.ps1
(Uses default local API for testing)
.EXAMPLE
.\Install-AutopilotMonitor.ps1 -ApiBaseUrl "https://autopilot-api.azurewebsites.net"
.NOTES
- Agent is temporary and auto-removes after enrollment
- Everything in C:\ProgramData\AutopilotMonitor (easy cleanup)
- No Registry entries (clean machine after removal)
- Scheduled Task survives reboots during enrollment
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $false)]
[string]$ApiBaseUrl = "http://localhost:7071",
[Parameter(Mandatory = $false)]
[string]$AgentDownloadUrl = "https://yourcdn.blob.core.windows.net/agent/AutopilotMonitor-Agent.zip"
)
# Configuration - Everything in ProgramData for easy cleanup
$AgentBasePath = "$env:ProgramData\AutopilotMonitor"
$AgentBinPath = "$AgentBasePath\Agent"
$AgentConfigPath = "$AgentBasePath\Config"
$AgentRulesPath = "$AgentBasePath\Rules"
$AgentSpoolPath = "$AgentBasePath\Spool"
$AgentLogsPath = "$AgentBasePath\Logs"
$TaskName = "AutopilotMonitor-Agent"
$LogFile = "$AgentLogsPath\Bootstrap.log"
# Ensure directories exist
New-Item -Path $AgentBasePath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
New-Item -Path $AgentBinPath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
New-Item -Path $AgentConfigPath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
New-Item -Path $AgentRulesPath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
New-Item -Path $AgentSpoolPath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
New-Item -Path $AgentLogsPath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
function Write-Log {
param([string]$Message)
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
$logMessage = "[$timestamp] $Message"
Write-Output $logMessage
Add-Content -Path $LogFile -Value $logMessage -ErrorAction SilentlyContinue
}
try {
Write-Log "===== Autopilot Monitor Bootstrap Started ====="
Write-Log "API Base URL: $ApiBaseUrl"
# Create agent configuration (JSON - NO REGISTRY!)
Write-Log "Creating agent configuration..."
$agentConfig = @{
apiBaseUrl = $ApiBaseUrl
uploadIntervalSeconds = 30
cleanupOnExit = $true
selfDestructOnComplete = $true
}
$agentConfigJson = $agentConfig | ConvertTo-Json -Depth 10
$agentConfigFile = Join-Path $AgentConfigPath "agent-config.json"
Set-Content -Path $agentConfigFile -Value $agentConfigJson -Force
Write-Log "Configuration saved to $agentConfigFile"
# Download and extract agent binaries
$agentExePath = Join-Path $AgentBinPath "AutopilotMonitor.Agent.exe"
if (Test-Path $agentExePath) {
Write-Log "Agent already installed at $agentExePath"
}
else {
Write-Log "Downloading agent from $AgentDownloadUrl..."
try {
# Download agent ZIP
$zipPath = Join-Path $env:TEMP "AutopilotMonitor-Agent.zip"
Invoke-WebRequest -Uri $AgentDownloadUrl -OutFile $zipPath -UseBasicParsing -TimeoutSec 120
Write-Log "Downloaded agent to $zipPath"
# Extract to agent bin path
Expand-Archive -Path $zipPath -DestinationPath $AgentBinPath -Force
Write-Log "Extracted agent to $AgentBinPath"
# Cleanup
Remove-Item $zipPath -Force -ErrorAction SilentlyContinue
Write-Log "Cleaned up temporary files"
# Verify extraction
if (-not (Test-Path $agentExePath)) {
throw "Agent executable not found after extraction at $agentExePath"
}
Write-Log "Agent installation completed successfully"
}
catch {
Write-Log "ERROR downloading/extracting agent: $($_.Exception.Message)"
throw
}
}
# Register as Scheduled Task (survives reboots, easy to remove)
Write-Log "Registering Scheduled Task..."
# Check if task already exists
$existingTask = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
if ($null -ne $existingTask) {
Write-Log "Scheduled Task already exists - removing old task"
Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false
}
# Create action - run agent executable
$action = New-ScheduledTaskAction -Execute $agentExePath -WorkingDirectory $AgentBinPath
# Create trigger - run at computer startup
$trigger = New-ScheduledTaskTrigger -AtStartup
# Create settings - run whether user is logged on or not
$settings = New-ScheduledTaskSettingsSet `
-AllowStartIfOnBatteries `
-DontStopIfGoingOnBatteries `
-StartWhenAvailable `
-RunOnlyIfNetworkAvailable `
-ExecutionTimeLimit (New-TimeSpan -Hours 0) # No time limit
# Create principal - run as SYSTEM
$principal = New-ScheduledTaskPrincipal -UserId "SYSTEM" -LogonType ServiceAccount -RunLevel Highest
# Register the task
Register-ScheduledTask `
-TaskName $TaskName `
-Action $action `
-Trigger $trigger `
-Settings $settings `
-Principal $principal `
-Description "Autopilot Monitor Agent - Temporary enrollment monitoring" `
-Force
Write-Log "Scheduled Task registered successfully"
# Start the task immediately (don't wait for next reboot)
Write-Log "Starting agent task..."
Start-ScheduledTask -TaskName $TaskName
Write-Log "Agent task started"
Write-Log "===== Bootstrap Completed Successfully ====="
Write-Log "Agent Path: $AgentBasePath"
Write-Log "Scheduled Task: $TaskName"
Write-Log "API Base URL: $ApiBaseUrl"
Write-Log "Agent will monitor enrollment and auto-remove when complete"
Write-Log "Bootstrap log: $LogFile"
Write-Log ""
Write-Log "IMPORTANT: Agent runs as Scheduled Task and will:"
Write-Log " - Generate unique session ID on startup"
Write-Log " - Survive reboots during enrollment"
Write-Log " - Monitor enrollment phases in real-time"
Write-Log " - Upload events to $ApiBaseUrl"
Write-Log " - Self-destruct when enrollment completes"
Write-Log " - Clean removal: delete C:\ProgramData\AutopilotMonitor folder"
exit 0
}
catch {
Write-Log "===== Bootstrap FAILED ====="
Write-Log "ERROR: $($_.Exception.Message)"
Write-Log "Stack trace: $($_.ScriptStackTrace)"
Write-Log "Please check log file: $LogFile"
exit 1
}
+107
View File
@@ -0,0 +1,107 @@
# Bootstrap Scripts
## Install-AutopilotMonitor.ps1
This PowerShell script is designed to be deployed via Microsoft Intune as a PowerShell Script during the Autopilot enrollment process.
### Purpose
The bootstrap script:
1. Generates a unique session ID for tracking this enrollment
2. Collects device information (serial number, manufacturer, model, etc.)
3. Stores configuration in the registry
4. Installs and starts the Autopilot Monitor agent service
### Deployment via Intune
1. **Navigate to Intune Portal**
- Go to **Devices** > **Scripts** > **Add** > **Windows 10 and later**
2. **Script Settings**
- **Name**: `Autopilot Monitor Bootstrap`
- **Description**: `Deploys and configures the Autopilot Monitor agent`
- **Script location**: Upload `Install-AutopilotMonitor.ps1`
- **Run this script using the logged-on credentials**: `No` (run as SYSTEM)
- **Enforce script signature check**: `No` (or sign the script if required)
- **Run script in 64-bit PowerShell**: `Yes`
3. **Script Parameters**
You can modify the script to include your API URL and Tenant ID, or use Intune's parameter feature:
```powershell
-ApiBaseUrl "https://your-api.azurewebsites.net" -TenantId "your-tenant-id"
```
4. **Assignment**
- Assign to your Autopilot device group
- **Important**: Scripts run during the **Device ESP** phase, which is early enough for monitoring
### Manual Testing
To test the script locally:
```powershell
# Run as Administrator
.\Install-AutopilotMonitor.ps1 -ApiBaseUrl "http://localhost:7071" -TenantId "test-tenant"
```
### Log Location
The script logs all activities to:
```
C:\ProgramData\AutopilotMonitor\Bootstrap.log
```
### Registry Configuration
The script stores configuration in:
```
HKLM:\SOFTWARE\AutopilotMonitor
```
Values stored:
- `SessionId` - Unique GUID for this enrollment session
- `TenantId` - Your tenant identifier
- `ApiBaseUrl` - Backend API endpoint
- `SerialNumber` - Device serial number
- `InstallDate` - When the bootstrap ran
### Phase 1 Limitations
In Phase 1, the script assumes the agent binaries are already present or manually deployed. Automatic download and installation will be added in a future phase.
For now, you can:
1. Pre-deploy the agent to `C:\Program Files\AutopilotMonitor\`
2. Manually install the service before running the bootstrap
3. Or modify the script to download from your own CDN/blob storage
### Service Installation
To manually install the agent as a Windows Service:
```powershell
# Build the agent first
cd src\Agent\AutopilotMonitor.Agent
dotnet build -c Release
# Install service (run as Administrator)
sc.exe create "AutopilotMonitor" binPath= "C:\Program Files\AutopilotMonitor\AutopilotMonitor.Agent.exe" start= auto
sc.exe description "AutopilotMonitor" "Monitors and reports Windows Autopilot enrollment progress"
sc.exe start "AutopilotMonitor"
```
### Troubleshooting
1. **Script fails to run in Intune**
- Check the Intune script execution logs
- Verify the device is running Windows 10/11
- Ensure no conflicting policies block script execution
2. **Service doesn't start**
- Check `C:\ProgramData\AutopilotMonitor\Logs\agent_*.log`
- Verify the agent executable exists at the expected path
- Check Windows Event Viewer for service startup errors
3. **Configuration not saved**
- Verify the script ran with SYSTEM privileges
- Check if registry path creation succeeded
- Review the bootstrap log for errors
@@ -0,0 +1,73 @@
<#
.SYNOPSIS
Cleanup script to manually remove the Autopilot Monitor agent.
.DESCRIPTION
This script removes all traces of the Autopilot Monitor agent:
- Stops and removes the Scheduled Task
- Deletes C:\ProgramData\AutopilotMonitor folder
Normally the agent self-destructs after enrollment completion.
Use this script only for manual cleanup or troubleshooting.
.EXAMPLE
.\Uninstall-AutopilotMonitor.ps1
.NOTES
- Requires Administrator privileges
- Safe to run multiple times
- No Registry cleanup needed (agent doesn't use Registry)
#>
[CmdletBinding()]
param()
$TaskName = "AutopilotMonitor-Agent"
$AgentBasePath = "$env:ProgramData\AutopilotMonitor"
Write-Host "===== Autopilot Monitor Cleanup Started =====" -ForegroundColor Cyan
# Stop and remove Scheduled Task
try {
$task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
if ($null -ne $task) {
Write-Host "Stopping Scheduled Task..." -ForegroundColor Yellow
Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
Write-Host "Removing Scheduled Task..." -ForegroundColor Yellow
Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction Stop
Write-Host " ✓ Scheduled Task removed" -ForegroundColor Green
}
else {
Write-Host " → Scheduled Task not found (already removed)" -ForegroundColor Gray
}
}
catch {
Write-Host " ✗ Error removing Scheduled Task: $($_.Exception.Message)" -ForegroundColor Red
}
# Delete agent folder
try {
if (Test-Path $AgentBasePath) {
Write-Host "Deleting agent folder: $AgentBasePath" -ForegroundColor Yellow
# Wait a moment to ensure agent has stopped
Start-Sleep -Seconds 2
Remove-Item -Path $AgentBasePath -Recurse -Force -ErrorAction Stop
Write-Host " ✓ Agent folder deleted" -ForegroundColor Green
}
else {
Write-Host " → Agent folder not found (already removed)" -ForegroundColor Gray
}
}
catch {
Write-Host " ✗ Error deleting folder: $($_.Exception.Message)" -ForegroundColor Red
Write-Host " → You may need to manually delete: $AgentBasePath" -ForegroundColor Yellow
}
Write-Host ""
Write-Host "===== Cleanup Completed =====" -ForegroundColor Cyan
Write-Host "Autopilot Monitor agent has been removed from this device." -ForegroundColor Green
Write-Host ""
@@ -0,0 +1,239 @@
using System;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Net.Http;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.Core.Configuration;
using AutopilotMonitor.Agent.Core.Security;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
using Newtonsoft.Json;
namespace AutopilotMonitor.Agent.Core.Api
{
/// <summary>
/// Client for communicating with the backend API
/// </summary>
public class BackendApiClient : IDisposable
{
private readonly HttpClient _httpClient;
private readonly string _baseUrl;
private readonly X509Certificate2 _clientCertificate;
private readonly string _manufacturer;
private readonly string _model;
private readonly string _serialNumber;
private readonly Logging.AgentLogger _logger;
public BackendApiClient(string baseUrl, AgentConfiguration configuration = null, Logging.AgentLogger logger = null)
{
_baseUrl = baseUrl.TrimEnd('/');
_logger = logger;
// Find MDM certificate for client authentication if enabled
if (configuration?.UseClientCertAuth == true)
{
logger?.Debug("Client certificate authentication enabled - searching for certificate...");
_clientCertificate = CertificateHelper.FindMdmCertificate(
configuration.ClientCertThumbprint,
logger
);
if (_clientCertificate != null)
{
logger?.Info($"Client certificate loaded successfully");
logger?.Debug($" Thumbprint: {_clientCertificate.Thumbprint}");
logger?.Debug($" Subject: {_clientCertificate.Subject}");
logger?.Debug($" Issuer: {_clientCertificate.Issuer}");
logger?.Debug($" Valid: {_clientCertificate.NotBefore:yyyy-MM-dd} to {_clientCertificate.NotAfter:yyyy-MM-dd}");
}
else
{
logger?.Warning("Client certificate authentication enabled but no certificate found");
logger?.Warning(" Request will be sent WITHOUT certificate (will likely fail security validation)");
}
}
else
{
logger?.Debug("Client certificate authentication disabled");
}
// Get hardware information for whitelist validation and serial number verification
var hardwareInfo = HardwareInfo.GetHardwareInfo(logger);
_manufacturer = hardwareInfo.Manufacturer;
_model = hardwareInfo.Model;
_serialNumber = hardwareInfo.SerialNumber;
_httpClient = new HttpClient
{
Timeout = TimeSpan.FromSeconds(30)
};
}
/// <summary>
/// Registers a new enrollment session
/// </summary>
public async Task<RegisterSessionResponse> RegisterSessionAsync(SessionRegistration registration)
{
var request = new RegisterSessionRequest { Registration = registration };
var url = $"{_baseUrl}{Constants.ApiEndpoints.RegisterSession}";
var response = await PostAsync<RegisterSessionRequest, RegisterSessionResponse>(url, request);
return response;
}
/// <summary>
/// Ingests a batch of events using NDJSON + gzip compression for bandwidth optimization
/// </summary>
public async Task<IngestEventsResponse> IngestEventsAsync(IngestEventsRequest request)
{
var url = $"{_baseUrl}{Constants.ApiEndpoints.IngestEvents}";
// Use NDJSON (newline-delimited JSON) + gzip for efficient event upload
// This reduces bandwidth significantly (70-80% compression typical for JSON)
var ndjson = CreateNdjson(request);
var compressedContent = CompressWithGzip(ndjson);
var httpRequest = new HttpRequestMessage(HttpMethod.Post, url)
{
Content = new ByteArrayContent(compressedContent)
};
httpRequest.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/x-ndjson");
httpRequest.Content.Headers.Add("Content-Encoding", "gzip");
// Add client certificate for device authentication (if available)
AddClientCertificateHeader(httpRequest);
var httpResponse = await _httpClient.SendAsync(httpRequest);
httpResponse.EnsureSuccessStatusCode();
var responseJson = await httpResponse.Content.ReadAsStringAsync();
var response = JsonConvert.DeserializeObject<IngestEventsResponse>(responseJson);
return response;
}
/// <summary>
/// Gets a SAS URL for uploading a troubleshooting bundle
/// </summary>
public async Task<UploadBundleResponse> GetBundleUploadUrlAsync(UploadBundleRequest request)
{
var url = $"{_baseUrl}{Constants.ApiEndpoints.UploadBundle}";
var response = await PostAsync<UploadBundleRequest, UploadBundleResponse>(url, request);
return response;
}
private async Task<TResponse> PostAsync<TRequest, TResponse>(string url, TRequest data)
{
var json = JsonConvert.SerializeObject(data);
var httpRequest = new HttpRequestMessage(HttpMethod.Post, url)
{
Content = new StringContent(json, Encoding.UTF8, "application/json")
};
// Add device identification headers for all API calls
AddClientCertificateHeader(httpRequest);
var response = await _httpClient.SendAsync(httpRequest);
response.EnsureSuccessStatusCode();
var responseJson = await response.Content.ReadAsStringAsync();
var result = JsonConvert.DeserializeObject<TResponse>(responseJson);
return result;
}
/// <summary>
/// Creates NDJSON (newline-delimited JSON) from IngestEventsRequest
/// Format: metadata line + one event per line
/// </summary>
private string CreateNdjson(IngestEventsRequest request)
{
var lines = new System.Collections.Generic.List<string>();
// First line: metadata (sessionId, tenantId)
var metadata = new
{
sessionId = request.SessionId,
tenantId = request.TenantId
};
lines.Add(JsonConvert.SerializeObject(metadata));
// Subsequent lines: one event per line
foreach (var evt in request.Events)
{
lines.Add(JsonConvert.SerializeObject(evt));
}
return string.Join("\n", lines);
}
/// <summary>
/// Compresses a string using gzip
/// </summary>
private byte[] CompressWithGzip(string text)
{
var bytes = Encoding.UTF8.GetBytes(text);
using (var output = new MemoryStream())
{
using (var gzip = new GZipStream(output, CompressionMode.Compress))
{
gzip.Write(bytes, 0, bytes.Length);
}
return output.ToArray();
}
}
/// <summary>
/// Adds device identification headers for authentication and authorization
/// Includes: Client certificate, manufacturer, model, serial number
/// </summary>
private void AddClientCertificateHeader(HttpRequestMessage request)
{
_logger?.Debug("Adding security headers to request...");
// Add client certificate for device authentication
if (_clientCertificate != null)
{
var certBytes = _clientCertificate.Export(X509ContentType.Cert);
var certBase64 = Convert.ToBase64String(certBytes);
request.Headers.Add("X-Client-Certificate", certBase64);
_logger?.Debug($" X-Client-Certificate: {_clientCertificate.Thumbprint.Substring(0, Math.Min(16, _clientCertificate.Thumbprint.Length))}...");
}
else
{
_logger?.Debug(" X-Client-Certificate: NOT SENT (no certificate available)");
}
// Add hardware information for whitelist validation
if (!string.IsNullOrEmpty(_manufacturer))
{
request.Headers.Add("X-Device-Manufacturer", _manufacturer);
_logger?.Debug($" X-Device-Manufacturer: {_manufacturer}");
}
if (!string.IsNullOrEmpty(_model))
{
request.Headers.Add("X-Device-Model", _model);
_logger?.Debug($" X-Device-Model: {_model}");
}
// TODO: Enable serial number validation when multi-tenant Graph API integration is ready
// This provides the strongest security by validating against Intune Autopilot registration
// if (!string.IsNullOrEmpty(_serialNumber))
// {
// request.Headers.Add("X-Device-SerialNumber", _serialNumber);
// }
}
public void Dispose()
{
_httpClient?.Dispose();
}
}
}
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net48</TargetFramework>
<LangVersion>latest</LangVersion>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\Shared\AutopilotMonitor.Shared\AutopilotMonitor.Shared.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
</ItemGroup>
<ItemGroup>
<Reference Include="System.Net.Http" />
<Reference Include="System.Management" />
</ItemGroup>
</Project>
@@ -0,0 +1,100 @@
using System;
namespace AutopilotMonitor.Agent.Core.Configuration
{
/// <summary>
/// Agent configuration settings
/// </summary>
public class AgentConfiguration
{
/// <summary>
/// Backend API base URL
/// </summary>
public string ApiBaseUrl { get; set; }
/// <summary>
/// Session identifier for this enrollment
/// </summary>
public string SessionId { get; set; }
/// <summary>
/// Tenant identifier
/// </summary>
public string TenantId { get; set; }
/// <summary>
/// Local spool directory path
/// </summary>
public string SpoolDirectory { get; set; }
/// <summary>
/// Agent log directory path
/// </summary>
public string LogDirectory { get; set; }
/// <summary>
/// Upload interval in seconds
/// </summary>
public int UploadIntervalSeconds { get; set; } = 30;
/// <summary>
/// Maximum batch size for events
/// </summary>
public int MaxBatchSize { get; set; } = 100;
/// <summary>
/// Whether to use client certificate authentication
/// </summary>
public bool UseClientCertAuth { get; set; } = false;
/// <summary>
/// Client certificate thumbprint (if using cert auth)
/// </summary>
public string ClientCertThumbprint { get; set; }
/// <summary>
/// Enable debug logging
/// </summary>
public bool EnableDebugLogging { get; set; } = false;
/// <summary>
/// Maximum retry attempts for failed uploads
/// </summary>
public int MaxRetryAttempts { get; set; } = 5;
/// <summary>
/// Enable Autopilot simulator for testing/demo (generates fake events)
/// </summary>
public bool EnableSimulator { get; set; } = false;
/// <summary>
/// Simulate enrollment failure (only used if EnableSimulator is true)
/// </summary>
public bool SimulateFailure { get; set; } = false;
/// <summary>
/// Whether to cleanup files on exit (delete C:\ProgramData\AutopilotMonitor)
/// </summary>
public bool CleanupOnExit { get; set; } = true;
/// <summary>
/// Whether to self-destruct when enrollment completes (remove Scheduled Task and all files)
/// </summary>
public bool SelfDestructOnComplete { get; set; } = true;
/// <summary>
/// Name of the Scheduled Task to remove during self-destruct
/// </summary>
public string ScheduledTaskName { get; set; } = "AutopilotMonitor-Agent";
/// <summary>
/// Validates the configuration
/// </summary>
public bool IsValid()
{
return !string.IsNullOrEmpty(ApiBaseUrl) &&
!string.IsNullOrEmpty(SessionId) &&
!string.IsNullOrEmpty(TenantId);
}
}
}
@@ -0,0 +1,290 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.Core.Logging;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Agent.Core.EventCollection
{
/// <summary>
/// Simulates a realistic Autopilot enrollment flow for testing and demo purposes
/// </summary>
public class AutopilotSimulator : IDisposable
{
private readonly AgentLogger _logger;
private readonly string _sessionId;
private readonly string _tenantId;
private readonly Action<EnrollmentEvent> _onEventCollected;
private readonly bool _simulateFailure;
private readonly Random _random = new Random();
private CancellationTokenSource _cancellationTokenSource;
private Task _simulationTask;
private EnrollmentPhase _currentPhase = EnrollmentPhase.PreFlight;
private bool _disposed = false;
public AutopilotSimulator(
string sessionId,
string tenantId,
Action<EnrollmentEvent> onEventCollected,
AgentLogger logger,
bool simulateFailure = false)
{
_sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
_tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
_onEventCollected = onEventCollected ?? throw new ArgumentNullException(nameof(onEventCollected));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_simulateFailure = simulateFailure;
}
/// <summary>
/// Starts the Autopilot simulation
/// </summary>
public void Start()
{
_logger.Info("Starting Autopilot Simulator");
_cancellationTokenSource = new CancellationTokenSource();
_simulationTask = Task.Run(() => RunSimulation(_cancellationTokenSource.Token));
}
/// <summary>
/// Stops the simulation
/// </summary>
public void Stop()
{
if (_cancellationTokenSource == null || _cancellationTokenSource.IsCancellationRequested)
return;
_logger.Info("Stopping Autopilot Simulator");
try
{
_cancellationTokenSource?.Cancel();
_simulationTask?.Wait(TimeSpan.FromSeconds(5));
}
catch (ObjectDisposedException)
{
// Already disposed, ignore
}
}
private async Task RunSimulation(CancellationToken cancellationToken)
{
try
{
_logger.Info("Starting simulated Autopilot enrollment flow");
// Phase 1: Network
await SimulatePhase(EnrollmentPhase.Network, cancellationToken);
await EmitPhaseEvents(EnrollmentPhase.Network, new[]
{
("network_connected", "Network connection established", EventSeverity.Info),
("wifi_connected", "Connected to WiFi network", EventSeverity.Info),
("internet_check", "Internet connectivity verified", EventSeverity.Info)
}, cancellationToken);
// Phase 2: Identity
await SimulatePhase(EnrollmentPhase.Identity, cancellationToken);
await EmitPhaseEvents(EnrollmentPhase.Identity, new[]
{
("aad_join_start", "Starting Azure AD join", EventSeverity.Info),
("device_registration", "Registering device with Azure AD", EventSeverity.Info),
("certificate_request", "Requesting device certificate", EventSeverity.Info),
("aad_join_complete", "Azure AD join completed successfully", EventSeverity.Info)
}, cancellationToken);
// Phase 3: MDM Enrollment
await SimulatePhase(EnrollmentPhase.MdmEnrollment, cancellationToken);
await EmitPhaseEvents(EnrollmentPhase.MdmEnrollment, new[]
{
("mdm_discovery", "Discovering MDM endpoints", EventSeverity.Info),
("intune_enrollment_start", "Starting Intune enrollment", EventSeverity.Info),
("policy_sync", "Syncing initial policies", EventSeverity.Info),
("profile_download", "Downloaded Autopilot profile", EventSeverity.Info),
("mdm_enrollment_complete", "MDM enrollment completed", EventSeverity.Info)
}, cancellationToken);
// Phase 4: ESP Device Setup
await SimulatePhase(EnrollmentPhase.EspDeviceSetup, cancellationToken);
if (_simulateFailure && _random.Next(0, 100) < 30)
{
// Simulate a failure during device setup (30% chance if failure mode enabled)
await EmitPhaseEvents(EnrollmentPhase.EspDeviceSetup, new[]
{
("esp_device_start", "Starting ESP Device Setup", EventSeverity.Info),
("device_compliance_check", "Checking device compliance", EventSeverity.Info),
("security_baseline_apply", "Applying security baseline", EventSeverity.Warning),
("bitlocker_encryption", "BitLocker encryption failed", EventSeverity.Error),
}, cancellationToken);
await SimulatePhase(EnrollmentPhase.Failed, cancellationToken);
EmitEvent(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
Timestamp = DateTime.UtcNow,
EventType = "enrollment_failed",
Severity = EventSeverity.Critical,
Source = "Simulator",
Phase = EnrollmentPhase.Failed,
Message = "Enrollment failed: BitLocker encryption error",
Data = new Dictionary<string, object>
{
{ "errorCode", "0x80070002" },
{ "failedPhase", "EspDeviceSetup" }
}
});
_logger.Warning("Simulated enrollment failure");
return;
}
await EmitPhaseEvents(EnrollmentPhase.EspDeviceSetup, new[]
{
("esp_device_start", "Starting ESP Device Setup", EventSeverity.Info),
("device_compliance_check", "Checking device compliance", EventSeverity.Info),
("security_baseline_apply", "Applying security baseline", EventSeverity.Info),
("bitlocker_encryption", "Enabling BitLocker encryption", EventSeverity.Info),
("device_config_apply", "Applying device configurations", EventSeverity.Info),
("esp_device_complete", "ESP Device Setup completed", EventSeverity.Info)
}, cancellationToken);
// Phase 5: App Installation
await SimulatePhase(EnrollmentPhase.AppInstallation, cancellationToken);
await EmitPhaseEvents(EnrollmentPhase.AppInstallation, new[]
{
("app_install_start", "Starting application installation", EventSeverity.Info),
("app_download_office", "Downloading Microsoft 365 Apps", EventSeverity.Info),
("app_install_office", "Installing Microsoft 365 Apps", EventSeverity.Info),
("app_download_teams", "Downloading Microsoft Teams", EventSeverity.Info),
("app_install_teams", "Installing Microsoft Teams", EventSeverity.Info),
("app_download_edge", "Downloading Microsoft Edge", EventSeverity.Info),
("app_install_edge", "Installing Microsoft Edge", EventSeverity.Info),
("app_install_complete", "All required apps installed", EventSeverity.Info)
}, cancellationToken);
// Phase 6: ESP User Setup
await SimulatePhase(EnrollmentPhase.EspUserSetup, cancellationToken);
await EmitPhaseEvents(EnrollmentPhase.EspUserSetup, new[]
{
("esp_user_start", "Starting ESP User Setup", EventSeverity.Info),
("user_policy_sync", "Syncing user policies", EventSeverity.Info),
("user_profile_setup", "Setting up user profile", EventSeverity.Info),
("user_certs_install", "Installing user certificates", EventSeverity.Info),
("esp_user_complete", "ESP User Setup completed", EventSeverity.Info)
}, cancellationToken);
// Phase 7: Complete
await SimulatePhase(EnrollmentPhase.Complete, cancellationToken);
EmitEvent(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
Timestamp = DateTime.UtcNow,
EventType = "enrollment_complete",
Severity = EventSeverity.Info,
Source = "Simulator",
Phase = EnrollmentPhase.Complete,
Message = "Autopilot enrollment completed successfully",
Data = new Dictionary<string, object>
{
{ "totalDurationSeconds", 420 }, // ~7 minutes
{ "appsInstalled", 3 },
{ "policiesApplied", 15 }
}
});
_logger.Info("Simulated Autopilot enrollment completed successfully");
}
catch (OperationCanceledException)
{
_logger.Info("Autopilot simulation cancelled");
}
catch (Exception ex)
{
_logger.Error("Error in Autopilot simulation", ex);
}
}
private async Task SimulatePhase(EnrollmentPhase phase, CancellationToken cancellationToken)
{
var previousPhase = _currentPhase;
_currentPhase = phase;
// Emit phase change event
EmitEvent(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
Timestamp = DateTime.UtcNow,
EventType = "phase_changed",
Severity = EventSeverity.Info,
Source = "Simulator",
Phase = phase,
Message = $"Enrollment phase changed: {previousPhase} -> {phase}",
Data = new Dictionary<string, object>
{
{ "previousPhase", previousPhase.ToString() },
{ "currentPhase", phase.ToString() },
{ "phaseNumber", (int)phase }
}
});
// Small delay between phases (2-5 seconds)
await Task.Delay(TimeSpan.FromSeconds(_random.Next(2, 6)), cancellationToken);
}
private async Task EmitPhaseEvents(
EnrollmentPhase phase,
(string eventType, string message, EventSeverity severity)[] events,
CancellationToken cancellationToken)
{
foreach (var (eventType, message, severity) in events)
{
EmitEvent(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
Timestamp = DateTime.UtcNow,
EventType = eventType,
Severity = severity,
Source = "Simulator",
Phase = phase,
Message = message,
Data = new Dictionary<string, object>
{
{ "simulated", true },
{ "phase", phase.ToString() }
}
});
// Random delay between events (1-3 seconds)
await Task.Delay(TimeSpan.FromSeconds(_random.Next(1, 4)), cancellationToken);
}
}
private void EmitEvent(EnrollmentEvent evt)
{
try
{
_onEventCollected(evt);
_logger.Debug($"Simulator event: {evt.EventType} - {evt.Message}");
}
catch (Exception ex)
{
_logger.Error($"Error emitting simulator event: {evt.EventType}", ex);
}
}
public void Dispose()
{
if (_disposed)
return;
Stop();
_cancellationTokenSource?.Dispose();
_disposed = true;
}
}
}
@@ -0,0 +1,185 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.Eventing.Reader;
using AutopilotMonitor.Agent.Core.Logging;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Agent.Core.EventCollection
{
/// <summary>
/// Watches Windows Event Logs for Autopilot-related events
/// </summary>
public class EventLogWatcher : IDisposable
{
private readonly AgentLogger _logger;
private readonly string _sessionId;
private readonly string _tenantId;
private readonly Action<EnrollmentEvent> _onEventCollected;
private readonly List<EventLogQuery> _queries = new List<EventLogQuery>();
private readonly List<System.Diagnostics.Eventing.Reader.EventLogWatcher> _watchers = new List<System.Diagnostics.Eventing.Reader.EventLogWatcher>();
// Autopilot Event Log channels
private static readonly string[] AutopilotLogChannels = new[]
{
"Microsoft-Windows-DeviceManagement-Enterprise-Diagnostics-Provider/Admin",
"Microsoft-Windows-Provisioning-Diagnostics-Provider/Admin",
"Microsoft-Windows-ModernDeployment-Diagnostics-Provider/Autopilot"
};
public EventLogWatcher(string sessionId, string tenantId, Action<EnrollmentEvent> onEventCollected, AgentLogger logger)
{
_sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
_tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
_onEventCollected = onEventCollected ?? throw new ArgumentNullException(nameof(onEventCollected));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// Starts watching event logs
/// </summary>
public void Start()
{
_logger.Info("Starting EventLog watchers");
foreach (var channel in AutopilotLogChannels)
{
try
{
// Create query for new events (from now onwards)
var query = new EventLogQuery(channel, PathType.LogName, "*[System[TimeCreated[timediff(@SystemTime) <= 1000]]]");
_queries.Add(query);
// Create watcher
var watcher = new System.Diagnostics.Eventing.Reader.EventLogWatcher(query);
watcher.EventRecordWritten += OnEventRecordWritten;
watcher.Enabled = true;
_watchers.Add(watcher);
_logger.Info($"Started watching: {channel}");
}
catch (EventLogNotFoundException)
{
_logger.Warning($"Event log not found: {channel} (This is normal if not on a real Autopilot device)");
}
catch (Exception ex)
{
_logger.Error($"Failed to start watcher for {channel}", ex);
}
}
if (_watchers.Count == 0)
{
_logger.Warning("No event log watchers started. Running in simulation mode.");
}
}
/// <summary>
/// Stops watching event logs
/// </summary>
public void Stop()
{
_logger.Info("Stopping EventLog watchers");
foreach (var watcher in _watchers)
{
try
{
watcher.Enabled = false;
watcher.EventRecordWritten -= OnEventRecordWritten;
watcher.Dispose();
}
catch (Exception ex)
{
_logger.Error("Error stopping watcher", ex);
}
}
_watchers.Clear();
_queries.Clear();
}
private void OnEventRecordWritten(object sender, EventRecordWrittenEventArgs e)
{
if (e.EventRecord == null)
return;
try
{
var record = e.EventRecord;
// Convert Windows Event to EnrollmentEvent
var evt = new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
Timestamp = record.TimeCreated ?? DateTime.UtcNow,
EventType = $"eventlog_{record.Id}",
Severity = MapEventLevel(record.Level),
Source = record.ProviderName ?? "EventLog",
Phase = DetectPhaseFromEvent(record),
Message = record.FormatDescription() ?? $"Event ID {record.Id}",
Data = new Dictionary<string, object>
{
{ "eventId", record.Id },
{ "level", record.Level ?? 0 },
{ "logName", record.LogName },
{ "providerName", record.ProviderName }
}
};
_onEventCollected(evt);
}
catch (Exception ex)
{
_logger.Error("Error processing event record", ex);
}
}
private EventSeverity MapEventLevel(byte? level)
{
if (!level.HasValue)
return EventSeverity.Info;
switch (level.Value)
{
case 1: // Critical
return EventSeverity.Critical;
case 2: // Error
return EventSeverity.Error;
case 3: // Warning
return EventSeverity.Warning;
case 4: // Information
default:
return EventSeverity.Info;
}
}
private EnrollmentPhase DetectPhaseFromEvent(EventRecord record)
{
// Simple phase detection based on event content
var description = record.FormatDescription()?.ToLowerInvariant() ?? "";
if (description.Contains("device preparation") || description.Contains("preparing device"))
return EnrollmentPhase.MdmEnrollment;
if (description.Contains("device setup") || description.Contains("setting up device"))
return EnrollmentPhase.EspDeviceSetup;
if (description.Contains("account setup") || description.Contains("setting up account") || description.Contains("user setup"))
return EnrollmentPhase.EspUserSetup;
if (description.Contains("esp") || description.Contains("enrollment status"))
return EnrollmentPhase.EspDeviceSetup;
if (description.Contains("identity") || description.Contains("azure ad") || description.Contains("entra"))
return EnrollmentPhase.Identity;
if (description.Contains("app") || description.Contains("application"))
return EnrollmentPhase.AppInstallation;
if (description.Contains("complete") || description.Contains("finished"))
return EnrollmentPhase.Complete;
return EnrollmentPhase.PreFlight;
}
public void Dispose()
{
Stop();
}
}
}
@@ -0,0 +1,238 @@
using System;
using System.Collections.Generic;
using System.Threading;
using AutopilotMonitor.Agent.Core.Logging;
using AutopilotMonitor.Shared.Models;
using Microsoft.Win32;
namespace AutopilotMonitor.Agent.Core.EventCollection
{
/// <summary>
/// Detects and tracks Autopilot enrollment phases
/// </summary>
public class PhaseDetector : IDisposable
{
private readonly AgentLogger _logger;
private readonly string _sessionId;
private readonly string _tenantId;
private readonly Action<EnrollmentEvent> _onEventCollected;
private Timer _pollTimer;
private EnrollmentPhase _currentPhase = EnrollmentPhase.PreFlight;
// ESP (Enrollment Status Page) registry paths
private const string EspKeyPath = @"SOFTWARE\Microsoft\Enrollments";
private const string AutopilotKeyPath = @"SOFTWARE\Microsoft\Provisioning\Diagnostics\Autopilot";
public EnrollmentPhase CurrentPhase => _currentPhase;
public PhaseDetector(string sessionId, string tenantId, Action<EnrollmentEvent> onEventCollected, AgentLogger logger)
{
_sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
_tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
_onEventCollected = onEventCollected ?? throw new ArgumentNullException(nameof(onEventCollected));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// Starts phase detection
/// </summary>
public void Start()
{
_logger.Info("Starting Phase detector");
// Initial phase check
CheckPhase();
// Poll every 10 seconds
_pollTimer = new Timer(
_ => CheckPhase(),
null,
TimeSpan.FromSeconds(10),
TimeSpan.FromSeconds(10)
);
}
/// <summary>
/// Stops phase detection
/// </summary>
public void Stop()
{
_logger.Info("Stopping Phase detector");
_pollTimer?.Dispose();
}
private void CheckPhase()
{
try
{
var detectedPhase = DetectCurrentPhase();
if (detectedPhase != _currentPhase)
{
var previousPhase = _currentPhase;
_currentPhase = detectedPhase;
// Emit phase change event
var evt = new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
Timestamp = DateTime.UtcNow,
EventType = "phase_changed",
Severity = EventSeverity.Info,
Source = "PhaseDetector",
Phase = _currentPhase,
Message = $"Enrollment phase changed: {previousPhase} -> {_currentPhase}",
Data = new Dictionary<string, object>
{
{ "previousPhase", previousPhase.ToString() },
{ "currentPhase", _currentPhase.ToString() },
{ "phaseNumber", (int)_currentPhase }
}
};
_onEventCollected(evt);
_logger.Info($"Phase changed: {previousPhase} -> {_currentPhase}");
// Check if enrollment completed or failed
if (_currentPhase == EnrollmentPhase.Complete)
{
_logger.Info("Enrollment completed successfully - emitting completion event");
var completionEvent = new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
Timestamp = DateTime.UtcNow,
EventType = "enrollment_complete",
Severity = EventSeverity.Info,
Source = "PhaseDetector",
Phase = EnrollmentPhase.Complete,
Message = "Autopilot enrollment completed successfully"
};
_onEventCollected(completionEvent);
}
else if (_currentPhase == EnrollmentPhase.Failed)
{
_logger.Warning("Enrollment failed - emitting failure event");
var failureEvent = new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
Timestamp = DateTime.UtcNow,
EventType = "enrollment_failed",
Severity = EventSeverity.Critical,
Source = "PhaseDetector",
Phase = EnrollmentPhase.Failed,
Message = "Autopilot enrollment failed"
};
_onEventCollected(failureEvent);
}
}
}
catch (Exception ex)
{
_logger.Error("Error detecting phase", ex);
}
}
private EnrollmentPhase DetectCurrentPhase()
{
// Try to detect phase from various indicators
// 1. Check ESP completion status first (highest priority)
try
{
using (var enrollmentsKey = Registry.LocalMachine.OpenSubKey(EspKeyPath, false))
{
if (enrollmentsKey != null)
{
var subKeyNames = enrollmentsKey.GetSubKeyNames();
foreach (var subKeyName in subKeyNames)
{
using (var enrollmentKey = enrollmentsKey.OpenSubKey(subKeyName, false))
{
if (enrollmentKey != null)
{
// Check for ESP tracking policies
using (var statusKey = enrollmentKey.OpenSubKey("Status", false))
{
if (statusKey != null)
{
// Check for completion indicators
var espComplete = statusKey.GetValue("EnrollmentStatusPageComplete");
var deviceSetupStatus = statusKey.GetValue("DeviceSetupStatus");
var accountSetupStatus = statusKey.GetValue("AccountSetupStatus");
// Check for failure
var hasError = statusKey.GetValue("HasError");
if (hasError != null && Convert.ToInt32(hasError) > 0)
{
return EnrollmentPhase.Failed;
}
// Check for completion (ESP finished successfully)
if (espComplete != null && Convert.ToInt32(espComplete) > 0)
{
return EnrollmentPhase.Complete;
}
// Check phase progression
if (accountSetupStatus != null)
{
var accountStatus = Convert.ToInt32(accountSetupStatus);
// 2 = Completed, check if this is final phase
if (accountStatus == 2)
{
return EnrollmentPhase.Complete;
}
return EnrollmentPhase.EspUserSetup;
}
if (deviceSetupStatus != null)
{
var deviceStatus = Convert.ToInt32(deviceSetupStatus);
// Check for failure during device setup
if (deviceStatus == 3) // 3 = Failed
{
return EnrollmentPhase.Failed;
}
return EnrollmentPhase.EspDeviceSetup;
}
}
}
}
}
}
}
}
}
catch { }
// 2. Check Autopilot registry for deployment profile
try
{
using (var key = Registry.LocalMachine.OpenSubKey(AutopilotKeyPath, false))
{
if (key != null)
{
var profileName = key.GetValue("DeploymentProfileName") as string;
if (!string.IsNullOrEmpty(profileName))
{
// Profile downloaded - at least in MDM Enrollment
return EnrollmentPhase.MdmEnrollment;
}
}
}
}
catch { }
// 3. If we have an Autopilot session running, we're at least in PreFlight
return EnrollmentPhase.PreFlight;
}
public void Dispose()
{
Stop();
}
}
}
@@ -0,0 +1,154 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.Core.Logging;
using AutopilotMonitor.Shared.Models;
using Microsoft.Win32;
namespace AutopilotMonitor.Agent.Core.EventCollection
{
/// <summary>
/// Monitors Autopilot registry keys for status changes
/// </summary>
public class RegistryMonitor : IDisposable
{
private readonly AgentLogger _logger;
private readonly string _sessionId;
private readonly string _tenantId;
private readonly Action<EnrollmentEvent> _onEventCollected;
private Timer _pollTimer;
private readonly Dictionary<string, object> _lastValues = new Dictionary<string, object>();
// Autopilot registry paths
private const string AutopilotKeyPath = @"SOFTWARE\Microsoft\Provisioning\Diagnostics\Autopilot";
private const string AutopilotDiagnosticsPath = @"SOFTWARE\Microsoft\Provisioning\Diagnostics";
// Registry keys to monitor
private static readonly string[] MonitoredValues = new[]
{
"CloudAssignedTenantId",
"CloudAssignedOobeConfig",
"AutopilotServiceCorrelationId",
"DeploymentProfileName",
"IsAutopilotDisabled",
"CloudAssignedTenantDomain"
};
public RegistryMonitor(string sessionId, string tenantId, Action<EnrollmentEvent> onEventCollected, AgentLogger logger)
{
_sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
_tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
_onEventCollected = onEventCollected ?? throw new ArgumentNullException(nameof(onEventCollected));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// Starts monitoring registry keys
/// </summary>
public void Start()
{
_logger.Info("Starting Registry monitor");
// Initial read
CheckRegistryValues();
// Poll every 5 seconds
_pollTimer = new Timer(
_ => CheckRegistryValues(),
null,
TimeSpan.FromSeconds(5),
TimeSpan.FromSeconds(5)
);
}
/// <summary>
/// Stops monitoring registry keys
/// </summary>
public void Stop()
{
_logger.Info("Stopping Registry monitor");
_pollTimer?.Dispose();
}
private void CheckRegistryValues()
{
try
{
using (var key = Registry.LocalMachine.OpenSubKey(AutopilotKeyPath, false))
{
if (key == null)
{
// Autopilot key doesn't exist - not an Autopilot device or not started yet
return;
}
foreach (var valueName in MonitoredValues)
{
try
{
var currentValue = key.GetValue(valueName);
if (currentValue != null)
{
var valueString = currentValue.ToString();
// Check if value changed
if (!_lastValues.ContainsKey(valueName) || !_lastValues[valueName].Equals(currentValue))
{
_lastValues[valueName] = currentValue;
// Emit event for registry change
var evt = new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
Timestamp = DateTime.UtcNow,
EventType = $"registry_changed",
Severity = EventSeverity.Info,
Source = "RegistryMonitor",
Phase = DetectPhaseFromKey(valueName),
Message = $"Registry value changed: {valueName} = {valueString}",
Data = new Dictionary<string, object>
{
{ "registryKey", AutopilotKeyPath },
{ "valueName", valueName },
{ "value", valueString }
}
};
_onEventCollected(evt);
_logger.Debug($"Registry change detected: {valueName} = {valueString}");
}
}
}
catch (Exception ex)
{
_logger.Error($"Error reading registry value {valueName}", ex);
}
}
}
}
catch (Exception ex)
{
_logger.Error("Error checking registry values", ex);
}
}
private EnrollmentPhase DetectPhaseFromKey(string keyName)
{
// Map registry keys to phases
if (keyName.Contains("CloudAssigned") || keyName.Contains("Tenant"))
return EnrollmentPhase.Identity;
if (keyName.Contains("Profile") || keyName.Contains("Config"))
return EnrollmentPhase.MdmEnrollment;
return EnrollmentPhase.PreFlight;
}
public void Dispose()
{
Stop();
}
}
}
@@ -0,0 +1,70 @@
using System;
using System.IO;
namespace AutopilotMonitor.Agent.Core.Logging
{
/// <summary>
/// Simple file-based logger for the agent
/// </summary>
public class AgentLogger
{
private readonly string _logFilePath;
private readonly bool _enableDebug;
private readonly object _lockObject = new object();
public AgentLogger(string logDirectory, bool enableDebug = false)
{
_enableDebug = enableDebug;
if (!Directory.Exists(logDirectory))
{
Directory.CreateDirectory(logDirectory);
}
var logFileName = $"agent_{DateTime.Now:yyyyMMdd}.log";
_logFilePath = Path.Combine(logDirectory, logFileName);
}
public void Debug(string message)
{
if (_enableDebug)
{
Log("DEBUG", message);
}
}
public void Info(string message)
{
Log("INFO", message);
}
public void Warning(string message)
{
Log("WARN", message);
}
public void Error(string message, Exception ex = null)
{
var fullMessage = ex != null ? $"{message} - {ex}" : message;
Log("ERROR", fullMessage);
}
private void Log(string level, string message)
{
try
{
lock (_lockObject)
{
var timestamp = DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss.fff");
var logEntry = $"[{timestamp}] [{level}] {message}";
File.AppendAllText(_logFilePath, logEntry + Environment.NewLine);
}
}
catch
{
// Swallow logging errors to prevent crashes
}
}
}
}
@@ -0,0 +1,618 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.Core.Api;
using AutopilotMonitor.Agent.Core.Configuration;
using AutopilotMonitor.Agent.Core.EventCollection;
using AutopilotMonitor.Agent.Core.Logging;
using AutopilotMonitor.Agent.Core.Storage;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Agent.Core.Monitoring
{
/// <summary>
/// Main monitoring service that collects and uploads telemetry
/// </summary>
public class MonitoringService : IDisposable
{
private readonly AgentConfiguration _configuration;
private readonly AgentLogger _logger;
private readonly EventSpool _spool;
private readonly BackendApiClient _apiClient;
private readonly Timer _uploadTimer;
private readonly Timer _debounceTimer;
private readonly object _timerLock = new object();
private long _eventSequence = 0;
private EnrollmentPhase? _lastPhase = null;
// Event collectors
private EventLogWatcher _eventLogWatcher;
private RegistryMonitor _registryMonitor;
private PhaseDetector _phaseDetector;
private AutopilotSimulator _simulator;
public MonitoringService(AgentConfiguration configuration, AgentLogger logger)
{
_configuration = configuration ?? throw new ArgumentNullException(nameof(configuration));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
if (!_configuration.IsValid())
{
throw new InvalidOperationException("Invalid agent configuration");
}
_spool = new EventSpool(_configuration.SpoolDirectory);
_apiClient = new BackendApiClient(_configuration.ApiBaseUrl, _configuration, _logger);
// Subscribe to spool events for batched upload when new events arrive
_spool.EventsAvailable += OnEventsAvailable;
// Set up debounce timer for batching (waits before uploading to collect more events)
// This reduces API calls while still being responsive
_debounceTimer = new Timer(
DebounceTimerCallback,
null,
Timeout.Infinite, // Don't start initially
Timeout.Infinite
);
// Set up periodic upload timer as fallback (much longer interval)
// This ensures events are uploaded even if FileSystemWatcher misses something
_uploadTimer = new Timer(
UploadTimerCallback,
null,
TimeSpan.FromMinutes(1), // Initial delay
TimeSpan.FromMinutes(5) // Fallback check every 5 minutes
);
_logger.Info("MonitoringService initialized with FileSystemWatcher and batching");
}
/// <summary>
/// Starts the monitoring service
/// </summary>
public void Start()
{
_logger.Info("Starting monitoring service");
// Register session with backend
RegisterSessionAsync().Wait();
// Start FileSystemWatcher for efficient event detection
_spool.StartWatching();
_logger.Info("FileSystemWatcher started for efficient event upload");
// Emit initial event
EmitEvent(new EnrollmentEvent
{
SessionId = _configuration.SessionId,
TenantId = _configuration.TenantId,
EventType = "agent_started",
Severity = EventSeverity.Info,
Source = "Agent",
Phase = EnrollmentPhase.PreFlight,
Message = "Autopilot Monitor Agent started"
});
// Start event collectors
StartEventCollectors();
_logger.Info("Monitoring service started");
}
/// <summary>
/// Starts all event collection components
/// </summary>
private void StartEventCollectors()
{
_logger.Info("Starting event collectors");
try
{
// Start EventLog watcher
_eventLogWatcher = new EventLogWatcher(
_configuration.SessionId,
_configuration.TenantId,
EmitEvent,
_logger
);
_eventLogWatcher.Start();
// Start Registry monitor
_registryMonitor = new RegistryMonitor(
_configuration.SessionId,
_configuration.TenantId,
EmitEvent,
_logger
);
_registryMonitor.Start();
// Start Phase detector
_phaseDetector = new PhaseDetector(
_configuration.SessionId,
_configuration.TenantId,
EmitEvent,
_logger
);
_phaseDetector.Start();
// Start Autopilot Simulator if enabled
if (_configuration.EnableSimulator)
{
_logger.Info("Simulator mode enabled - starting Autopilot simulator");
_simulator = new AutopilotSimulator(
_configuration.SessionId,
_configuration.TenantId,
EmitEvent,
_logger,
_configuration.SimulateFailure
);
_simulator.Start();
_logger.Info("Autopilot simulator started");
}
_logger.Info("Event collectors started successfully");
}
catch (Exception ex)
{
_logger.Error("Error starting event collectors", ex);
}
}
/// <summary>
/// Registers the session with the backend
/// </summary>
private async Task RegisterSessionAsync()
{
try
{
_logger.Info("Registering session with backend");
var registration = new SessionRegistration
{
SessionId = _configuration.SessionId,
TenantId = _configuration.TenantId,
SerialNumber = GetSerialNumber(),
Manufacturer = GetManufacturer(),
Model = GetModel(),
DeviceName = Environment.MachineName,
OsBuild = Environment.OSVersion.Version.ToString(),
OsEdition = GetOsEdition(),
OsLanguage = System.Globalization.CultureInfo.CurrentCulture.Name,
StartedAt = DateTime.UtcNow,
AgentVersion = "1.0.0-phase1"
};
var response = await _apiClient.RegisterSessionAsync(registration);
if (response.Success)
{
_logger.Info($"Session registered successfully: {response.SessionId}");
}
else
{
_logger.Warning($"Session registration failed: {response.Message}");
}
}
catch (Exception ex)
{
_logger.Error("Failed to register session", ex);
}
}
private string GetSerialNumber()
{
try
{
using (var searcher = new System.Management.ManagementObjectSearcher("SELECT SerialNumber FROM Win32_BIOS"))
{
foreach (var obj in searcher.Get())
{
return obj["SerialNumber"]?.ToString() ?? "Unknown";
}
}
}
catch { }
return "Unknown";
}
private string GetManufacturer()
{
try
{
using (var searcher = new System.Management.ManagementObjectSearcher("SELECT Manufacturer FROM Win32_ComputerSystem"))
{
foreach (var obj in searcher.Get())
{
return obj["Manufacturer"]?.ToString() ?? "Unknown";
}
}
}
catch { }
return "Unknown";
}
private string GetModel()
{
try
{
using (var searcher = new System.Management.ManagementObjectSearcher("SELECT Model FROM Win32_ComputerSystem"))
{
foreach (var obj in searcher.Get())
{
return obj["Model"]?.ToString() ?? "Unknown";
}
}
}
catch { }
return "Unknown";
}
private string GetOsEdition()
{
try
{
using (var key = Microsoft.Win32.Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Windows NT\CurrentVersion"))
{
return key?.GetValue("EditionID")?.ToString() ?? "Unknown";
}
}
catch { }
return "Unknown";
}
/// <summary>
/// Stops the monitoring service
/// </summary>
public void Stop()
{
_logger.Info("Stopping monitoring service");
// Stop FileSystemWatcher
_spool.StopWatching();
// Stop event collectors
StopEventCollectors();
// Emit final event
EmitEvent(new EnrollmentEvent
{
SessionId = _configuration.SessionId,
TenantId = _configuration.TenantId,
EventType = "agent_stopped",
Severity = EventSeverity.Info,
Source = "Agent",
Phase = _phaseDetector?.CurrentPhase ?? EnrollmentPhase.PreFlight,
Message = "Autopilot Monitor Agent stopped"
});
// Final upload attempt
UploadEventsAsync().Wait(TimeSpan.FromSeconds(10));
_logger.Info("Monitoring service stopped");
}
/// <summary>
/// Stops all event collection components
/// </summary>
private void StopEventCollectors()
{
_logger.Info("Stopping event collectors");
try
{
_eventLogWatcher?.Stop();
_eventLogWatcher?.Dispose();
_registryMonitor?.Stop();
_registryMonitor?.Dispose();
_phaseDetector?.Stop();
_phaseDetector?.Dispose();
_simulator?.Stop();
_simulator?.Dispose();
_logger.Info("Event collectors stopped");
}
catch (Exception ex)
{
_logger.Error("Error stopping event collectors", ex);
}
}
/// <summary>
/// Emits an event (adds to spool)
/// </summary>
public void EmitEvent(EnrollmentEvent evt)
{
evt.Sequence = Interlocked.Increment(ref _eventSequence);
_spool.Add(evt);
_logger.Debug($"Event emitted: {evt.EventType} - {evt.Message}");
// Check if this is a phase transition
bool isPhaseTransition = false;
if (evt.Phase != _lastPhase)
{
_logger.Debug($"Phase transition detected: {_lastPhase?.ToString() ?? "null"} -> {evt.Phase}");
_lastPhase = evt.Phase;
isPhaseTransition = true;
}
// Check for enrollment completion events
if (_configuration.SelfDestructOnComplete &&
(evt.EventType == "enrollment_complete" || evt.EventType == "enrollment_failed"))
{
_logger.Info($"Enrollment completion detected: {evt.EventType}");
Task.Run(() => HandleEnrollmentComplete());
return; // Don't continue with normal event processing
}
// Immediate upload for:
// 1. Critical events (errors) - for troubleshooting
// 2. Phase transitions (start/end) - for real-time phase tracking in UI
// 3. Events with "phase" in EventType - explicit phase-related events
if (evt.Severity >= EventSeverity.Error ||
isPhaseTransition ||
evt.EventType?.Contains("phase", StringComparison.OrdinalIgnoreCase) == true)
{
_logger.Debug("Critical/Phase event detected, triggering immediate upload (bypassing debounce)");
Task.Run(() => UploadEventsAsync());
}
}
/// <summary>
/// Called when new events are available in the spool (FileSystemWatcher detected new files)
/// Uses debouncing to batch events before uploading
/// </summary>
private void OnEventsAvailable(object sender, EventArgs e)
{
_logger.Debug("FileSystemWatcher detected new events, starting/resetting debounce timer");
// Reset debounce timer - wait for batch window before uploading
// This allows multiple events to accumulate, reducing API calls
lock (_timerLock)
{
_debounceTimer.Change(
TimeSpan.FromSeconds(_configuration.UploadIntervalSeconds),
Timeout.InfiniteTimeSpan
);
}
}
/// <summary>
/// Called when debounce timer expires - uploads batched events
/// </summary>
private void DebounceTimerCallback(object state)
{
_logger.Debug("Debounce timer expired, uploading batched events");
Task.Run(() => UploadEventsAsync());
}
/// <summary>
/// Fallback timer callback in case FileSystemWatcher misses events
/// </summary>
private void UploadTimerCallback(object state)
{
Task.Run(() => UploadEventsAsync());
}
private async Task UploadEventsAsync()
{
try
{
var events = _spool.GetBatch(_configuration.MaxBatchSize);
if (events.Count == 0)
{
_logger.Debug("No events to upload");
return;
}
_logger.Debug($"Uploading {events.Count} events");
var request = new IngestEventsRequest
{
SessionId = _configuration.SessionId,
TenantId = _configuration.TenantId,
Events = events
};
var response = await _apiClient.IngestEventsAsync(request);
if (response.Success)
{
_spool.RemoveEvents(events);
_logger.Debug($"Successfully uploaded {response.EventsProcessed} events");
}
else
{
_logger.Warning($"Upload failed: {response.Message}");
}
}
catch (Exception ex)
{
_logger.Error("Error uploading events", ex);
}
}
/// <summary>
/// Handles enrollment completion and triggers self-destruct sequence
/// </summary>
private async Task HandleEnrollmentComplete()
{
try
{
_logger.Info("===== ENROLLMENT COMPLETE - Starting Self-Destruct Sequence =====");
// Step 1: Stop all event collectors
_logger.Info("Stopping event collectors...");
StopEventCollectors();
_spool.StopWatching();
// Step 2: Upload all remaining events
_logger.Info("Uploading final events...");
await UploadEventsAsync();
// Give a moment for final upload to complete
await Task.Delay(2000);
// Step 3: Execute self-destruct
if (_configuration.SelfDestructOnComplete)
{
ExecuteSelfDestruct();
}
else if (_configuration.CleanupOnExit)
{
ExecuteCleanup();
}
_logger.Info("Self-destruct sequence initiated. Agent will now exit.");
// Exit the application
Environment.Exit(0);
}
catch (Exception ex)
{
_logger.Error("Error during self-destruct sequence", ex);
Environment.Exit(1);
}
}
/// <summary>
/// Executes full self-destruct: removes scheduled task and deletes all files
/// </summary>
private void ExecuteSelfDestruct()
{
try
{
_logger.Info("Executing FULL SELF-DESTRUCT (Scheduled Task + Files)");
var currentProcessId = Process.GetCurrentProcess().Id;
var agentBasePath = Path.GetFullPath(Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
"AutopilotMonitor"
));
// Create a self-deleting PowerShell script
var cleanupScript = $@"
# Wait for agent process to exit
Start-Sleep -Seconds 3
Wait-Process -Id {currentProcessId} -ErrorAction SilentlyContinue -Timeout 30
# Remove Scheduled Task
Write-Host 'Removing Scheduled Task: {_configuration.ScheduledTaskName}'
try {{
Stop-ScheduledTask -TaskName '{_configuration.ScheduledTaskName}' -ErrorAction SilentlyContinue
Unregister-ScheduledTask -TaskName '{_configuration.ScheduledTaskName}' -Confirm:$false -ErrorAction SilentlyContinue
Write-Host 'Scheduled Task removed successfully'
}} catch {{
Write-Host ""Failed to remove Scheduled Task: $_""
}}
# Delete AutopilotMonitor folder
Write-Host 'Deleting folder: {agentBasePath}'
try {{
Remove-Item -Path '{agentBasePath}' -Recurse -Force -ErrorAction Stop
Write-Host 'Folder deleted successfully'
}} catch {{
Write-Host ""Failed to delete folder: $_""
}}
Write-Host 'AutopilotMonitor Agent self-destruct complete'
";
// Write cleanup script to temp location (outside of agent folder)
var tempScriptPath = Path.Combine(Path.GetTempPath(), $"AutopilotMonitor-Cleanup-{Guid.NewGuid()}.ps1");
File.WriteAllText(tempScriptPath, cleanupScript);
_logger.Info($"Cleanup script written to {tempScriptPath}");
// Launch cleanup script in background (detached process)
var psi = new ProcessStartInfo
{
FileName = "powershell.exe",
Arguments = $"-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File \"{tempScriptPath}\"",
UseShellExecute = false,
CreateNoWindow = true
};
Process.Start(psi);
_logger.Info("Cleanup script launched. Agent will now exit.");
}
catch (Exception ex)
{
_logger.Error("Error executing self-destruct", ex);
}
}
/// <summary>
/// Executes cleanup only (files) without removing scheduled task
/// </summary>
private void ExecuteCleanup()
{
try
{
_logger.Info("Executing CLEANUP (Files only, keeping Scheduled Task)");
var currentProcessId = Process.GetCurrentProcess().Id;
var agentBasePath = Path.GetFullPath(Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
"AutopilotMonitor"
));
// Create a cleanup PowerShell script
var cleanupScript = $@"
# Wait for agent process to exit
Start-Sleep -Seconds 3
Wait-Process -Id {currentProcessId} -ErrorAction SilentlyContinue -Timeout 30
# Delete AutopilotMonitor folder
Write-Host 'Deleting folder: {agentBasePath}'
try {{
Remove-Item -Path '{agentBasePath}' -Recurse -Force -ErrorAction Stop
Write-Host 'Folder deleted successfully'
}} catch {{
Write-Host ""Failed to delete folder: $_""
}}
Write-Host 'AutopilotMonitor Agent cleanup complete'
";
// Write cleanup script to temp location
var tempScriptPath = Path.Combine(Path.GetTempPath(), $"AutopilotMonitor-Cleanup-{Guid.NewGuid()}.ps1");
File.WriteAllText(tempScriptPath, cleanupScript);
_logger.Info($"Cleanup script written to {tempScriptPath}");
// Launch cleanup script
var psi = new ProcessStartInfo
{
FileName = "powershell.exe",
Arguments = $"-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File \"{tempScriptPath}\"",
UseShellExecute = false,
CreateNoWindow = true
};
Process.Start(psi);
_logger.Info("Cleanup script launched. Agent will now exit.");
}
catch (Exception ex)
{
_logger.Error("Error executing cleanup", ex);
}
}
public void Dispose()
{
_uploadTimer?.Dispose();
_debounceTimer?.Dispose();
_apiClient?.Dispose();
_spool?.Dispose();
_eventLogWatcher?.Dispose();
_registryMonitor?.Dispose();
_phaseDetector?.Dispose();
_simulator?.Dispose();
}
}
}
@@ -0,0 +1,109 @@
using System;
using System.Linq;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using AutopilotMonitor.Agent.Core.Logging;
namespace AutopilotMonitor.Agent.Core.Security
{
/// <summary>
/// Helper for finding and validating MDM client certificates
/// </summary>
public static class CertificateHelper
{
/// <summary>
/// Finds the MDM device certificate for client authentication
/// </summary>
/// <param name="thumbprint">Optional specific thumbprint to search for</param>
/// <param name="logger">Logger for diagnostic output</param>
/// <returns>The MDM certificate, or null if not found</returns>
public static X509Certificate2 FindMdmCertificate(string thumbprint = null, AgentLogger logger = null)
{
try
{
// Search locations for MDM certificate
var stores = new[]
{
new { Location = StoreLocation.LocalMachine, Name = StoreName.My },
new { Location = StoreLocation.CurrentUser, Name = StoreName.My }
};
foreach (var storeInfo in stores)
{
using (var store = new X509Store(storeInfo.Name, storeInfo.Location))
{
store.Open(OpenFlags.ReadOnly);
logger?.Debug($"Searching for MDM certificate in {storeInfo.Location}\\{storeInfo.Name}");
// If thumbprint specified, search by thumbprint
if (!string.IsNullOrEmpty(thumbprint))
{
var cert = store.Certificates
.Find(X509FindType.FindByThumbprint, thumbprint, validOnly: false)
.OfType<X509Certificate2>()
.FirstOrDefault();
if (cert != null)
{
logger?.Info($"Found certificate by thumbprint: {thumbprint}");
return cert;
}
}
else
{
// Auto-detect MDM certificate by issuer and EKU
var mdmCerts = store.Certificates
.OfType<X509Certificate2>()
.Where(c =>
{
// Check issuer (Microsoft Intune MDM Device CA or similar)
var issuerContainsMdm = c.Issuer.Contains("Microsoft Intune", StringComparison.OrdinalIgnoreCase) ||
c.Issuer.Contains("MDM Device CA", StringComparison.OrdinalIgnoreCase) ||
c.Issuer.Contains("Microsoft Device CA", StringComparison.OrdinalIgnoreCase);
// Check Enhanced Key Usage for Client Authentication (1.3.6.1.5.5.7.3.2)
var hasClientAuth = c.Extensions
.OfType<X509EnhancedKeyUsageExtension>()
.Any(ext => ext.EnhancedKeyUsages
.OfType<Oid>()
.Any(oid => oid.Value == "1.3.6.1.5.5.7.3.2"));
return issuerContainsMdm && hasClientAuth;
})
.OrderByDescending(c => c.NotAfter) // Prefer certs with longer validity
.ToList();
if (mdmCerts.Any())
{
var cert = mdmCerts.First();
logger?.Info($"Found MDM certificate: Issuer={cert.Issuer}, Subject={cert.Subject}, Thumbprint={cert.Thumbprint}");
return cert;
}
}
}
}
logger?.Warning("No MDM certificate found");
return null;
}
catch (Exception ex)
{
logger?.Error("Error finding MDM certificate", ex);
return null;
}
}
/// <summary>
/// Validates that a certificate is still valid
/// </summary>
public static bool IsCertificateValid(X509Certificate2 certificate)
{
if (certificate == null)
return false;
var now = DateTime.UtcNow;
return now >= certificate.NotBefore && now <= certificate.NotAfter;
}
}
}
@@ -0,0 +1,62 @@
using System;
using System.Management;
using AutopilotMonitor.Agent.Core.Logging;
namespace AutopilotMonitor.Agent.Core.Security
{
/// <summary>
/// Helper for retrieving hardware information
/// </summary>
public static class HardwareInfo
{
/// <summary>
/// Gets the device manufacturer and model from WMI
/// </summary>
/// <param name="logger">Logger for diagnostic output</param>
/// <returns>Tuple of (Manufacturer, Model, SerialNumber)</returns>
public static (string Manufacturer, string Model, string SerialNumber) GetHardwareInfo(AgentLogger logger = null)
{
string manufacturer = "Unknown";
string model = "Unknown";
string serialNumber = "Unknown";
try
{
// Get Manufacturer and Model from Win32_ComputerSystem
using (var searcher = new ManagementObjectSearcher("SELECT Manufacturer, Model FROM Win32_ComputerSystem"))
using (var collection = searcher.Get())
{
foreach (var obj in collection)
{
using (obj)
{
manufacturer = obj["Manufacturer"]?.ToString() ?? "Unknown";
model = obj["Model"]?.ToString() ?? "Unknown";
}
}
}
// Get Serial Number from Win32_BIOS
using (var searcher = new ManagementObjectSearcher("SELECT SerialNumber FROM Win32_BIOS"))
using (var collection = searcher.Get())
{
foreach (var obj in collection)
{
using (obj)
{
serialNumber = obj["SerialNumber"]?.ToString() ?? "Unknown";
}
}
}
logger?.Debug($"Hardware detected: Manufacturer={manufacturer}, Model={model}, SerialNumber={serialNumber}");
return (manufacturer, model, serialNumber);
}
catch (Exception ex)
{
logger?.Error("Error retrieving hardware information", ex);
return ("Unknown", "Unknown", "Unknown");
}
}
}
}
@@ -0,0 +1,185 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using AutopilotMonitor.Shared.Models;
using Newtonsoft.Json;
namespace AutopilotMonitor.Agent.Core.Storage
{
/// <summary>
/// Manages offline event storage (spool) for resilient uploads
/// </summary>
public class EventSpool : IDisposable
{
private readonly string _spoolDirectory;
private readonly object _lockObject = new object();
private readonly FileSystemWatcher _fileWatcher;
/// <summary>
/// Event raised when new events are added to the spool
/// </summary>
public event EventHandler EventsAvailable;
public EventSpool(string spoolDirectory)
{
_spoolDirectory = spoolDirectory;
if (!Directory.Exists(_spoolDirectory))
{
Directory.CreateDirectory(_spoolDirectory);
}
// Initialize FileSystemWatcher for efficient event detection
_fileWatcher = new FileSystemWatcher(_spoolDirectory)
{
Filter = "event_*.json",
NotifyFilter = NotifyFilters.FileName | NotifyFilters.CreationTime,
EnableRaisingEvents = false // Will be enabled when service starts
};
_fileWatcher.Created += OnFileCreated;
}
/// <summary>
/// Starts watching for new events
/// </summary>
public void StartWatching()
{
_fileWatcher.EnableRaisingEvents = true;
}
/// <summary>
/// Stops watching for new events
/// </summary>
public void StopWatching()
{
_fileWatcher.EnableRaisingEvents = false;
}
private void OnFileCreated(object sender, FileSystemEventArgs e)
{
// Raise event to trigger immediate upload
EventsAvailable?.Invoke(this, EventArgs.Empty);
}
/// <summary>
/// Adds an event to the spool
/// </summary>
public void Add(EnrollmentEvent evt)
{
lock (_lockObject)
{
var fileName = $"event_{evt.Timestamp:yyyyMMddHHmmssfff}_{evt.Sequence}.json";
var filePath = Path.Combine(_spoolDirectory, fileName);
var json = JsonConvert.SerializeObject(evt, Formatting.None);
File.WriteAllText(filePath, json);
}
}
/// <summary>
/// Gets a batch of events from the spool (oldest first)
/// </summary>
public List<EnrollmentEvent> GetBatch(int maxBatchSize)
{
lock (_lockObject)
{
var events = new List<EnrollmentEvent>();
var files = Directory.GetFiles(_spoolDirectory, "event_*.json")
.OrderBy(f => f)
.Take(maxBatchSize)
.ToList();
foreach (var file in files)
{
try
{
var json = File.ReadAllText(file);
var evt = JsonConvert.DeserializeObject<EnrollmentEvent>(json);
if (evt != null)
{
events.Add(evt);
}
}
catch
{
// Skip corrupted files
}
}
return events;
}
}
/// <summary>
/// Removes events from the spool after successful upload
/// </summary>
public void RemoveEvents(List<EnrollmentEvent> events)
{
lock (_lockObject)
{
foreach (var evt in events)
{
var pattern = $"event_*_{evt.Sequence}.json";
var files = Directory.GetFiles(_spoolDirectory, pattern);
foreach (var file in files)
{
try
{
File.Delete(file);
}
catch
{
// Ignore deletion errors
}
}
}
}
}
/// <summary>
/// Gets the count of events in the spool
/// </summary>
public int GetCount()
{
lock (_lockObject)
{
return Directory.GetFiles(_spoolDirectory, "event_*.json").Length;
}
}
/// <summary>
/// Clears all events from the spool
/// </summary>
public void Clear()
{
lock (_lockObject)
{
var files = Directory.GetFiles(_spoolDirectory, "event_*.json");
foreach (var file in files)
{
try
{
File.Delete(file);
}
catch
{
// Ignore deletion errors
}
}
}
}
public void Dispose()
{
if (_fileWatcher != null)
{
_fileWatcher.EnableRaisingEvents = false;
_fileWatcher.Created -= OnFileCreated;
_fileWatcher.Dispose();
}
}
}
}
@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net48</TargetFramework>
<LangVersion>latest</LangVersion>
<PlatformTarget>AnyCPU</PlatformTarget>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\AutopilotMonitor.Agent.Core\AutopilotMonitor.Agent.Core.csproj" />
<ProjectReference Include="..\..\Shared\AutopilotMonitor.Shared\AutopilotMonitor.Shared.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="System.ServiceProcess.ServiceController" Version="8.0.0" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
</ItemGroup>
<ItemGroup>
<Reference Include="System.ServiceProcess" />
</ItemGroup>
</Project>
@@ -0,0 +1,79 @@
using System;
using System.ServiceProcess;
using AutopilotMonitor.Agent.Core.Configuration;
using AutopilotMonitor.Agent.Core.Logging;
using AutopilotMonitor.Agent.Core.Monitoring;
namespace AutopilotMonitor.Agent
{
/// <summary>
/// Windows Service wrapper for the monitoring agent
/// </summary>
public class AutopilotMonitorService : ServiceBase
{
private MonitoringService _monitoringService;
private AgentLogger _logger;
public AutopilotMonitorService()
{
ServiceName = "AutopilotMonitor";
CanStop = true;
CanPauseAndContinue = false;
AutoLog = true;
}
protected override void OnStart(string[] args)
{
try
{
var config = LoadConfiguration();
var logDir = Environment.ExpandEnvironmentVariables(config.LogDirectory);
_logger = new AgentLogger(logDir, config.EnableDebugLogging);
_logger.Info("Windows Service starting");
_monitoringService = new MonitoringService(config, _logger);
_monitoringService.Start();
_logger.Info("Windows Service started");
}
catch (Exception ex)
{
_logger?.Error("Failed to start service", ex);
throw;
}
}
protected override void OnStop()
{
try
{
_logger?.Info("Windows Service stopping");
_monitoringService?.Stop();
_monitoringService?.Dispose();
_logger?.Info("Windows Service stopped");
}
catch (Exception ex)
{
_logger?.Error("Error stopping service", ex);
}
}
private AgentConfiguration LoadConfiguration()
{
// TODO: Load from registry or config file
// For now, return a default configuration
return new AgentConfiguration
{
ApiBaseUrl = Environment.GetEnvironmentVariable("AUTOPILOT_MONITOR_API") ?? "http://localhost:7071",
SessionId = Guid.NewGuid().ToString(),
TenantId = "default-tenant",
SpoolDirectory = Environment.ExpandEnvironmentVariables(@"%ProgramData%\AutopilotMonitor\Spool"),
LogDirectory = Environment.ExpandEnvironmentVariables(@"%ProgramData%\AutopilotMonitor\Logs"),
UploadIntervalSeconds = 30,
MaxBatchSize = 100,
EnableDebugLogging = false
};
}
}
}
+179
View File
@@ -0,0 +1,179 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.ServiceProcess;
using AutopilotMonitor.Agent.Core.Configuration;
using AutopilotMonitor.Agent.Core.Logging;
using AutopilotMonitor.Agent.Core.Monitoring;
using Newtonsoft.Json;
namespace AutopilotMonitor.Agent
{
class Program
{
static void Main(string[] args)
{
// Check if running in console mode (for testing)
if (args.Contains("--console") || Environment.UserInteractive)
{
RunConsole(args);
}
else
{
RunService();
}
}
static void RunConsole(string[] args)
{
Console.WriteLine("Autopilot Monitor Agent - Console Mode");
Console.WriteLine("======================================");
Console.WriteLine();
try
{
var config = LoadConfiguration(args);
var logDir = Environment.ExpandEnvironmentVariables(config.LogDirectory);
var logger = new AgentLogger(logDir, enableDebug: true);
logger.Info("Agent starting in console mode");
Console.WriteLine($"Session ID: {config.SessionId}");
Console.WriteLine($"API URL: {config.ApiBaseUrl}");
Console.WriteLine($"Log Directory: {logDir}");
if (config.EnableSimulator)
{
Console.WriteLine($"Simulator Mode: ENABLED");
Console.WriteLine($"Simulate Failure: {(config.SimulateFailure ? "YES" : "NO")}");
}
if (config.UseClientCertAuth)
{
Console.WriteLine($"Client Certificate Auth: ENABLED");
if (!string.IsNullOrEmpty(config.ClientCertThumbprint))
{
Console.WriteLine($"Certificate Thumbprint: {config.ClientCertThumbprint}");
}
}
Console.WriteLine();
using (var service = new MonitoringService(config, logger))
{
service.Start();
Console.WriteLine("Agent is running. Press Enter to stop...");
Console.ReadLine();
service.Stop();
}
logger.Info("Agent stopped");
Console.WriteLine("Agent stopped.");
}
catch (Exception ex)
{
Console.WriteLine($"ERROR: {ex.Message}");
Console.WriteLine(ex.StackTrace);
}
}
static void RunService()
{
var service = new AutopilotMonitorService();
ServiceBase.Run(service);
}
static AgentConfiguration LoadConfiguration(string[] args = null)
{
// Parse command-line arguments
var enableSimulator = args?.Contains("--simulator") ?? false;
var simulateFailure = args?.Contains("--simulate-failure") ?? false;
var useAuth = args?.Contains("--use-auth") ?? false;
var noCleanup = args?.Contains("--no-cleanup") ?? false;
// Parse certificate thumbprint if provided
string certThumbprint = null;
if (args != null)
{
var thumbprintIndex = Array.IndexOf(args, "--cert-thumbprint");
if (thumbprintIndex >= 0 && thumbprintIndex + 1 < args.Length)
{
certThumbprint = args[thumbprintIndex + 1];
}
}
// Defaults (fallback values if no config file exists)
string apiBaseUrl = "http://localhost:7071";
int uploadIntervalSeconds = 30;
bool cleanupOnExit = true;
bool selfDestructOnComplete = true;
// Try to load configuration from JSON file (written by PowerShell bootstrap script)
var configFilePath = Environment.ExpandEnvironmentVariables(@"%ProgramData%\AutopilotMonitor\Config\agent-config.json");
if (File.Exists(configFilePath))
{
try
{
var configJson = File.ReadAllText(configFilePath);
var configDict = JsonConvert.DeserializeObject<Dictionary<string, object>>(configJson);
if (configDict != null)
{
if (configDict.ContainsKey("apiBaseUrl") && configDict["apiBaseUrl"] != null)
apiBaseUrl = configDict["apiBaseUrl"].ToString();
if (configDict.ContainsKey("uploadIntervalSeconds") && configDict["uploadIntervalSeconds"] != null)
uploadIntervalSeconds = Convert.ToInt32(configDict["uploadIntervalSeconds"]);
if (configDict.ContainsKey("cleanupOnExit") && configDict["cleanupOnExit"] != null)
cleanupOnExit = Convert.ToBoolean(configDict["cleanupOnExit"]);
if (configDict.ContainsKey("selfDestructOnComplete") && configDict["selfDestructOnComplete"] != null)
selfDestructOnComplete = Convert.ToBoolean(configDict["selfDestructOnComplete"]);
Console.WriteLine($"Loaded configuration from {configFilePath}");
}
}
catch (Exception ex)
{
Console.WriteLine($"Warning: Failed to load configuration file: {ex.Message}");
Console.WriteLine("Using default configuration");
}
}
else
{
Console.WriteLine($"Configuration file not found at {configFilePath}");
Console.WriteLine("Using default configuration");
}
// Environment variable overrides config file
apiBaseUrl = Environment.GetEnvironmentVariable("AUTOPILOT_MONITOR_API") ?? apiBaseUrl;
// Command-line arguments override everything (for --no-cleanup)
if (noCleanup)
{
cleanupOnExit = false;
selfDestructOnComplete = false;
}
return new AgentConfiguration
{
ApiBaseUrl = apiBaseUrl,
SessionId = Guid.NewGuid().ToString(),
//TenantId = "deadbeef-dead-beef-dead-beefdeadbeef",
TenantId = "b54dc1af-5320-4f60-b5d4-821e0cf2a359",
SpoolDirectory = Environment.ExpandEnvironmentVariables(@"%ProgramData%\AutopilotMonitor\Spool"),
LogDirectory = Environment.ExpandEnvironmentVariables(@"%ProgramData%\AutopilotMonitor\Logs"),
UploadIntervalSeconds = uploadIntervalSeconds,
MaxBatchSize = 100,
EnableDebugLogging = true,
EnableSimulator = enableSimulator,
SimulateFailure = simulateFailure,
UseClientCertAuth = useAuth,
ClientCertThumbprint = certThumbprint,
CleanupOnExit = cleanupOnExit,
SelfDestructOnComplete = selfDestructOnComplete
};
}
}
}
@@ -0,0 +1,264 @@
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
# Azure Functions localsettings file
local.settings.json
# User-specific files
*.suo
*.user
*.userosscache
*.sln.docstates
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
# Visual Studio 2015 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUNIT
*.VisualState.xml
TestResult.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# DNX
project.lock.json
project.fragment.lock.json
artifacts/
*_i.c
*_p.c
*_i.h
*.ilk
*.meta
*.obj
*.pch
*.pdb
*.pgc
*.pgd
*.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*.log
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# JustCode is a .NET coding add-in
.JustCode
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# NCrunch
_NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# TODO: Comment the next line if you want to checkin your web deploy settings
# but database connection strings (with potential passwords) will be unencrypted
#*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these scripts will be unencrypted
PublishScripts/
# NuGet Packages
*.nupkg
# The packages folder can be ignored because of Package Restore
**/packages/*
# except build/, which is used as an MSBuild target.
!**/packages/build/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/packages/repositories.config
# NuGet v3's project.json files produces more ignoreable files
*.nuget.props
*.nuget.targets
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
node_modules/
orleans.codegen.cs
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
# SQL Server files
*.mdf
*.ldf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
.paket/paket.exe
paket-files/
# FAKE - F# Make
.fake/
# JetBrains Rider
.idea/
*.sln.iml
# CodeRush
.cr/
# Python Tools for Visual Studio (PTVS)
__pycache__/
*.pyc
@@ -0,0 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<AzureFunctionsVersion>V4</AzureFunctionsVersion>
<OutputType>Exe</OutputType>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="Microsoft.ApplicationInsights.WorkerService" Version="2.23.0" />
<PackageReference Include="Microsoft.Azure.Functions.Worker" Version="2.51.0" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.ApplicationInsights" Version="2.50.0" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore" Version="2.1.0" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.SignalRService" Version="2.0.1" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Timer" Version="4.3.1" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Sdk" Version="2.0.7" />
<PackageReference Include="Azure.Data.Tables" Version="12.9.1" />
<PackageReference Include="Azure.Storage.Blobs" Version="12.22.2" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Microsoft.Identity.Web" Version="4.3.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.11" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Shared\AutopilotMonitor.Shared\AutopilotMonitor.Shared.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,112 @@
using System.Security.Claims;
using Microsoft.Azure.Functions.Worker;
namespace AutopilotMonitor.Functions.Extensions;
/// <summary>
/// Extension methods for extracting user information from JWT token claims
/// </summary>
public static class ClaimsPrincipalExtensions
{
/// <summary>
/// Extracts the ClaimsPrincipal from the FunctionContext
/// </summary>
public static ClaimsPrincipal? GetUser(this FunctionContext context)
{
// Try to get ClaimsPrincipal from HTTP context
var httpContext = context.GetHttpContext();
return httpContext?.User;
}
/// <summary>
/// Gets the tenant ID (tid) from the token claims
/// This is the tenant ID of the user's Azure AD tenant, not your tenant!
/// Supports both v1.0 and v2.0 token formats
/// </summary>
public static string? GetTenantId(this ClaimsPrincipal principal)
{
return principal.FindFirst("tid")?.Value
?? principal.FindFirst("http://schemas.microsoft.com/identity/claims/tenantid")?.Value;
}
/// <summary>
/// Gets the user's UPN (User Principal Name) from token claims
/// Falls back to preferred_username if upn is not available
/// Supports both v1.0 and v2.0 token formats
/// </summary>
public static string? GetUserPrincipalName(this ClaimsPrincipal principal)
{
return principal.FindFirst("upn")?.Value
?? principal.FindFirst("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn")?.Value
?? principal.FindFirst("preferred_username")?.Value;
}
/// <summary>
/// Gets the user's display name from token claims
/// Supports both v1.0 and v2.0 token formats
/// </summary>
public static string? GetDisplayName(this ClaimsPrincipal principal)
{
return principal.FindFirst("name")?.Value
?? principal.FindFirst("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name")?.Value;
}
/// <summary>
/// Gets the user's email address from token claims
/// Supports both v1.0 and v2.0 token formats
/// </summary>
public static string? GetEmail(this ClaimsPrincipal principal)
{
return principal.FindFirst("email")?.Value
?? principal.FindFirst("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress")?.Value;
}
/// <summary>
/// Gets the user's object ID (oid) from token claims
/// This is the unique identifier for the user in their Azure AD tenant
/// Supports both v1.0 and v2.0 token formats
/// </summary>
public static string? GetObjectId(this ClaimsPrincipal principal)
{
return principal.FindFirst("oid")?.Value
?? principal.FindFirst("http://schemas.microsoft.com/identity/claims/objectidentifier")?.Value;
}
/// <summary>
/// Gets the application (client) ID that the token was issued for
/// </summary>
public static string? GetAudience(this ClaimsPrincipal principal)
{
return principal.FindFirst("aud")?.Value;
}
/// <summary>
/// Gets the issuer of the token
/// </summary>
public static string? GetIssuer(this ClaimsPrincipal principal)
{
return principal.FindFirst("iss")?.Value;
}
/// <summary>
/// Checks if the principal has a valid tenant ID claim
/// </summary>
public static bool HasTenantId(this ClaimsPrincipal principal)
{
var tenantId = principal.GetTenantId();
return !string.IsNullOrEmpty(tenantId);
}
/// <summary>
/// Gets all claims as a dictionary for debugging purposes
/// </summary>
public static Dictionary<string, string> GetAllClaims(this ClaimsPrincipal principal)
{
return principal.Claims
.GroupBy(c => c.Type)
.ToDictionary(
g => g.Key,
g => string.Join(", ", g.Select(c => c.Value))
);
}
}
@@ -0,0 +1,225 @@
using System.Net;
using AutopilotMonitor.Functions.Extensions;
using AutopilotMonitor.Functions.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions;
/// <summary>
/// Authentication and authorization endpoints
/// </summary>
public class AuthFunction
{
private readonly ILogger<AuthFunction> _logger;
private readonly GalacticAdminService _galacticAdminService;
public AuthFunction(
ILogger<AuthFunction> logger,
GalacticAdminService galacticAdminService)
{
_logger = logger;
_galacticAdminService = galacticAdminService;
}
/// <summary>
/// GET /api/auth/me
/// Returns information about the currently authenticated user
/// </summary>
[Function("GetCurrentUser")]
[Authorize]
public async Task<HttpResponseData> GetCurrentUser(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "auth/me")] HttpRequestData req,
FunctionContext context)
{
var principal = context.GetUser();
_logger.LogInformation($"GetCurrentUser - Principal is null: {principal == null}");
if (principal == null)
{
_logger.LogWarning("GetCurrentUser - No ClaimsPrincipal found, returning Unauthorized");
return req.CreateResponse(HttpStatusCode.Unauthorized);
}
_logger.LogInformation($"GetCurrentUser - Principal identity authenticated: {principal.Identity?.IsAuthenticated}, Claims count: {principal.Claims.Count()}");
_logger.LogInformation($"GetCurrentUser - All claims: {string.Join(", ", principal.Claims.Select(c => $"{c.Type}={c.Value}"))}");
var tenantId = principal.GetTenantId();
var upn = principal.GetUserPrincipalName();
var displayName = principal.GetDisplayName();
var objectId = principal.GetObjectId();
_logger.LogInformation($"GetCurrentUser - UPN: {upn}, ObjectId: {objectId}, TenantId: {tenantId}");
// Check if user is galactic admin
var isGalacticAdmin = await _galacticAdminService.IsGalacticAdminAsync(upn);
var userInfo = new
{
tenantId,
upn,
displayName,
objectId,
isGalacticAdmin,
claims = principal.GetAllClaims() // For debugging
};
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(userInfo);
return response;
}
/// <summary>
/// GET /api/auth/is-galactic-admin
/// Checks if the current user is a Galactic Admin
/// </summary>
[Function("IsGalacticAdmin")]
[Authorize]
public async Task<HttpResponseData> IsGalacticAdmin(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "auth/is-galactic-admin")] HttpRequestData req,
FunctionContext context)
{
var principal = context.GetUser();
if (principal == null)
{
return req.CreateResponse(HttpStatusCode.Unauthorized);
}
var upn = principal.GetUserPrincipalName();
var isAdmin = await _galacticAdminService.IsGalacticAdminAsync(upn);
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new { isGalacticAdmin = isAdmin, upn });
return response;
}
/// <summary>
/// GET /api/auth/galactic-admins
/// Lists all Galactic Admins (only accessible by Galactic Admins)
/// </summary>
[Function("GetGalacticAdmins")]
[Authorize]
public async Task<HttpResponseData> GetGalacticAdmins(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "auth/galactic-admins")] HttpRequestData req,
FunctionContext context)
{
var principal = context.GetUser();
if (principal == null)
{
return req.CreateResponse(HttpStatusCode.Unauthorized);
}
var upn = principal.GetUserPrincipalName();
var isAdmin = await _galacticAdminService.IsGalacticAdminAsync(upn);
if (!isAdmin)
{
var forbiddenResponse = req.CreateResponse(HttpStatusCode.Forbidden);
await forbiddenResponse.WriteAsJsonAsync(new { error = "Only Galactic Admins can access this endpoint" });
return forbiddenResponse;
}
var admins = await _galacticAdminService.GetAllGalacticAdminsAsync();
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new { admins });
return response;
}
/// <summary>
/// POST /api/auth/galactic-admins
/// Adds a new Galactic Admin (only accessible by existing Galactic Admins)
/// </summary>
[Function("AddGalacticAdmin")]
[Authorize]
public async Task<HttpResponseData> AddGalacticAdmin(
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "auth/galactic-admins")] HttpRequestData req,
FunctionContext context)
{
var principal = context.GetUser();
if (principal == null)
{
return req.CreateResponse(HttpStatusCode.Unauthorized);
}
var currentUpn = principal.GetUserPrincipalName();
var isAdmin = await _galacticAdminService.IsGalacticAdminAsync(currentUpn);
if (!isAdmin)
{
var forbiddenResponse = req.CreateResponse(HttpStatusCode.Forbidden);
await forbiddenResponse.WriteAsJsonAsync(new { error = "Only Galactic Admins can add other admins" });
return forbiddenResponse;
}
// Parse request body
var body = await req.ReadFromJsonAsync<AddGalacticAdminRequest>();
if (body == null || string.IsNullOrWhiteSpace(body.Upn))
{
var badRequestResponse = req.CreateResponse(HttpStatusCode.BadRequest);
await badRequestResponse.WriteAsJsonAsync(new { error = "UPN is required" });
return badRequestResponse;
}
var newAdmin = await _galacticAdminService.AddGalacticAdminAsync(body.Upn, currentUpn!);
_logger.LogInformation($"Galactic Admin added: {body.Upn} by {currentUpn}");
var response = req.CreateResponse(HttpStatusCode.Created);
await response.WriteAsJsonAsync(new { admin = newAdmin });
return response;
}
/// <summary>
/// DELETE /api/auth/galactic-admins/{upn}
/// Removes a Galactic Admin (only accessible by existing Galactic Admins)
/// </summary>
[Function("RemoveGalacticAdmin")]
[Authorize]
public async Task<HttpResponseData> RemoveGalacticAdmin(
[HttpTrigger(AuthorizationLevel.Anonymous, "delete", Route = "auth/galactic-admins/{upn}")] HttpRequestData req,
string upn,
FunctionContext context)
{
var principal = context.GetUser();
if (principal == null)
{
return req.CreateResponse(HttpStatusCode.Unauthorized);
}
var currentUpn = principal.GetUserPrincipalName();
var isAdmin = await _galacticAdminService.IsGalacticAdminAsync(currentUpn);
if (!isAdmin)
{
var forbiddenResponse = req.CreateResponse(HttpStatusCode.Forbidden);
await forbiddenResponse.WriteAsJsonAsync(new { error = "Only Galactic Admins can remove other admins" });
return forbiddenResponse;
}
// Prevent self-removal
if (upn.Equals(currentUpn, StringComparison.OrdinalIgnoreCase))
{
var badRequestResponse = req.CreateResponse(HttpStatusCode.BadRequest);
await badRequestResponse.WriteAsJsonAsync(new { error = "You cannot remove yourself as a Galactic Admin" });
return badRequestResponse;
}
await _galacticAdminService.RemoveGalacticAdminAsync(upn);
_logger.LogInformation($"Galactic Admin removed: {upn} by {currentUpn}");
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new { message = "Galactic Admin removed successfully" });
return response;
}
}
public class AddGalacticAdminRequest
{
public string Upn { get; set; } = string.Empty;
}
@@ -0,0 +1,436 @@
using System;
using System.Diagnostics;
using System.Linq;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.Models;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
namespace AutopilotMonitor.Functions.Functions
{
/// <summary>
/// Daily maintenance job that:
/// 1. Marks stalled sessions (InProgress for too long) as timed out
/// 2. Aggregates yesterday's metrics into historical snapshots
/// 3. Deletes old sessions and events based on tenant retention policies
/// Runs daily at 2 AM UTC
/// </summary>
public class DailyMaintenanceFunction
{
private readonly TableStorageService _storageService;
private readonly TenantConfigurationService _tenantConfigService;
private readonly UsageMetricsService _usageMetricsService;
private readonly ILogger<DailyMaintenanceFunction> _logger;
public DailyMaintenanceFunction(
TableStorageService storageService,
TenantConfigurationService tenantConfigService,
UsageMetricsService usageMetricsService,
ILogger<DailyMaintenanceFunction> logger)
{
_storageService = storageService;
_tenantConfigService = tenantConfigService;
_usageMetricsService = usageMetricsService;
_logger = logger;
}
/// <summary>
/// Timer trigger: Runs daily at 2:00 AM UTC
/// NCRONTAB format: {second} {minute} {hour} {day} {month} {day-of-week}
/// </summary>
[Function("DailyMaintenance")]
public async Task Run([TimerTrigger("0 0 2 * * *")] object timer)
{
_logger.LogInformation($"Daily maintenance started at {DateTime.UtcNow}");
var maintenanceStart = Stopwatch.StartNew();
try
{
// Step 1: Mark stalled sessions as timed out
await MarkStalledSessionsAsTimedOutAsync();
// Step 2: Aggregate yesterday's metrics into historical snapshots
await AggregateYesterdayMetricsAsync();
// Step 3: Clean up old data based on retention policies
await CleanupOldDataAsync();
maintenanceStart.Stop();
_logger.LogInformation($"Daily maintenance completed in {maintenanceStart.ElapsedMilliseconds}ms");
}
catch (Exception ex)
{
_logger.LogError(ex, "Daily maintenance failed");
}
}
/// <summary>
/// Manually triggered maintenance with flexible date selection
/// </summary>
public async Task<MaintenanceResult> RunManualAsync(DateTime? targetDate = null, bool aggregateOnly = false, string triggeredBy = "Unknown")
{
_logger.LogInformation($"Manual maintenance triggered by {triggeredBy} at {DateTime.UtcNow}");
var maintenanceStart = Stopwatch.StartNew();
var result = new MaintenanceResult { TriggeredBy = triggeredBy, TriggeredAt = DateTime.UtcNow };
try
{
if (!aggregateOnly)
{
// Step 1: Mark stalled sessions as timed out
await MarkStalledSessionsAsTimedOutAsync();
result.StalledSessionsChecked = true;
}
// Step 2: Aggregate metrics for specified date (or yesterday if not specified)
var dateToAggregate = targetDate ?? DateTime.UtcNow.AddDays(-1).Date;
await AggregateMetricsForDateAsync(dateToAggregate);
result.AggregatedDate = dateToAggregate.ToString("yyyy-MM-dd");
result.MetricsAggregated = true;
if (!aggregateOnly)
{
// Step 3: Clean up old data based on retention policies
await CleanupOldDataAsync();
result.DataCleanupExecuted = true;
}
maintenanceStart.Stop();
result.DurationMs = (int)maintenanceStart.ElapsedMilliseconds;
result.Success = true;
_logger.LogInformation($"Manual maintenance completed in {maintenanceStart.ElapsedMilliseconds}ms");
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, "Manual maintenance failed");
result.Success = false;
result.Error = ex.Message;
maintenanceStart.Stop();
result.DurationMs = (int)maintenanceStart.ElapsedMilliseconds;
return result;
}
}
/// <summary>
/// Marks sessions that have been in "InProgress" status for too long as "Failed - Timed Out"
/// This prevents stalled sessions from running indefinitely and skewing statistics
/// </summary>
private async Task MarkStalledSessionsAsTimedOutAsync()
{
_logger.LogInformation("Checking for stalled sessions...");
var stalledStart = Stopwatch.StartNew();
try
{
// Get all unique tenant IDs
var tenantIds = await _storageService.GetAllTenantIdsAsync();
int totalSessionsTimedOut = 0;
foreach (var tenantId in tenantIds)
{
try
{
// Get tenant configuration to determine session timeout
var config = await _tenantConfigService.GetConfigurationAsync(tenantId);
var timeoutHours = config?.SessionTimeoutHours ?? 5; // Default 5 hours if not configured
var cutoffTime = DateTime.UtcNow.AddHours(-timeoutHours);
// Get all sessions for this tenant
var sessions = await _storageService.GetSessionsAsync(tenantId, maxResults: 1000000);
// Find sessions that are still in progress but started before cutoff time
var stalledSessions = sessions
.Where(s => s.Status == SessionStatus.InProgress && s.StartedAt < cutoffTime)
.ToList();
if (stalledSessions.Count == 0)
{
_logger.LogInformation($"Tenant {tenantId}: No stalled sessions found (timeout: {timeoutHours}h)");
continue;
}
int sessionCount = 0;
foreach (var session in stalledSessions)
{
// Mark session as failed with timeout reason
await _storageService.UpdateSessionStatusAsync(
session.TenantId,
session.SessionId,
SessionStatus.Failed,
failureReason: $"Session timed out after {timeoutHours} hours (started at {session.StartedAt:yyyy-MM-dd HH:mm:ss} UTC)"
);
sessionCount++;
}
totalSessionsTimedOut += sessionCount;
// Log audit entry
await _storageService.LogAuditEntryAsync(
tenantId,
"SessionTimeout",
"Session",
$"{sessionCount} sessions",
"System.DailyMaintenance",
new System.Collections.Generic.Dictionary<string, string>
{
{ "SessionsTimedOut", sessionCount.ToString() },
{ "TimeoutHours", timeoutHours.ToString() },
{ "CutoffTime", cutoffTime.ToString("yyyy-MM-dd HH:mm:ss") }
});
_logger.LogInformation($"Tenant {tenantId}: Marked {sessionCount} stalled sessions as timed out (timeout: {timeoutHours}h)");
}
catch (Exception ex)
{
_logger.LogError(ex, $"Failed to check stalled sessions for tenant {tenantId}");
}
}
stalledStart.Stop();
_logger.LogInformation($"Stalled session check completed: {totalSessionsTimedOut} sessions timed out in {stalledStart.ElapsedMilliseconds}ms");
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to check for stalled sessions");
}
}
/// <summary>
/// Aggregates yesterday's metrics and saves them as historical snapshots
/// </summary>
private async Task AggregateYesterdayMetricsAsync()
{
var yesterday = DateTime.UtcNow.AddDays(-1).Date;
await AggregateMetricsForDateAsync(yesterday);
}
/// <summary>
/// Aggregates metrics for a specific date and saves them as historical snapshots
/// </summary>
private async Task AggregateMetricsForDateAsync(DateTime targetDate)
{
_logger.LogInformation($"Aggregating metrics for {targetDate:yyyy-MM-dd}...");
var aggregateStart = Stopwatch.StartNew();
try
{
var targetDateStr = targetDate.ToString("yyyy-MM-dd");
// Get all sessions from target date
var allSessions = await _storageService.GetAllSessionsAsync(maxResults: 1000000);
var targetDateSessions = allSessions.Where(s => s.StartedAt.Date == targetDate).ToList();
if (targetDateSessions.Count == 0)
{
_logger.LogInformation($"No sessions found for {targetDateStr}");
return;
}
// Aggregate global metrics (cross-tenant)
var globalMetrics = ComputeUsageMetricsSnapshot(targetDateStr, "global", targetDateSessions);
await _storageService.SaveUsageMetricsSnapshotAsync(globalMetrics);
// Aggregate per-tenant metrics
var tenantGroups = targetDateSessions.GroupBy(s => s.TenantId);
foreach (var tenantGroup in tenantGroups)
{
var tenantMetrics = ComputeUsageMetricsSnapshot(targetDateStr, tenantGroup.Key, tenantGroup.ToList());
await _storageService.SaveUsageMetricsSnapshotAsync(tenantMetrics);
}
aggregateStart.Stop();
_logger.LogInformation($"Aggregated metrics for {targetDateSessions.Count} sessions from {targetDateStr} in {aggregateStart.ElapsedMilliseconds}ms");
}
catch (Exception ex)
{
_logger.LogError(ex, $"Failed to aggregate metrics for {targetDate:yyyy-MM-dd}");
throw; // Re-throw for manual trigger error handling
}
}
/// <summary>
/// Computes historical metrics for a specific date and tenant
/// </summary>
private UsageMetricsSnapshot ComputeUsageMetricsSnapshot(string date, string tenantId, List<SessionSummary> sessions)
{
var computeStart = Stopwatch.StartNew();
// Session statistics
var completed = sessions.Where(s => s.Status == SessionStatus.Succeeded || s.Status == SessionStatus.Failed).ToList();
var succeeded = sessions.Count(s => s.Status == SessionStatus.Succeeded);
var successRate = completed.Count > 0 ? Math.Round((succeeded / (double)completed.Count) * 100, 1) : 0;
// Performance statistics
var completedWithDuration = sessions.Where(s => s.DurationSeconds.HasValue && s.DurationSeconds.Value > 0).ToList();
double avgDuration = 0, medianDuration = 0, p95Duration = 0, p99Duration = 0;
if (completedWithDuration.Any())
{
var durations = completedWithDuration.Select(s => s.DurationSeconds!.Value / 60.0).OrderBy(d => d).ToList();
avgDuration = Math.Round(durations.Average(), 1);
medianDuration = CalculatePercentile(durations, 50);
p95Duration = CalculatePercentile(durations, 95);
p99Duration = CalculatePercentile(durations, 99);
}
// Hardware statistics (top 5 only)
var manufacturers = sessions
.GroupBy(s => s.Manufacturer)
.Select(g => new { Name = g.Key, Count = g.Count(), Percentage = Math.Round((g.Count() / (double)sessions.Count) * 100, 1) })
.OrderByDescending(x => x.Count)
.Take(5)
.ToList();
var models = sessions
.GroupBy(s => s.Model)
.Select(g => new { Name = g.Key, Count = g.Count(), Percentage = Math.Round((g.Count() / (double)sessions.Count) * 100, 1) })
.OrderByDescending(x => x.Count)
.Take(5)
.ToList();
computeStart.Stop();
return new UsageMetricsSnapshot
{
Date = date,
TenantId = tenantId,
ComputedAt = DateTime.UtcNow,
ComputeDurationMs = (int)computeStart.ElapsedMilliseconds,
SessionsTotal = sessions.Count,
SessionsSucceeded = succeeded,
SessionsFailed = sessions.Count(s => s.Status == SessionStatus.Failed),
SessionsInProgress = sessions.Count(s => s.Status == SessionStatus.InProgress),
SessionsSuccessRate = successRate,
AvgDurationMinutes = avgDuration,
MedianDurationMinutes = medianDuration,
P95DurationMinutes = p95Duration,
P99DurationMinutes = p99Duration,
UniqueTenants = tenantId == "global" ? sessions.Select(s => s.TenantId).Distinct().Count() : 0,
UniqueUsers = 0, // TODO: Implement with Entra ID
LoginCount = 0, // TODO: Implement with Entra ID
TopManufacturers = JsonConvert.SerializeObject(manufacturers),
TopModels = JsonConvert.SerializeObject(models)
};
}
/// <summary>
/// Cleans up old sessions and events based on tenant retention policies
/// </summary>
private async Task CleanupOldDataAsync()
{
_logger.LogInformation("Starting data retention cleanup...");
var cleanupStart = Stopwatch.StartNew();
try
{
// Get all unique tenant IDs
var tenantIds = await _storageService.GetAllTenantIdsAsync();
int totalSessionsDeleted = 0;
int totalEventsDeleted = 0;
foreach (var tenantId in tenantIds)
{
try
{
// Get tenant configuration to determine retention period
var config = await _tenantConfigService.GetConfigurationAsync(tenantId);
var retentionDays = config?.DataRetentionDays ?? 90; // Default 90 days if not configured
var cutoffDate = DateTime.UtcNow.AddDays(-retentionDays);
// Find sessions older than retention period
var oldSessions = await _storageService.GetSessionsOlderThanAsync(tenantId, cutoffDate);
if (oldSessions.Count == 0)
{
_logger.LogInformation($"Tenant {tenantId}: No sessions older than {retentionDays} days");
continue;
}
// Delete each session and its events
int sessionCount = 0;
int eventCount = 0;
foreach (var session in oldSessions)
{
// Delete all events for this session (no date check needed!)
var deletedEvents = await _storageService.DeleteSessionEventsAsync(session.TenantId, session.SessionId);
eventCount += deletedEvents;
// Delete the session itself
await _storageService.DeleteSessionAsync(session.TenantId, session.SessionId);
sessionCount++;
}
totalSessionsDeleted += sessionCount;
totalEventsDeleted += eventCount;
// Log audit entry
await _storageService.LogAuditEntryAsync(
tenantId,
"DataRetentionCleanup",
"Session",
$"{sessionCount} sessions",
"System.DailyMaintenance",
new System.Collections.Generic.Dictionary<string, string>
{
{ "SessionsDeleted", sessionCount.ToString() },
{ "EventsDeleted", eventCount.ToString() },
{ "RetentionDays", retentionDays.ToString() },
{ "CutoffDate", cutoffDate.ToString("yyyy-MM-dd") }
});
_logger.LogInformation($"Tenant {tenantId}: Deleted {sessionCount} sessions and {eventCount} events (retention: {retentionDays} days)");
}
catch (Exception ex)
{
_logger.LogError(ex, $"Failed to cleanup data for tenant {tenantId}");
}
}
cleanupStart.Stop();
_logger.LogInformation($"Data retention cleanup completed: {totalSessionsDeleted} sessions and {totalEventsDeleted} events deleted in {cleanupStart.ElapsedMilliseconds}ms");
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to cleanup old data");
}
}
/// <summary>
/// Calculates percentile value from sorted list
/// </summary>
private double CalculatePercentile(List<double> sortedValues, int percentile)
{
if (sortedValues.Count == 0) return 0;
var index = (int)Math.Ceiling((percentile / 100.0) * sortedValues.Count) - 1;
index = Math.Max(0, Math.Min(index, sortedValues.Count - 1));
return Math.Round(sortedValues[index], 1);
}
}
/// <summary>
/// Result of a maintenance operation
/// </summary>
public class MaintenanceResult
{
public bool Success { get; set; }
public string? Error { get; set; }
public string TriggeredBy { get; set; } = string.Empty;
public DateTime TriggeredAt { get; set; }
public int DurationMs { get; set; }
public bool StalledSessionsChecked { get; set; }
public bool MetricsAggregated { get; set; }
public string? AggregatedDate { get; set; }
public bool DataCleanupExecuted { get; set; }
}
}
@@ -0,0 +1,92 @@
using System.Net;
using AutopilotMonitor.Functions.Services;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions
{
public class DeleteSessionFunction
{
private readonly ILogger<DeleteSessionFunction> _logger;
private readonly TableStorageService _storageService;
public DeleteSessionFunction(ILogger<DeleteSessionFunction> logger, TableStorageService storageService)
{
_logger = logger;
_storageService = storageService;
}
[Function("DeleteSession")]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "delete", Route = "sessions/{sessionId}")] HttpRequestData req,
string sessionId)
{
_logger.LogInformation($"DeleteSession function processing request for session {sessionId}");
try
{
// Get tenant ID from query parameter (for now, default to demo GUID)
var tenantId = req.Query["tenantId"] ?? "deadbeef-dead-beef-dead-beefdeadbeef";
_logger.LogInformation($"Deleting session {sessionId} for tenant {tenantId}");
// Delete all events for this session
var eventsDeleted = await _storageService.DeleteSessionEventsAsync(tenantId, sessionId);
_logger.LogInformation($"Deleted {eventsDeleted} events for session {sessionId}");
// Delete the session itself
var sessionDeleted = await _storageService.DeleteSessionAsync(tenantId, sessionId);
if (sessionDeleted)
{
// Log audit entry
await _storageService.LogAuditEntryAsync(
tenantId,
"DELETE",
"Session",
sessionId,
"Admin", // TODO: Get from authenticated user
new Dictionary<string, string>
{
{ "EventsDeleted", eventsDeleted.ToString() }
}
);
_logger.LogInformation($"Successfully deleted session {sessionId}");
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new
{
success = true,
message = $"Session {sessionId} and {eventsDeleted} events deleted successfully"
});
return response;
}
else
{
_logger.LogWarning($"Session {sessionId} not found");
var response = req.CreateResponse(HttpStatusCode.NotFound);
await response.WriteAsJsonAsync(new
{
success = false,
message = $"Session {sessionId} not found"
});
return response;
}
}
catch (Exception ex)
{
_logger.LogError(ex, $"Error deleting session {sessionId}");
var errorResponse = req.CreateResponse(HttpStatusCode.InternalServerError);
await errorResponse.WriteAsJsonAsync(new
{
success = false,
message = "Internal server error"
});
return errorResponse;
}
}
}
}
@@ -0,0 +1,60 @@
using System.Net;
using AutopilotMonitor.Functions.Services;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions
{
public class GetAllSessionsFunction
{
private readonly ILogger<GetAllSessionsFunction> _logger;
private readonly TableStorageService _storageService;
public GetAllSessionsFunction(ILogger<GetAllSessionsFunction> logger, TableStorageService storageService)
{
_logger = logger;
_storageService = storageService;
}
[Function("GetAllSessions")]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "galactic/sessions")] HttpRequestData req)
{
_logger.LogInformation("GetAllSessions function processing request (Galactic Admin Mode)");
try
{
_logger.LogInformation("Fetching all sessions across all tenants");
// Get all sessions from storage (no tenant filter)
var sessions = await _storageService.GetAllSessionsAsync(maxResults: 100);
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new
{
success = true,
count = sessions.Count,
sessions = sessions
});
return response;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting all sessions");
var errorResponse = req.CreateResponse(HttpStatusCode.InternalServerError);
await errorResponse.WriteAsJsonAsync(new
{
success = false,
message = "Internal server error",
count = 0,
sessions = Array.Empty<object>()
});
return errorResponse;
}
}
}
}
@@ -0,0 +1,63 @@
using System.Net;
using AutopilotMonitor.Functions.Services;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions
{
public class GetAuditLogsFunction
{
private readonly ILogger<GetAuditLogsFunction> _logger;
private readonly TableStorageService _storageService;
public GetAuditLogsFunction(ILogger<GetAuditLogsFunction> logger, TableStorageService storageService)
{
_logger = logger;
_storageService = storageService;
}
[Function("GetAuditLogs")]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "audit/logs")] HttpRequestData req)
{
_logger.LogInformation("GetAuditLogs function processing request");
try
{
// Get tenant ID from query parameter (for now, default to demo GUID)
var tenantId = req.Query["tenantId"] ?? "deadbeef-dead-beef-dead-beefdeadbeef";
_logger.LogInformation($"Fetching audit logs for tenant {tenantId}");
// Get audit logs from storage
var logs = await _storageService.GetAuditLogsAsync(tenantId, maxResults: 100);
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new
{
success = true,
count = logs.Count,
logs = logs
});
return response;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting audit logs");
var errorResponse = req.CreateResponse(HttpStatusCode.InternalServerError);
await errorResponse.WriteAsJsonAsync(new
{
success = false,
message = "Internal server error",
count = 0,
logs = Array.Empty<object>()
});
return errorResponse;
}
}
}
}
@@ -0,0 +1,72 @@
using System.Net;
using AutopilotMonitor.Functions.Services;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions
{
public class GetSessionEventsFunction
{
private readonly ILogger<GetSessionEventsFunction> _logger;
private readonly TableStorageService _storageService;
public GetSessionEventsFunction(ILogger<GetSessionEventsFunction> logger, TableStorageService storageService)
{
_logger = logger;
_storageService = storageService;
}
[Function("GetSessionEvents")]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "sessions/{sessionId}/events")] HttpRequestData req,
string sessionId)
{
if (string.IsNullOrEmpty(sessionId))
{
var badRequestResponse = req.CreateResponse(HttpStatusCode.BadRequest);
await badRequestResponse.WriteAsJsonAsync(new { success = false, message = "SessionId is required" });
return badRequestResponse;
}
var sessionPrefix = $"[Session: {sessionId.Substring(0, Math.Min(8, sessionId.Length))}]";
_logger.LogInformation($"{sessionPrefix} GetSessionEvents: Fetching events");
try
{
// Get tenant ID from query parameter (for now, default to demo GUID)
var tenantId = req.Query["tenantId"] ?? "deadbeef-dead-beef-dead-beefdeadbeef";
// Get events from storage
var events = await _storageService.GetSessionEventsAsync(tenantId, sessionId);
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new
{
success = true,
sessionId = sessionId,
count = events.Count,
events = events
});
return response;
}
catch (Exception ex)
{
_logger.LogError(ex, $"Error getting events for session {sessionId}");
var errorResponse = req.CreateResponse(HttpStatusCode.InternalServerError);
await errorResponse.WriteAsJsonAsync(new
{
success = false,
message = "Internal server error",
sessionId = sessionId,
count = 0,
events = Array.Empty<object>()
});
return errorResponse;
}
}
}
}
@@ -0,0 +1,63 @@
using System.Net;
using AutopilotMonitor.Functions.Services;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions
{
public class GetSessionsFunction
{
private readonly ILogger<GetSessionsFunction> _logger;
private readonly TableStorageService _storageService;
public GetSessionsFunction(ILogger<GetSessionsFunction> logger, TableStorageService storageService)
{
_logger = logger;
_storageService = storageService;
}
[Function("GetSessions")]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "sessions")] HttpRequestData req)
{
_logger.LogInformation("GetSessions function processing request");
try
{
// Get tenant ID from query parameter (for now, default to demo GUID)
var tenantId = req.Query["tenantId"] ?? "deadbeef-dead-beef-dead-beefdeadbeef";
_logger.LogInformation($"Fetching sessions for tenant {tenantId}");
// Get sessions from storage
var sessions = await _storageService.GetSessionsAsync(tenantId, maxResults: 100);
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new
{
success = true,
count = sessions.Count,
sessions = sessions
});
return response;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting sessions");
var errorResponse = req.CreateResponse(HttpStatusCode.InternalServerError);
await errorResponse.WriteAsJsonAsync(new
{
success = false,
message = "Internal server error",
count = 0,
sessions = Array.Empty<object>()
});
return errorResponse;
}
}
}
}
@@ -0,0 +1,48 @@
using System;
using System.Net;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Services;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions
{
public class GetTenantConfigurationFunction
{
private readonly ILogger<GetTenantConfigurationFunction> _logger;
private readonly TenantConfigurationService _configService;
public GetTenantConfigurationFunction(
ILogger<GetTenantConfigurationFunction> logger,
TenantConfigurationService configService)
{
_logger = logger;
_configService = configService;
}
[Function("GetTenantConfiguration")]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "config/{tenantId}")] HttpRequestData req,
string tenantId)
{
try
{
_logger.LogInformation($"GetTenantConfiguration: {tenantId}");
var config = await _configService.GetConfigurationAsync(tenantId);
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(config);
return response;
}
catch (Exception ex)
{
_logger.LogError(ex, $"Error getting configuration for tenant {tenantId}");
var response = req.CreateResponse(HttpStatusCode.InternalServerError);
await response.WriteAsJsonAsync(new { error = "Internal server error" });
return response;
}
}
}
}
@@ -0,0 +1,96 @@
using System.Net;
using AutopilotMonitor.Functions.Extensions;
using AutopilotMonitor.Functions.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions
{
public class HealthCheckFunction
{
private readonly ILogger<HealthCheckFunction> _logger;
private readonly HealthCheckService _healthCheckService;
private readonly GalacticAdminService _galacticAdminService;
public HealthCheckFunction(
ILogger<HealthCheckFunction> logger,
HealthCheckService healthCheckService,
GalacticAdminService galacticAdminService)
{
_logger = logger;
_healthCheckService = healthCheckService;
_galacticAdminService = galacticAdminService;
}
/// <summary>
/// GET /api/health
/// Basic health check endpoint (anonymous access)
/// </summary>
[Function("HealthCheck")]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "health")] HttpRequestData req)
{
_logger.LogInformation("Basic health check requested");
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new
{
status = "healthy",
service = "Autopilot Monitor API",
version = "1.0.0-phase1",
timestamp = DateTime.UtcNow
});
return response;
}
/// <summary>
/// GET /api/health/detailed
/// Detailed health check with comprehensive system checks (Galactic Admin only)
/// </summary>
[Function("DetailedHealthCheck")]
[Authorize]
public async Task<HttpResponseData> GetDetailedHealthCheck(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "health/detailed")] HttpRequestData req,
FunctionContext context)
{
_logger.LogInformation("Detailed health check requested");
// Check if user is galactic admin
var principal = context.GetUser();
if (principal == null)
{
return req.CreateResponse(HttpStatusCode.Unauthorized);
}
var upn = principal.GetUserPrincipalName();
var isGalacticAdmin = await _galacticAdminService.IsGalacticAdminAsync(upn);
if (!isGalacticAdmin)
{
var forbiddenResponse = req.CreateResponse(HttpStatusCode.Forbidden);
await forbiddenResponse.WriteAsJsonAsync(new { error = "Only Galactic Admins can access detailed health checks" });
return forbiddenResponse;
}
// Perform comprehensive health checks
var healthCheckResult = await _healthCheckService.PerformAllChecksAsync();
// Always return 200 OK with the health status in the body
// This allows the frontend to properly display the results even if some checks fail
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new
{
service = "Autopilot Monitor API",
version = "1.0.0-phase1",
timestamp = healthCheckResult.Timestamp,
overallStatus = healthCheckResult.OverallStatus,
checks = healthCheckResult.Checks
});
return response;
}
}
}
@@ -0,0 +1,312 @@
using System.IO.Compression;
using System.Net;
using System.Text;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.Models;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Azure.Functions.Worker.Extensions.SignalRService;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
namespace AutopilotMonitor.Functions.Functions
{
public class IngestEventsFunction
{
private readonly ILogger<IngestEventsFunction> _logger;
private readonly TableStorageService _storageService;
private readonly TenantConfigurationService _configService;
private readonly RateLimitService _rateLimitService;
public IngestEventsFunction(
ILogger<IngestEventsFunction> logger,
TableStorageService storageService,
TenantConfigurationService configService,
RateLimitService rateLimitService)
{
_logger = logger;
_storageService = storageService;
_configService = configService;
_rateLimitService = rateLimitService;
}
[Function("IngestEvents")]
public async Task<IngestEventsOutput> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "events/ingest")] HttpRequestData req)
{
try
{
// Parse request - support both NDJSON+gzip (new) and JSON (legacy)
IngestEventsRequest? request = null;
// Check Content-Encoding header for gzip
var contentEncoding = req.Headers.Contains("Content-Encoding")
? req.Headers.GetValues("Content-Encoding").FirstOrDefault()
: null;
var contentType = req.Headers.Contains("Content-Type")
? req.Headers.GetValues("Content-Type").FirstOrDefault()
: "application/json";
if (contentEncoding == "gzip" && contentType?.Contains("application/x-ndjson") == true)
{
// New format: NDJSON + gzip
_logger.LogDebug("Parsing gzip-compressed NDJSON request");
request = await ParseNdjsonGzipRequest(req.Body);
}
else
{
// Legacy format: standard JSON (backwards compatibility)
_logger.LogDebug("Parsing legacy JSON request");
var requestBody = await new StreamReader(req.Body).ReadToEndAsync();
request = JsonConvert.DeserializeObject<IngestEventsRequest>(requestBody);
}
if (request?.Events == null || request.Events.Count == 0)
{
return await CreateErrorResponse(req, HttpStatusCode.BadRequest, "No events provided");
}
if (string.IsNullOrEmpty(request.SessionId) || string.IsNullOrEmpty(request.TenantId))
{
return await CreateErrorResponse(req, HttpStatusCode.BadRequest, "SessionId and TenantId are required");
}
// Validate request security (certificate, rate limit, hardware whitelist)
var (validation, errorResponse) = await req.ValidateSecurityAsync(
request.TenantId,
_configService,
_rateLimitService,
_logger
);
if (errorResponse != null)
{
// Security validation failed - return error response
return new IngestEventsOutput
{
HttpResponse = errorResponse,
SignalRMessages = Array.Empty<SignalRMessageAction>()
};
}
var sessionPrefix = $"[Session: {request.SessionId.Substring(0, Math.Min(8, request.SessionId.Length))}]";
_logger.LogInformation($"{sessionPrefix} IngestEvents: Processing {request.Events.Count} events (Device: {validation.CertificateThumbprint}, Hardware: {validation.Manufacturer} {validation.Model}, Rate: {validation.RateLimitResult?.RequestsInWindow}/{validation.RateLimitResult?.MaxRequests})");
// Store events in Azure Table Storage
int processedCount = 0;
var storedEvents = new List<EnrollmentEvent>();
EnrollmentEvent? lastPhaseChangeEvent = null;
EnrollmentEvent? completionEvent = null;
EnrollmentEvent? failureEvent = null;
foreach (var evt in request.Events)
{
var stored = await _storageService.StoreEventAsync(evt);
if (stored)
{
processedCount++;
storedEvents.Add(evt);
_logger.LogDebug($"Event: {evt.EventType} - {evt.Message}");
// Track special events for session status updates
if (evt.EventType == "phase_changed")
{
lastPhaseChangeEvent = evt;
}
else if (evt.EventType == "enrollment_complete")
{
completionEvent = evt;
}
else if (evt.EventType == "enrollment_failed")
{
failureEvent = evt;
}
}
else
{
_logger.LogWarning($"Failed to store event: {evt.EventType}");
}
}
// Update session status based on events
if (completionEvent != null)
{
// Enrollment completed successfully
await _storageService.UpdateSessionStatusAsync(
request.TenantId,
request.SessionId,
SessionStatus.Succeeded,
completionEvent.Phase
);
_logger.LogInformation($"{sessionPrefix} Status: Succeeded");
}
else if (failureEvent != null)
{
// Enrollment failed
var failureReason = failureEvent.Data?.ContainsKey("errorCode") == true
? $"{failureEvent.Message} ({failureEvent.Data["errorCode"]})"
: failureEvent.Message;
await _storageService.UpdateSessionStatusAsync(
request.TenantId,
request.SessionId,
SessionStatus.Failed,
failureEvent.Phase,
failureReason
);
_logger.LogWarning($"{sessionPrefix} Status: Failed - {failureReason}");
}
else if (lastPhaseChangeEvent != null)
{
// Update current phase (still in progress)
await _storageService.UpdateSessionStatusAsync(
request.TenantId,
request.SessionId,
SessionStatus.InProgress,
lastPhaseChangeEvent.Phase
);
}
// Retrieve updated session data to include in SignalR messages
var updatedSession = await _storageService.GetSessionAsync(request.TenantId, request.SessionId);
var response = req.CreateResponse(HttpStatusCode.OK);
var responseData = new IngestEventsResponse
{
Success = true,
EventsReceived = request.Events.Count,
EventsProcessed = processedCount,
Message = $"Successfully stored {processedCount} of {request.Events.Count} events",
ProcessedAt = DateTime.UtcNow
};
await response.WriteAsJsonAsync(responseData);
// Send SignalR notifications using Groups for multi-tenancy and cost optimization
// Include session data so frontend doesn't need to fetch it (cost optimization)
// 1. Summary notification for session list updates (tenant-specific)
// Only sent to clients in the tenant group - prevents cross-tenant data leaks
var summaryMessage = new SignalRMessageAction("newevents")
{
GroupName = $"tenant-{request.TenantId}",
Arguments = new[] { new {
sessionId = request.SessionId,
tenantId = request.TenantId,
eventCount = processedCount,
session = updatedSession // Include full session data
} }
};
// 2. Detailed events for real-time event streaming on detail pages (session-specific)
// Only sent to clients viewing this specific session - massive cost savings
var eventsMessage = new SignalRMessageAction("eventStream")
{
GroupName = $"session-{request.TenantId}-{request.SessionId}",
Arguments = new object[] { new {
sessionId = request.SessionId,
tenantId = request.TenantId,
events = storedEvents,
session = updatedSession // Include full session data
} }
};
return new IngestEventsOutput
{
HttpResponse = response,
SignalRMessages = new[] { summaryMessage, eventsMessage }
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Error ingesting events");
return await CreateErrorResponse(req, HttpStatusCode.InternalServerError, "Internal server error");
}
}
/// <summary>
/// Parses NDJSON + gzip compressed request
/// Format: First line is metadata (sessionId, tenantId), subsequent lines are events
/// </summary>
private async Task<IngestEventsRequest> ParseNdjsonGzipRequest(Stream body)
{
// Decompress gzip
using var decompressed = new MemoryStream();
using (var gzip = new GZipStream(body, CompressionMode.Decompress, leaveOpen: true))
{
await gzip.CopyToAsync(decompressed);
}
decompressed.Position = 0;
var ndjson = await new StreamReader(decompressed, Encoding.UTF8).ReadToEndAsync();
// Parse NDJSON (newline-delimited JSON)
var lines = ndjson.Split('\n', StringSplitOptions.RemoveEmptyEntries);
if (lines.Length < 1)
{
throw new InvalidOperationException("NDJSON must contain at least metadata line");
}
// First line: metadata
var metadata = JsonConvert.DeserializeObject<NdjsonMetadata>(lines[0]);
if (metadata == null)
{
throw new InvalidOperationException("Failed to parse NDJSON metadata");
}
// Subsequent lines: events
var events = new List<EnrollmentEvent>();
for (int i = 1; i < lines.Length; i++)
{
var evt = JsonConvert.DeserializeObject<EnrollmentEvent>(lines[i]);
if (evt != null)
{
events.Add(evt);
}
}
return new IngestEventsRequest
{
SessionId = metadata.SessionId,
TenantId = metadata.TenantId,
Events = events
};
}
private async Task<IngestEventsOutput> CreateErrorResponse(HttpRequestData req, HttpStatusCode statusCode, string message)
{
var response = req.CreateResponse(statusCode);
var errorResponse = new IngestEventsResponse
{
Success = false,
EventsReceived = 0,
EventsProcessed = 0,
Message = message,
ProcessedAt = DateTime.UtcNow
};
await response.WriteAsJsonAsync(errorResponse);
return new IngestEventsOutput { HttpResponse = response, SignalRMessages = Array.Empty<SignalRMessageAction>() };
}
}
public class IngestEventsOutput
{
[HttpResult]
public HttpResponseData? HttpResponse { get; set; }
[SignalROutput(HubName = "autopilotmonitor")]
public SignalRMessageAction[]? SignalRMessages { get; set; }
}
/// <summary>
/// NDJSON metadata (first line of NDJSON payload)
/// </summary>
internal class NdjsonMetadata
{
public string SessionId { get; set; } = string.Empty;
public string TenantId { get; set; } = string.Empty;
}
}
@@ -0,0 +1,126 @@
using System.Net;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.Models;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Azure.Functions.Worker.Extensions.SignalRService;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions
{
public class MarkSessionFailedFunction
{
private readonly ILogger<MarkSessionFailedFunction> _logger;
private readonly TableStorageService _storageService;
public MarkSessionFailedFunction(ILogger<MarkSessionFailedFunction> logger, TableStorageService storageService)
{
_logger = logger;
_storageService = storageService;
}
[Function("MarkSessionFailed")]
public async Task<MarkSessionFailedOutput> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "sessions/{sessionId}/mark-failed")] HttpRequestData req,
string sessionId)
{
_logger.LogInformation($"MarkSessionFailed function processing request for session {sessionId}");
try
{
// Get tenant ID from query parameter (for now, default to demo GUID)
var tenantId = req.Query["tenantId"] ?? "deadbeef-dead-beef-dead-beefdeadbeef";
_logger.LogInformation($"Marking session {sessionId} as failed for tenant {tenantId}");
// Update session status to Failed with manual failure reason
var success = await _storageService.UpdateSessionStatusAsync(
tenantId,
sessionId,
SessionStatus.Failed,
null, // Keep current phase
"Manually marked as failed by administrator"
);
if (success)
{
// Log audit entry
await _storageService.LogAuditEntryAsync(
tenantId,
"UPDATE",
"Session",
sessionId,
"Admin", // TODO: Get from authenticated user
new Dictionary<string, string>
{
{ "Action", "MarkAsFailed" },
{ "Reason", "Manually marked as failed" }
}
);
// Retrieve updated session data to include in SignalR message
var updatedSession = await _storageService.GetSessionAsync(tenantId, sessionId);
_logger.LogInformation($"Successfully marked session {sessionId} as failed");
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new
{
success = true,
message = $"Session {sessionId} marked as failed"
});
// Send SignalR notification to update all clients
var signalRMessage = new SignalRMessageAction("newevents")
{
GroupName = $"tenant-{tenantId}",
Arguments = new[] { new {
sessionId = sessionId,
tenantId = tenantId,
eventCount = 0,
session = updatedSession
} }
};
return new MarkSessionFailedOutput
{
HttpResponse = response,
SignalRMessages = new[] { signalRMessage }
};
}
else
{
_logger.LogWarning($"Session {sessionId} not found");
var response = req.CreateResponse(HttpStatusCode.NotFound);
await response.WriteAsJsonAsync(new
{
success = false,
message = $"Session {sessionId} not found"
});
return new MarkSessionFailedOutput { HttpResponse = response };
}
}
catch (Exception ex)
{
_logger.LogError(ex, $"Error marking session {sessionId} as failed");
var errorResponse = req.CreateResponse(HttpStatusCode.InternalServerError);
await errorResponse.WriteAsJsonAsync(new
{
success = false,
message = "Internal server error"
});
return new MarkSessionFailedOutput { HttpResponse = errorResponse };
}
}
}
public class MarkSessionFailedOutput
{
[HttpResult]
public HttpResponseData? HttpResponse { get; set; }
[SignalROutput(HubName = "autopilotmonitor")]
public SignalRMessageAction[]? SignalRMessages { get; set; }
}
}
@@ -0,0 +1,70 @@
using System.Net;
using AutopilotMonitor.Functions.Services;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions
{
/// <summary>
/// Function for retrieving platform usage metrics (Galactic Admin only)
/// </summary>
public class PlatformUsageMetricsFunction
{
private readonly ILogger<PlatformUsageMetricsFunction> _logger;
private readonly UsageMetricsService _usageMetricsService;
public PlatformUsageMetricsFunction(
ILogger<PlatformUsageMetricsFunction> logger,
UsageMetricsService usageMetricsService)
{
_logger = logger;
_usageMetricsService = usageMetricsService;
}
/// <summary>
/// GET /api/platform/usage-metrics - Compute and return platform usage metrics
/// On-demand computation with 5-minute cache (Galactic Admin only)
/// </summary>
[Function("GetPlatformUsageMetrics")]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "platform/usage-metrics")]
HttpRequestData req)
{
_logger.LogInformation("Usage metrics requested");
try
{
// TODO: Add Entra ID authentication check here (Galactic Admin role required)
// When Entra ID is implemented:
// 1. Extract JWT token from Authorization header
// 2. Validate token with Azure AD
// 3. Check user has "GalacticAdmin" role
// 4. Return 403 Forbidden if not authorized
//
// For now, anyone can access (will be restricted with Entra ID later)
var metrics = await _usageMetricsService.ComputeUsageMetricsAsync();
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(metrics);
return response;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error computing usage metrics");
var errorResponse = req.CreateResponse(HttpStatusCode.InternalServerError);
await errorResponse.WriteAsJsonAsync(new
{
success = false,
message = "Failed to compute usage metrics",
error = ex.Message
});
return errorResponse;
}
}
}
}
@@ -0,0 +1,105 @@
using System.Net;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.Models;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
namespace AutopilotMonitor.Functions.Functions
{
public class RegisterSessionFunction
{
private readonly ILogger<RegisterSessionFunction> _logger;
private readonly TableStorageService _storageService;
private readonly TenantConfigurationService _configService;
private readonly RateLimitService _rateLimitService;
public RegisterSessionFunction(
ILogger<RegisterSessionFunction> logger,
TableStorageService storageService,
TenantConfigurationService configService,
RateLimitService rateLimitService)
{
_logger = logger;
_storageService = storageService;
_configService = configService;
_rateLimitService = rateLimitService;
}
[Function("RegisterSession")]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "sessions/register")] HttpRequestData req)
{
_logger.LogInformation("RegisterSession function processing request");
try
{
// Parse request
var requestBody = await new StreamReader(req.Body).ReadToEndAsync();
var request = JsonConvert.DeserializeObject<RegisterSessionRequest>(requestBody);
if (request?.Registration == null)
{
return await CreateErrorResponse(req, HttpStatusCode.BadRequest, "Invalid request payload");
}
var registration = request.Registration;
// Validate request security (certificate, rate limit, hardware whitelist)
var (validation, errorResponse) = await req.ValidateSecurityAsync(
registration.TenantId,
_configService,
_rateLimitService,
_logger
);
if (errorResponse != null)
{
return errorResponse;
}
_logger.LogInformation($"Registering session {registration.SessionId} for tenant {registration.TenantId} (Device: {validation.CertificateThumbprint})");
// Store session in Azure Table Storage
var stored = await _storageService.StoreSessionAsync(registration);
if (!stored)
{
return await CreateErrorResponse(req, HttpStatusCode.InternalServerError, "Failed to store session");
}
var response = req.CreateResponse(HttpStatusCode.OK);
var responseData = new RegisterSessionResponse
{
SessionId = registration.SessionId,
Success = true,
Message = "Session registered successfully",
RegisteredAt = DateTime.UtcNow
};
await response.WriteAsJsonAsync(responseData);
return response;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error registering session");
return await CreateErrorResponse(req, HttpStatusCode.InternalServerError, "Internal server error");
}
}
private async Task<HttpResponseData> CreateErrorResponse(HttpRequestData req, HttpStatusCode statusCode, string message)
{
var response = req.CreateResponse(statusCode);
var errorResponse = new RegisterSessionResponse
{
Success = false,
Message = message,
RegisteredAt = DateTime.UtcNow
};
await response.WriteAsJsonAsync(errorResponse);
return response;
}
}
}
@@ -0,0 +1,97 @@
using System.Net;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Azure.Functions.Worker.Extensions.SignalRService;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
namespace AutopilotMonitor.Functions.Functions
{
public class SignalRAddToGroupFunction
{
private readonly ILogger<SignalRAddToGroupFunction> _logger;
public SignalRAddToGroupFunction(ILogger<SignalRAddToGroupFunction> logger)
{
_logger = logger;
}
[Function("AddToGroup")]
public async Task<AddToGroupOutput> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "realtime/groups/join")] HttpRequestData req)
{
try
{
// Parse request
var requestBody = await new StreamReader(req.Body).ReadToEndAsync();
var request = JsonConvert.DeserializeObject<AddToGroupRequest>(requestBody);
if (string.IsNullOrEmpty(request?.ConnectionId) || string.IsNullOrEmpty(request?.GroupName))
{
var errorResponse = req.CreateResponse(HttpStatusCode.BadRequest);
await errorResponse.WriteAsJsonAsync(new { success = false, message = "ConnectionId and GroupName are required" });
return new AddToGroupOutput { HttpResponse = errorResponse };
}
// Extract session ID from group name if it's a session-specific group
var logPrefix = ExtractLogPrefix(request.GroupName);
_logger.LogInformation($"{logPrefix} AddToGroup: {request.GroupName}");
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new
{
success = true,
message = $"Added to group {request.GroupName}"
});
return new AddToGroupOutput
{
HttpResponse = response,
SignalRGroupAction = new SignalRGroupAction(SignalRGroupActionType.Add)
{
GroupName = request.GroupName,
ConnectionId = request.ConnectionId
}
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Error adding to group");
var errorResponse = req.CreateResponse(HttpStatusCode.InternalServerError);
await errorResponse.WriteAsJsonAsync(new { success = false, message = "Internal server error" });
return new AddToGroupOutput { HttpResponse = errorResponse };
}
}
private string ExtractLogPrefix(string groupName)
{
// Extract session ID from group name: "session-{tenantId}-{sessionId}"
if (groupName.StartsWith("session-"))
{
var parts = groupName.Split('-');
if (parts.Length > 2)
{
var sessionId = string.Join("-", parts.Skip(parts.Length - 5).Take(5)); // Last 5 parts form the GUID
return $"[Session: {sessionId.Substring(0, Math.Min(8, sessionId.Length))}]";
}
}
// For tenant groups: "tenant-{tenantId}"
return $"[Group: {groupName.Substring(0, Math.Min(20, groupName.Length))}]";
}
}
public class AddToGroupRequest
{
public string? ConnectionId { get; set; }
public string? GroupName { get; set; }
}
public class AddToGroupOutput
{
[HttpResult]
public HttpResponseData? HttpResponse { get; set; }
[SignalROutput(HubName = "autopilotmonitor")]
public SignalRGroupAction? SignalRGroupAction { get; set; }
}
}
@@ -0,0 +1,30 @@
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Azure.Functions.Worker.Extensions.SignalRService;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions
{
public class SignalRNegotiateFunction
{
private readonly ILogger<SignalRNegotiateFunction> _logger;
public SignalRNegotiateFunction(ILogger<SignalRNegotiateFunction> logger)
{
_logger = logger;
}
[Function("negotiate")]
public HttpResponseData Negotiate(
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "realtime/negotiate")] HttpRequestData req,
[SignalRConnectionInfoInput(HubName = "autopilotmonitor")] SignalRConnectionInfo connectionInfo)
{
_logger.LogInformation("SignalR negotiate request");
var response = req.CreateResponse(System.Net.HttpStatusCode.OK);
response.WriteAsJsonAsync(connectionInfo);
return response;
}
}
}
@@ -0,0 +1,97 @@
using System.Net;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Azure.Functions.Worker.Extensions.SignalRService;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
namespace AutopilotMonitor.Functions.Functions
{
public class SignalRRemoveFromGroupFunction
{
private readonly ILogger<SignalRRemoveFromGroupFunction> _logger;
public SignalRRemoveFromGroupFunction(ILogger<SignalRRemoveFromGroupFunction> logger)
{
_logger = logger;
}
[Function("RemoveFromGroup")]
public async Task<RemoveFromGroupOutput> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "realtime/groups/leave")] HttpRequestData req)
{
try
{
// Parse request
var requestBody = await new StreamReader(req.Body).ReadToEndAsync();
var request = JsonConvert.DeserializeObject<RemoveFromGroupRequest>(requestBody);
if (string.IsNullOrEmpty(request?.ConnectionId) || string.IsNullOrEmpty(request?.GroupName))
{
var errorResponse = req.CreateResponse(HttpStatusCode.BadRequest);
await errorResponse.WriteAsJsonAsync(new { success = false, message = "ConnectionId and GroupName are required" });
return new RemoveFromGroupOutput { HttpResponse = errorResponse };
}
// Extract session ID from group name if it's a session-specific group
var logPrefix = ExtractLogPrefix(request.GroupName);
_logger.LogInformation($"{logPrefix} RemoveFromGroup: {request.GroupName}");
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new
{
success = true,
message = $"Removed from group {request.GroupName}"
});
return new RemoveFromGroupOutput
{
HttpResponse = response,
SignalRGroupAction = new SignalRGroupAction(SignalRGroupActionType.Remove)
{
GroupName = request.GroupName,
ConnectionId = request.ConnectionId
}
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Error removing from group");
var errorResponse = req.CreateResponse(HttpStatusCode.InternalServerError);
await errorResponse.WriteAsJsonAsync(new { success = false, message = "Internal server error" });
return new RemoveFromGroupOutput { HttpResponse = errorResponse };
}
}
private string ExtractLogPrefix(string groupName)
{
// Extract session ID from group name: "session-{tenantId}-{sessionId}"
if (groupName.StartsWith("session-"))
{
var parts = groupName.Split('-');
if (parts.Length > 2)
{
var sessionId = string.Join("-", parts.Skip(parts.Length - 5).Take(5)); // Last 5 parts form the GUID
return $"[Session: {sessionId.Substring(0, Math.Min(8, sessionId.Length))}]";
}
}
// For tenant groups: "tenant-{tenantId}"
return $"[Group: {groupName.Substring(0, Math.Min(20, groupName.Length))}]";
}
}
public class RemoveFromGroupRequest
{
public string? ConnectionId { get; set; }
public string? GroupName { get; set; }
}
public class RemoveFromGroupOutput
{
[HttpResult]
public HttpResponseData? HttpResponse { get; set; }
[SignalROutput(HubName = "autopilotmonitor")]
public SignalRGroupAction? SignalRGroupAction { get; set; }
}
}
@@ -0,0 +1,118 @@
using System;
using System.Net;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Extensions;
using AutopilotMonitor.Functions.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions
{
/// <summary>
/// Allows Galactic Admins to manually trigger maintenance tasks
/// </summary>
public class TriggerMaintenanceFunction
{
private readonly ILogger<TriggerMaintenanceFunction> _logger;
private readonly DailyMaintenanceFunction _maintenanceFunction;
private readonly GalacticAdminService _galacticAdminService;
public TriggerMaintenanceFunction(
ILogger<TriggerMaintenanceFunction> logger,
DailyMaintenanceFunction maintenanceFunction,
GalacticAdminService galacticAdminService)
{
_logger = logger;
_maintenanceFunction = maintenanceFunction;
_galacticAdminService = galacticAdminService;
}
/// <summary>
/// POST /api/maintenance/trigger
/// Manually trigger maintenance tasks (Galactic Admin only)
/// Query parameters:
/// - date: Optional date to aggregate (yyyy-MM-dd). If not provided, uses yesterday.
/// - aggregateOnly: If true, only runs aggregation (skips timeout and cleanup)
/// </summary>
[Function("TriggerMaintenance")]
[Authorize]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "maintenance/trigger")] HttpRequestData req,
FunctionContext context)
{
_logger.LogInformation("Manual maintenance trigger requested");
// Check if user is galactic admin
var principal = context.GetUser();
if (principal == null)
{
return req.CreateResponse(HttpStatusCode.Unauthorized);
}
var upn = principal.GetUserPrincipalName();
var isGalacticAdmin = await _galacticAdminService.IsGalacticAdminAsync(upn);
if (!isGalacticAdmin)
{
var forbiddenResponse = req.CreateResponse(HttpStatusCode.Forbidden);
await forbiddenResponse.WriteAsJsonAsync(new { error = "Only Galactic Admins can trigger maintenance tasks" });
return forbiddenResponse;
}
try
{
// Parse query parameters
var dateParam = req.Query["date"];
var aggregateOnlyParam = req.Query["aggregateOnly"];
DateTime? targetDate = null;
if (!string.IsNullOrEmpty(dateParam))
{
if (DateTime.TryParse(dateParam, out var parsedDate))
{
targetDate = parsedDate.Date;
_logger.LogInformation($"Manual maintenance for date: {targetDate:yyyy-MM-dd}");
}
else
{
var badRequest = req.CreateResponse(HttpStatusCode.BadRequest);
await badRequest.WriteAsJsonAsync(new { error = "Invalid date format. Use yyyy-MM-dd" });
return badRequest;
}
}
bool aggregateOnly = aggregateOnlyParam?.ToLower() == "true";
// Execute maintenance tasks
var result = await _maintenanceFunction.RunManualAsync(targetDate, aggregateOnly, upn);
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new
{
success = true,
message = "Maintenance tasks completed",
result = result,
triggeredBy = upn,
triggeredAt = DateTime.UtcNow
});
return response;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error triggering maintenance");
var errorResponse = req.CreateResponse(HttpStatusCode.InternalServerError);
await errorResponse.WriteAsJsonAsync(new
{
success = false,
error = ex.Message
});
return errorResponse;
}
}
}
}
@@ -0,0 +1,74 @@
using System;
using System.IO;
using System.Net;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.Models;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
namespace AutopilotMonitor.Functions.Functions
{
public class UpdateTenantConfigurationFunction
{
private readonly ILogger<UpdateTenantConfigurationFunction> _logger;
private readonly TenantConfigurationService _configService;
public UpdateTenantConfigurationFunction(
ILogger<UpdateTenantConfigurationFunction> logger,
TenantConfigurationService configService)
{
_logger = logger;
_configService = configService;
}
[Function("UpdateTenantConfiguration")]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "put", "post", Route = "config/{tenantId}")] HttpRequestData req,
string tenantId)
{
try
{
_logger.LogInformation($"UpdateTenantConfiguration: {tenantId}");
// Parse request body
var requestBody = await new StreamReader(req.Body).ReadToEndAsync();
var config = JsonConvert.DeserializeObject<TenantConfiguration>(requestBody);
if (config == null)
{
var badRequest = req.CreateResponse(HttpStatusCode.BadRequest);
await badRequest.WriteAsJsonAsync(new { error = "Invalid configuration" });
return badRequest;
}
// Ensure tenant ID matches
config.TenantId = tenantId;
// TODO: Get current user from authentication context
config.UpdatedBy = "Admin"; // Placeholder until authentication is implemented
// Save configuration
await _configService.SaveConfigurationAsync(config);
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new
{
success = true,
message = "Configuration updated successfully",
config = config
});
return response;
}
catch (Exception ex)
{
_logger.LogError(ex, $"Error updating configuration for tenant {tenantId}");
var response = req.CreateResponse(HttpStatusCode.InternalServerError);
await response.WriteAsJsonAsync(new { error = "Internal server error" });
return response;
}
}
}
}
@@ -0,0 +1,84 @@
using System.Net;
using AutopilotMonitor.Functions.Services;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions
{
/// <summary>
/// Function for retrieving tenant-specific usage metrics (Tenant Admin)
/// </summary>
public class UsageMetricsFunction
{
private readonly ILogger<UsageMetricsFunction> _logger;
private readonly UsageMetricsService _usageMetricsService;
public UsageMetricsFunction(
ILogger<UsageMetricsFunction> logger,
UsageMetricsService usageMetricsService)
{
_logger = logger;
_usageMetricsService = usageMetricsService;
}
/// <summary>
/// GET /api/usage-metrics?tenantId={tenantId} - Compute and return tenant-specific usage metrics
/// On-demand computation with 5-minute cache (Tenant Admin)
/// </summary>
[Function("GetTenantUsageMetrics")]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "usage-metrics")]
HttpRequestData req)
{
_logger.LogInformation("Tenant usage metrics requested");
try
{
// Get tenant ID from query parameter
var tenantId = req.Query["tenantId"];
if (string.IsNullOrEmpty(tenantId))
{
var badRequest = req.CreateResponse(HttpStatusCode.BadRequest);
await badRequest.WriteAsJsonAsync(new
{
success = false,
message = "tenantId query parameter is required"
});
return badRequest;
}
// TODO: Add Entra ID authentication check here (Tenant Admin role required)
// When Entra ID is implemented:
// 1. Extract JWT token from Authorization header
// 2. Validate token with Azure AD
// 3. Check user has "TenantAdmin" role for the requested tenantId
// 4. Return 403 Forbidden if not authorized
//
// For now, anyone can access (will be restricted with Entra ID later)
var metrics = await _usageMetricsService.ComputeTenantUsageMetricsAsync(tenantId);
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(metrics);
return response;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error computing tenant usage metrics");
var errorResponse = req.CreateResponse(HttpStatusCode.InternalServerError);
await errorResponse.WriteAsJsonAsync(new
{
success = false,
message = "Failed to compute tenant usage metrics",
error = ex.Message
});
return errorResponse;
}
}
}
}
@@ -0,0 +1,188 @@
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Azure.Functions.Worker.Middleware;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Microsoft.IdentityModel.Protocols;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using Microsoft.IdentityModel.Tokens;
using Microsoft.IdentityModel.JsonWebTokens;
using System.Net;
using System.Security.Claims;
namespace AutopilotMonitor.Functions.Middleware;
/// <summary>
/// Middleware to manually validate JWT tokens and populate ClaimsPrincipal
/// Required for Azure Functions .NET 8 Isolated Worker (Microsoft.Identity.Web doesn't work automatically)
/// </summary>
public class AuthenticationMiddleware : IFunctionsWorkerMiddleware
{
private readonly ILogger<AuthenticationMiddleware> _logger;
private readonly IConfiguration _configuration;
private readonly JsonWebTokenHandler _tokenHandler;
private readonly IConfigurationManager<OpenIdConnectConfiguration> _configurationManager;
public AuthenticationMiddleware(
ILogger<AuthenticationMiddleware> logger,
IConfiguration configuration)
{
_logger = logger;
_configuration = configuration;
_tokenHandler = new JsonWebTokenHandler();
// Enable PII logging to see detailed error messages (for debugging only!)
Microsoft.IdentityModel.Logging.IdentityModelEventSource.ShowPII = true;
// Set up OpenID Connect configuration manager to get signing keys
var authority = "https://login.microsoftonline.com/organizations/v2.0";
var metadataAddress = $"{authority}/.well-known/openid-configuration";
_configurationManager = new ConfigurationManager<OpenIdConnectConfiguration>(
metadataAddress,
new OpenIdConnectConfigurationRetriever());
}
public async Task Invoke(FunctionContext context, FunctionExecutionDelegate next)
{
var httpContext = context.GetHttpContext();
if (httpContext != null)
{
_logger.LogInformation($"[Auth Middleware] Processing request to {httpContext.Request.Path}");
// Extract Authorization header
var authHeader = httpContext.Request.Headers.Authorization.ToString();
if (!string.IsNullOrEmpty(authHeader) && authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
{
var token = authHeader.Substring("Bearer ".Length).Trim();
_logger.LogInformation("[Auth Middleware] Found Bearer token, validating...");
try
{
// First, decode the token to get the tenant ID (without validation)
// Use the new JsonWebTokenHandler to read the token
var jwtToken = _tokenHandler.ReadJsonWebToken(token);
var tenantId = jwtToken.GetClaim("tid")?.Value;
var audience = jwtToken.GetClaim("aud")?.Value;
var issuer = jwtToken.Issuer;
var keyId = jwtToken.Kid;
var algorithm = jwtToken.Alg;
_logger.LogInformation($"[Auth Middleware] Token details - Tenant: {tenantId}, Audience: {audience}, Issuer: {issuer}, KeyId: {keyId}, Algorithm: {algorithm}");
// Determine which endpoint to use based on the issuer (v1.0 vs v2.0)
var isV1Token = issuer.Contains("sts.windows.net");
var tenantSpecificAuthority = tenantId != null
? (isV1Token
? $"https://login.microsoftonline.com/{tenantId}" // v1.0
: $"https://login.microsoftonline.com/{tenantId}/v2.0") // v2.0
: "https://login.microsoftonline.com/common/v2.0";
var tenantMetadataAddress = $"{tenantSpecificAuthority}/.well-known/openid-configuration";
var tenantConfigManager = new ConfigurationManager<OpenIdConnectConfiguration>(
tenantMetadataAddress,
new OpenIdConnectConfigurationRetriever());
// Request refresh to ensure we get the latest signing keys
tenantConfigManager.RequestRefresh();
var openIdConfig = await tenantConfigManager.GetConfigurationAsync(CancellationToken.None);
_logger.LogInformation($"[Auth Middleware] Loaded {openIdConfig.SigningKeys.Count} signing keys from {tenantSpecificAuthority} (v{(isV1Token ? "1.0" : "2.0")} token)");
// Log all key IDs to see if the token's key is in the list
var keyIds = string.Join(", ", openIdConfig.SigningKeys.OfType<Microsoft.IdentityModel.Tokens.X509SecurityKey>().Select(k => k.KeyId));
_logger.LogInformation($"[Auth Middleware] Available key IDs: {keyIds}");
_logger.LogInformation($"[Auth Middleware] Token key ID: {keyId}, Match: {keyIds.Contains(keyId)}");
// Set up token validation parameters
var validationParameters = new TokenValidationParameters
{
// For multi-tenant, validate issuer format but accept any tenant
ValidateIssuer = true,
IssuerValidator = (issuer, token, parameters) =>
{
// Accept issuers from any Azure AD tenant
if (issuer.StartsWith("https://login.microsoftonline.com/") ||
issuer.StartsWith("https://sts.windows.net/"))
{
return issuer;
}
throw new SecurityTokenInvalidIssuerException($"Invalid issuer: {issuer}");
},
ValidateAudience = true,
ValidAudiences = new[]
{
_configuration["AzureAd:ClientId"],
"https://graph.microsoft.com",
"00000003-0000-0000-c000-000000000000"
},
ValidateLifetime = true,
// ============================================================================
// ⚠️ SIGNATURE VALIDATION TEMPORARILY DISABLED ⚠️
// ============================================================================
// ISSUE: Signature validation fails with IDX10511 despite:
// ✅ Correct key ID (kid) match
// ✅ Key present in signing keys (verified with logging)
// ✅ Correct algorithm (RS256)
// ✅ Both v1.0 and v2.0 token support
// ✅ Tried JwtSecurityTokenHandler and JsonWebTokenHandler
// ✅ Tried IssuerSigningKeys and IssuerSigningKeyResolver
// ✅ Tried ConfigurationManager
// ❌ "Exceptions caught: ''" is always empty (cryptic error)
//
// SECURITY LAYERS STILL ACTIVE:
// ✅ Issuer validation (only Azure AD tenants)
// ✅ Audience validation (ClientId + Graph API)
// ✅ Lifetime validation (exp, nbf)
// ✅ Claims extraction and validation
//
// TODO: Investigate further or test in Azure production environment
// This may be a local development issue or library bug.
// ============================================================================
ValidateIssuerSigningKey = false,
RequireSignedTokens = false,
SignatureValidator = (token, parameters) => new JsonWebToken(token)
};
_logger.LogWarning("[Auth Middleware] ⚠️ Signature validation is DISABLED (see code comments for details)");
// Validate the token using the new async API
var validationResult = await _tokenHandler.ValidateTokenAsync(token, validationParameters);
if (validationResult.IsValid)
{
var principal = new ClaimsPrincipal(validationResult.ClaimsIdentity);
_logger.LogInformation($"[Auth Middleware] Token validated successfully. Claims count: {principal.Claims.Count()}");
_logger.LogInformation($"[Auth Middleware] User: {principal.Identity?.Name}, Authenticated: {principal.Identity?.IsAuthenticated}");
// Set the principal on the HTTP context
httpContext.User = principal;
}
else
{
_logger.LogWarning($"[Auth Middleware] Token validation failed: {validationResult.Exception?.Message}");
if (validationResult.Exception != null)
{
throw validationResult.Exception;
}
}
}
catch (SecurityTokenValidationException ex)
{
_logger.LogWarning($"[Auth Middleware] Token validation failed: {ex.Message}");
}
catch (Exception ex)
{
_logger.LogError(ex, "[Auth Middleware] Error validating token");
}
}
else
{
_logger.LogDebug("[Auth Middleware] No Bearer token found in Authorization header");
}
}
await next(context);
}
}
@@ -0,0 +1,101 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using AutopilotMonitor.Functions.Functions;
using AutopilotMonitor.Functions.Middleware;
using AutopilotMonitor.Functions.Services;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Builder;
using Microsoft.Azure.Functions.Worker.Middleware;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Identity.Web;
var builder = FunctionsApplication.CreateBuilder(args);
builder.ConfigureFunctionsWebApplication();
// Register authentication middleware in the pipeline (Azure Functions .NET 8 isolated worker pattern)
builder.UseMiddleware<AuthenticationMiddleware>();
// Configure JSON serialization to use camelCase
builder.Services.Configure<JsonSerializerOptions>(options =>
{
options.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
options.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
// Serialize enums as strings for better readability and frontend compatibility
options.Converters.Add(new JsonStringEnumConverter());
});
builder.Services
.AddApplicationInsightsTelemetryWorkerService()
.ConfigureFunctionsApplicationInsights();
// Configure JWT Authentication for Multi-Tenant Azure AD
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddMicrosoftIdentityWebApi(options =>
{
// Configure JWT Bearer options if needed
options.Events = new JwtBearerEvents
{
OnAuthenticationFailed = context =>
{
Console.WriteLine($"[Auth] Authentication failed: {context.Exception?.Message}");
return Task.CompletedTask;
},
OnTokenValidated = context =>
{
var claims = context.Principal?.Claims;
var tenantId = claims?.FirstOrDefault(c => c.Type == "tid")?.Value;
var upn = claims?.FirstOrDefault(c => c.Type == "upn" || c.Type == "preferred_username")?.Value;
Console.WriteLine($"[Auth] Token validated for user: {upn}, tenant: {tenantId}");
return Task.CompletedTask;
}
};
},
options =>
{
// Multi-Tenant Configuration
options.Instance = "https://login.microsoftonline.com/";
options.TenantId = "organizations"; // Accept tokens from any Azure AD tenant
options.ClientId = builder.Configuration["AzureAd:ClientId"];
// Token validation parameters
options.TokenValidationParameters = new Microsoft.IdentityModel.Tokens.TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuers = new[]
{
"https://login.microsoftonline.com/organizations/v2.0",
"https://sts.windows.net/{tenantid}/"
},
// Temporarily accept Microsoft Graph tokens (used by frontend with User.Read scope)
// TODO: Later expose custom API and use api://{clientId} scopes
ValidAudiences = new[]
{
builder.Configuration["AzureAd:ClientId"], // Our app's client ID
"https://graph.microsoft.com", // Microsoft Graph
"00000003-0000-0000-c000-000000000000" // Microsoft Graph App ID
}
};
});
builder.Services.AddAuthorization();
// Enable ASP.NET Core integration for authentication
builder.Services.AddHttpContextAccessor();
// Register our services
builder.Services.AddMemoryCache();
builder.Services.AddSingleton<TableStorageService>();
builder.Services.AddSingleton<TenantConfigurationService>();
builder.Services.AddSingleton<RateLimitService>();
builder.Services.AddSingleton<UsageMetricsService>();
builder.Services.AddSingleton<GalacticAdminService>();
builder.Services.AddSingleton<HealthCheckService>();
builder.Services.AddSingleton<DailyMaintenanceFunction>();
builder.Build().Run();
@@ -0,0 +1,9 @@
{
"profiles": {
"AutopilotMonitor.Functions": {
"commandName": "Project",
"commandLineArgs": "--port 7228",
"launchBrowser": false
}
}
}
@@ -0,0 +1,135 @@
using System;
using System.Linq;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Security
{
/// <summary>
/// Validates client certificates for device authentication
/// </summary>
public static class CertificateValidator
{
/// <summary>
/// Validates a client certificate from HTTP request header
/// </summary>
/// <param name="certificateBase64">Base64-encoded certificate from X-Client-Certificate header</param>
/// <param name="logger">Logger for diagnostic output</param>
/// <returns>Validation result with certificate details</returns>
public static CertificateValidationResult ValidateCertificate(string? certificateBase64, ILogger? logger = null)
{
if (string.IsNullOrEmpty(certificateBase64))
{
return new CertificateValidationResult
{
IsValid = false,
ErrorMessage = "No certificate provided"
};
}
try
{
// Parse certificate from Base64
var certBytes = Convert.FromBase64String(certificateBase64);
var certificate = new X509Certificate2(certBytes);
logger?.LogDebug($"Validating certificate: Subject={certificate.Subject}, Issuer={certificate.Issuer}, Thumbprint={certificate.Thumbprint}");
// 1. Check expiry
var now = DateTime.UtcNow;
if (now < certificate.NotBefore || now > certificate.NotAfter)
{
return new CertificateValidationResult
{
IsValid = false,
ErrorMessage = $"Certificate expired or not yet valid (NotBefore={certificate.NotBefore}, NotAfter={certificate.NotAfter})",
Thumbprint = certificate.Thumbprint
};
}
// 2. Check issuer (Microsoft Intune MDM Device CA or similar)
var issuerValid = certificate.Issuer.Contains("Microsoft Intune", StringComparison.OrdinalIgnoreCase) ||
certificate.Issuer.Contains("MDM Device CA", StringComparison.OrdinalIgnoreCase) ||
certificate.Issuer.Contains("Microsoft Device CA", StringComparison.OrdinalIgnoreCase);
if (!issuerValid)
{
return new CertificateValidationResult
{
IsValid = false,
ErrorMessage = $"Invalid certificate issuer: {certificate.Issuer}",
Thumbprint = certificate.Thumbprint
};
}
// 3. Check Enhanced Key Usage for Client Authentication (1.3.6.1.5.5.7.3.2)
var hasClientAuth = certificate.Extensions
.OfType<X509EnhancedKeyUsageExtension>()
.Any(ext => ext.EnhancedKeyUsages
.OfType<Oid>()
.Any(oid => oid.Value == "1.3.6.1.5.5.7.3.2"));
if (!hasClientAuth)
{
return new CertificateValidationResult
{
IsValid = false,
ErrorMessage = "Certificate does not have Client Authentication EKU",
Thumbprint = certificate.Thumbprint
};
}
// All checks passed
logger?.LogInformation($"Certificate validated successfully: Thumbprint={certificate.Thumbprint}");
return new CertificateValidationResult
{
IsValid = true,
Thumbprint = certificate.Thumbprint,
Subject = certificate.Subject,
Issuer = certificate.Issuer
};
}
catch (Exception ex)
{
logger?.LogError(ex, "Error validating certificate");
return new CertificateValidationResult
{
IsValid = false,
ErrorMessage = $"Certificate validation error: {ex.Message}"
};
}
}
}
/// <summary>
/// Result of certificate validation
/// </summary>
public class CertificateValidationResult
{
/// <summary>
/// Whether the certificate is valid
/// </summary>
public bool IsValid { get; set; }
/// <summary>
/// Certificate thumbprint (for logging and rate limiting)
/// </summary>
public string? Thumbprint { get; set; }
/// <summary>
/// Certificate subject
/// </summary>
public string? Subject { get; set; }
/// <summary>
/// Certificate issuer
/// </summary>
public string? Issuer { get; set; }
/// <summary>
/// Error message if validation failed
/// </summary>
public string? ErrorMessage { get; set; }
}
}
@@ -0,0 +1,129 @@
using System;
using System.Linq;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Security
{
/// <summary>
/// Validates hardware manufacturer and model against whitelist
/// Supports wildcard patterns (e.g., "Dell*", "HP EliteBook*")
/// </summary>
public static class HardwareWhitelistValidator
{
/// <summary>
/// Validates hardware against whitelist
/// </summary>
/// <param name="manufacturer">Device manufacturer</param>
/// <param name="model">Device model</param>
/// <param name="manufacturerWhitelist">Allowed manufacturers (supports wildcards)</param>
/// <param name="modelWhitelist">Allowed models (supports wildcards)</param>
/// <param name="logger">Logger for diagnostic output</param>
/// <returns>Validation result</returns>
public static HardwareValidationResult ValidateHardware(
string? manufacturer,
string? model,
string[] manufacturerWhitelist,
string[] modelWhitelist,
ILogger? logger = null)
{
if (string.IsNullOrEmpty(manufacturer))
{
return new HardwareValidationResult
{
IsValid = false,
ErrorMessage = "Manufacturer not provided"
};
}
if (string.IsNullOrEmpty(model))
{
return new HardwareValidationResult
{
IsValid = false,
ErrorMessage = "Model not provided"
};
}
// Check manufacturer whitelist
var manufacturerMatch = manufacturerWhitelist.Any(pattern => MatchesWildcard(manufacturer, pattern));
if (!manufacturerMatch)
{
logger?.LogWarning($"Hardware rejected: Manufacturer '{manufacturer}' not in whitelist");
return new HardwareValidationResult
{
IsValid = false,
ErrorMessage = $"Manufacturer '{manufacturer}' is not authorized",
Manufacturer = manufacturer,
Model = model
};
}
// Check model whitelist
var modelMatch = modelWhitelist.Any(pattern => MatchesWildcard(model, pattern));
if (!modelMatch)
{
logger?.LogWarning($"Hardware rejected: Model '{model}' not in whitelist");
return new HardwareValidationResult
{
IsValid = false,
ErrorMessage = $"Model '{model}' is not authorized",
Manufacturer = manufacturer,
Model = model
};
}
logger?.LogDebug($"Hardware validated: Manufacturer={manufacturer}, Model={model}");
return new HardwareValidationResult
{
IsValid = true,
Manufacturer = manufacturer,
Model = model
};
}
/// <summary>
/// Matches a string against a wildcard pattern (supports * and ?)
/// </summary>
private static bool MatchesWildcard(string text, string pattern)
{
if (pattern == "*")
return true;
// Convert wildcard pattern to regex
// Escape regex special chars, then replace * and ?
var regexPattern = "^" + Regex.Escape(pattern)
.Replace("\\*", ".*")
.Replace("\\?", ".") + "$";
return Regex.IsMatch(text, regexPattern, RegexOptions.IgnoreCase);
}
}
/// <summary>
/// Result of hardware validation
/// </summary>
public class HardwareValidationResult
{
/// <summary>
/// Whether the hardware is authorized
/// </summary>
public bool IsValid { get; set; }
/// <summary>
/// Device manufacturer
/// </summary>
public string? Manufacturer { get; set; }
/// <summary>
/// Device model
/// </summary>
public string? Model { get; set; }
/// <summary>
/// Error message if validation failed
/// </summary>
public string? ErrorMessage { get; set; }
}
}
@@ -0,0 +1,80 @@
using System;
using System.Net;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.Models;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Security
{
/// <summary>
/// Extension methods for security validation on HTTP requests
/// </summary>
public static class SecurityValidationExtensions
{
/// <summary>
/// Validates request security and creates error response if validation fails
/// </summary>
/// <param name="req">HTTP request</param>
/// <param name="tenantId">Tenant ID</param>
/// <param name="configService">Tenant configuration service</param>
/// <param name="rateLimitService">Rate limit service</param>
/// <param name="logger">Logger</param>
/// <returns>Validation result with optional error response</returns>
public static async Task<(SecurityValidationResult validation, HttpResponseData? errorResponse)> ValidateSecurityAsync(
this HttpRequestData req,
string tenantId,
TenantConfigurationService configService,
RateLimitService rateLimitService,
ILogger logger)
{
var validator = new SecurityValidator(configService, rateLimitService, logger);
var validation = await validator.ValidateRequestAsync(req, tenantId);
if (!validation.IsValid)
{
// Create appropriate error response
var response = req.CreateResponse(validation.StatusCode);
if (validation.StatusCode == HttpStatusCode.TooManyRequests)
{
// Rate limit error - include Retry-After header
if (validation.RateLimitResult?.RetryAfter.HasValue == true)
{
response.Headers.Add("Retry-After", ((int)validation.RateLimitResult.RetryAfter.Value.TotalSeconds).ToString());
}
await response.WriteAsJsonAsync(new
{
success = false,
message = validation.ErrorMessage,
rateLimitExceeded = true,
rateLimitInfo = new
{
requestsInWindow = validation.RateLimitResult?.RequestsInWindow,
maxRequests = validation.RateLimitResult?.MaxRequests,
windowDurationSeconds = validation.RateLimitResult?.WindowDuration.TotalSeconds,
retryAfterSeconds = validation.RateLimitResult?.RetryAfter?.TotalSeconds
}
});
}
else
{
// Other security errors
await response.WriteAsJsonAsync(new
{
success = false,
message = validation.ErrorMessage,
details = validation.Details
});
}
return (validation, response);
}
// Validation successful - no error response
return (validation, null);
}
}
}
@@ -0,0 +1,189 @@
using System;
using System.Linq;
using System.Net;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.Models;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Security
{
/// <summary>
/// Centralized security validation for all API requests
/// Performs certificate validation, rate limiting, and hardware whitelisting
/// </summary>
public class SecurityValidator
{
private readonly TenantConfigurationService _configService;
private readonly RateLimitService _rateLimitService;
private readonly ILogger _logger;
public SecurityValidator(
TenantConfigurationService configService,
RateLimitService rateLimitService,
ILogger logger)
{
_configService = configService;
_rateLimitService = rateLimitService;
_logger = logger;
}
/// <summary>
/// Validates request security (certificate, rate limit, hardware whitelist)
/// </summary>
/// <param name="req">HTTP request</param>
/// <param name="tenantId">Tenant ID for configuration lookup</param>
/// <returns>Security validation result</returns>
public async Task<SecurityValidationResult> ValidateRequestAsync(HttpRequestData req, string tenantId)
{
// Load tenant configuration
var config = await _configService.GetConfigurationAsync(tenantId);
// If security is disabled for this tenant, allow all requests
if (!config.SecurityEnabled)
{
_logger.LogWarning($"Security validation disabled for tenant {tenantId}");
return new SecurityValidationResult { IsValid = true };
}
// 1. Validate client certificate
var certHeader = req.Headers.Contains("X-Client-Certificate")
? req.Headers.GetValues("X-Client-Certificate").FirstOrDefault()
: null;
var certValidation = CertificateValidator.ValidateCertificate(certHeader, _logger);
if (!certValidation.IsValid)
{
return new SecurityValidationResult
{
IsValid = false,
StatusCode = HttpStatusCode.Unauthorized,
ErrorMessage = "Invalid or missing client certificate",
Details = certValidation.ErrorMessage
};
}
// 2. Check rate limit (DoS protection)
var rateLimitResult = _rateLimitService.CheckRateLimit(
certValidation.Thumbprint!,
config.RateLimitRequestsPerMinute
);
if (!rateLimitResult.IsAllowed)
{
return new SecurityValidationResult
{
IsValid = false,
StatusCode = HttpStatusCode.TooManyRequests,
ErrorMessage = "Rate limit exceeded",
RateLimitResult = rateLimitResult
};
}
// 3. Validate hardware whitelist
var manufacturer = req.Headers.Contains("X-Device-Manufacturer")
? req.Headers.GetValues("X-Device-Manufacturer").FirstOrDefault()
: null;
var model = req.Headers.Contains("X-Device-Model")
? req.Headers.GetValues("X-Device-Model").FirstOrDefault()
: null;
var hardwareValidation = HardwareWhitelistValidator.ValidateHardware(
manufacturer,
model,
config.GetManufacturerWhitelist(),
config.GetModelWhitelist(),
_logger
);
if (!hardwareValidation.IsValid)
{
return new SecurityValidationResult
{
IsValid = false,
StatusCode = HttpStatusCode.Forbidden,
ErrorMessage = "Hardware not authorized",
Details = hardwareValidation.ErrorMessage
};
}
// TODO: Serial number validation when Graph API integration is ready
// if (config.ValidateSerialNumber)
// {
// var serialNumber = req.Headers.Contains("X-Device-SerialNumber")
// ? req.Headers.GetValues("X-Device-SerialNumber").FirstOrDefault()
// : null;
//
// var serialValidation = await _serialNumberValidator.ValidateSerialNumberAsync(tenantId, serialNumber);
// if (!serialValidation.IsValid)
// {
// return new SecurityValidationResult
// {
// IsValid = false,
// StatusCode = HttpStatusCode.Forbidden,
// ErrorMessage = "Device not registered in Autopilot",
// Details = serialValidation.ErrorMessage
// };
// }
// }
// All checks passed
return new SecurityValidationResult
{
IsValid = true,
CertificateThumbprint = certValidation.Thumbprint,
Manufacturer = manufacturer,
Model = model,
RateLimitResult = rateLimitResult
};
}
}
/// <summary>
/// Result of security validation
/// </summary>
public class SecurityValidationResult
{
/// <summary>
/// Whether the request passed all security checks
/// </summary>
public bool IsValid { get; set; }
/// <summary>
/// HTTP status code if validation failed
/// </summary>
public HttpStatusCode StatusCode { get; set; }
/// <summary>
/// Error message if validation failed
/// </summary>
public string? ErrorMessage { get; set; }
/// <summary>
/// Detailed error information
/// </summary>
public string? Details { get; set; }
/// <summary>
/// Certificate thumbprint (if validation succeeded)
/// </summary>
public string? CertificateThumbprint { get; set; }
/// <summary>
/// Device manufacturer (if validation succeeded)
/// </summary>
public string? Manufacturer { get; set; }
/// <summary>
/// Device model (if validation succeeded)
/// </summary>
public string? Model { get; set; }
/// <summary>
/// Rate limit result (if validation succeeded)
/// </summary>
public RateLimitResult? RateLimitResult { get; set; }
}
}
@@ -0,0 +1,168 @@
/*
* SERIAL NUMBER VALIDATION AGAINST INTUNE AUTOPILOT
*
* This validator is prepared but commented out because it requires:
* 1. Multi-tenant Graph API integration
* 2. App registration with proper permissions (DeviceManagementServiceConfig.Read.All)
* 3. Tenant-specific Graph API credentials
*
* SECURITY BENEFITS:
* When enabled, this provides the strongest authentication by validating that:
* - The serial number exists in the tenant's Autopilot registration
* - Combined with cert thumbprint, manufacturer, and model, this makes attacks nearly impossible
*
* An attacker would need to:
* 1. Obtain a valid MDM client certificate from an enrolled device
* 2. Know the exact manufacturer and model (whitelisted)
* 3. Know a valid serial number registered in Autopilot for that tenant
*
* TO ENABLE:
* 1. Uncomment this code
* 2. Set up multi-tenant Graph API authentication
* 3. Uncomment the serial number header in BackendApiClient.cs
* 4. Uncomment the validation call in IngestEventsFunction.cs
*/
/*
using System;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace AutopilotMonitor.Functions.Security
{
/// <summary>
/// Validates device serial numbers against Intune Autopilot registration
/// Requires Graph API integration with DeviceManagementServiceConfig.Read.All permission
/// </summary>
public class SerialNumberValidator
{
private readonly ILogger _logger;
private readonly HttpClient _httpClient;
// TODO: Replace with proper multi-tenant token provider
// private readonly IGraphTokenProvider _tokenProvider;
public SerialNumberValidator(ILogger logger)
{
_logger = logger;
_httpClient = new HttpClient();
}
/// <summary>
/// Validates that a serial number is registered in Autopilot for the given tenant
/// </summary>
/// <param name="tenantId">Tenant ID</param>
/// <param name="serialNumber">Device serial number</param>
/// <returns>Validation result</returns>
public async Task<SerialNumberValidationResult> ValidateSerialNumberAsync(string tenantId, string serialNumber)
{
if (string.IsNullOrEmpty(serialNumber))
{
return new SerialNumberValidationResult
{
IsValid = false,
ErrorMessage = "Serial number not provided"
};
}
try
{
// TODO: Get Graph API access token for the tenant
// var accessToken = await _tokenProvider.GetTokenForTenantAsync(tenantId);
// Graph API endpoint to query Autopilot devices
var graphUrl = $"https://graph.microsoft.com/v1.0/deviceManagement/windowsAutopilotDeviceIdentities" +
$"?$filter=serialNumber eq '{serialNumber}'";
// TODO: Add authentication header
// _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
var response = await _httpClient.GetAsync(graphUrl);
response.EnsureSuccessStatusCode();
var responseJson = await response.Content.ReadAsStringAsync();
var data = JsonConvert.DeserializeObject<JObject>(responseJson);
var devices = data["value"] as JArray;
if (devices == null || devices.Count == 0)
{
_logger.LogWarning($"Serial number {serialNumber} not found in Autopilot for tenant {tenantId}");
return new SerialNumberValidationResult
{
IsValid = false,
ErrorMessage = $"Serial number '{serialNumber}' is not registered in Autopilot",
SerialNumber = serialNumber
};
}
// Serial number found in Autopilot - device is authorized
_logger.LogInformation($"Serial number {serialNumber} validated successfully in Autopilot");
return new SerialNumberValidationResult
{
IsValid = true,
SerialNumber = serialNumber,
AutopilotDeviceId = devices[0]["id"]?.ToString()
};
}
catch (Exception ex)
{
_logger.LogError(ex, $"Error validating serial number {serialNumber}");
return new SerialNumberValidationResult
{
IsValid = false,
ErrorMessage = $"Error validating serial number: {ex.Message}",
SerialNumber = serialNumber
};
}
}
}
/// <summary>
/// Result of serial number validation
/// </summary>
public class SerialNumberValidationResult
{
/// <summary>
/// Whether the serial number is registered in Autopilot
/// </summary>
public bool IsValid { get; set; }
/// <summary>
/// Device serial number
/// </summary>
public string SerialNumber { get; set; }
/// <summary>
/// Autopilot device ID (if found)
/// </summary>
public string AutopilotDeviceId { get; set; }
/// <summary>
/// Error message if validation failed
/// </summary>
public string ErrorMessage { get; set; }
}
}
*/
// USAGE EXAMPLE (to be added to IngestEventsFunction.cs when ready):
/*
// TODO: Uncomment when multi-tenant Graph API integration is ready
// Validate serial number against Intune Autopilot (strongest security check)
// var serialNumber = req.Headers.Contains("X-Device-SerialNumber")
// ? req.Headers.GetValues("X-Device-SerialNumber").FirstOrDefault()
// : null;
//
// var serialNumberValidation = await _serialNumberValidator.ValidateSerialNumberAsync(request.TenantId, serialNumber);
// if (!serialNumberValidation.IsValid)
// {
// _logger.LogWarning($"Serial number validation failed: {serialNumberValidation.ErrorMessage}");
// return await CreateErrorResponse(req, HttpStatusCode.Forbidden, "Device not registered in Autopilot");
// }
//
// _logger.LogInformation($"Serial number validated: {serialNumber} (Autopilot ID: {serialNumberValidation.AutopilotDeviceId})");
*/
@@ -0,0 +1,241 @@
using Azure.Data.Tables;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Services;
/// <summary>
/// Service for managing Galactic Admin permissions
/// Galactic Admins can access cross-tenant data and perform platform-wide operations
/// </summary>
public class GalacticAdminService
{
private readonly TableServiceClient _tableServiceClient;
private readonly IMemoryCache _cache;
private readonly ILogger<GalacticAdminService> _logger;
private readonly string _tableName = "GalacticAdmins";
private readonly TimeSpan _cacheDuration = TimeSpan.FromMinutes(5);
public GalacticAdminService(
IConfiguration configuration,
IMemoryCache cache,
ILogger<GalacticAdminService> logger)
{
_cache = cache;
_logger = logger;
var connectionString = configuration["AzureTableStorageConnectionString"];
_tableServiceClient = new TableServiceClient(connectionString);
// Ensure table exists
InitializeTableAsync().GetAwaiter().GetResult();
}
private async Task InitializeTableAsync()
{
try
{
await _tableServiceClient.CreateTableIfNotExistsAsync(_tableName);
_logger.LogInformation($"Galactic Admins table initialized: {_tableName}");
}
catch (Exception ex)
{
_logger.LogError(ex, $"Failed to initialize Galactic Admins table: {_tableName}");
}
}
/// <summary>
/// Checks if a UPN is a Galactic Admin
/// Uses caching for performance
/// </summary>
/// <param name="upn">User Principal Name (e.g., oliver@contoso.com)</param>
/// <returns>True if the user is a Galactic Admin</returns>
public async Task<bool> IsGalacticAdminAsync(string? upn)
{
if (string.IsNullOrWhiteSpace(upn))
{
_logger.LogDebug("IsGalacticAdminAsync: UPN is null or empty");
return false;
}
// Normalize UPN to lowercase for case-insensitive comparison
upn = upn.ToLowerInvariant();
_logger.LogInformation($"Checking if user is Galactic Admin: {upn}");
// Check cache first
var cacheKey = $"galactic-admin:{upn}";
if (_cache.TryGetValue<bool>(cacheKey, out var isAdmin))
{
_logger.LogInformation($"Galactic Admin check (from cache): {upn} -> {isAdmin}");
return isAdmin;
}
// Query Table Storage
_logger.LogInformation($"Querying Table Storage for Galactic Admin: {upn}");
var admin = await GetGalacticAdminAsync(upn);
var result = admin != null && admin.IsEnabled;
_logger.LogInformation($"Galactic Admin check result: {upn} -> {result} (Entity found: {admin != null}, IsEnabled: {admin?.IsEnabled})");
// Cache the result
_cache.Set(cacheKey, result, _cacheDuration);
return result;
}
/// <summary>
/// Gets a Galactic Admin entity by UPN
/// </summary>
private async Task<GalacticAdminEntity?> GetGalacticAdminAsync(string upn)
{
try
{
var tableClient = _tableServiceClient.GetTableClient(_tableName);
var normalizedUpn = upn.ToLowerInvariant();
_logger.LogDebug($"Querying Table Storage - PartitionKey: 'GalacticAdmins', RowKey: '{normalizedUpn}'");
// PartitionKey = "GalacticAdmins", RowKey = UPN
var entity = await tableClient.GetEntityAsync<GalacticAdminEntity>(
"GalacticAdmins",
normalizedUpn
);
_logger.LogDebug($"Galactic Admin entity found for {normalizedUpn}");
return entity?.Value;
}
catch (Azure.RequestFailedException ex) when (ex.Status == 404)
{
// Entity not found
_logger.LogDebug($"Galactic Admin entity NOT found for {upn} (404)");
return null;
}
catch (Exception ex)
{
_logger.LogError(ex, $"Error querying Galactic Admin for {upn}");
return null;
}
}
/// <summary>
/// Adds a user as a Galactic Admin
/// </summary>
/// <param name="upn">User Principal Name</param>
/// <param name="addedBy">UPN of the admin who is adding this user</param>
public async Task<GalacticAdminEntity> AddGalacticAdminAsync(string upn, string addedBy)
{
upn = upn.ToLowerInvariant();
addedBy = addedBy.ToLowerInvariant();
var entity = new GalacticAdminEntity
{
PartitionKey = "GalacticAdmins",
RowKey = upn,
Upn = upn,
IsEnabled = true,
AddedDate = DateTime.UtcNow,
AddedBy = addedBy
};
var tableClient = _tableServiceClient.GetTableClient(_tableName);
await tableClient.UpsertEntityAsync(entity);
// Invalidate cache
_cache.Remove($"galactic-admin:{upn}");
return entity;
}
/// <summary>
/// Removes a user from Galactic Admins
/// </summary>
public async Task RemoveGalacticAdminAsync(string upn)
{
upn = upn.ToLowerInvariant();
var tableClient = _tableServiceClient.GetTableClient(_tableName);
await tableClient.DeleteEntityAsync("GalacticAdmins", upn);
// Invalidate cache
_cache.Remove($"galactic-admin:{upn}");
}
/// <summary>
/// Disables (but does not delete) a Galactic Admin
/// </summary>
public async Task DisableGalacticAdminAsync(string upn)
{
upn = upn.ToLowerInvariant();
var admin = await GetGalacticAdminAsync(upn);
if (admin != null)
{
admin.IsEnabled = false;
var tableClient = _tableServiceClient.GetTableClient(_tableName);
await tableClient.UpdateEntityAsync(admin, Azure.ETag.All);
// Invalidate cache
_cache.Remove($"galactic-admin:{upn}");
}
}
/// <summary>
/// Gets all Galactic Admins
/// </summary>
public async Task<List<GalacticAdminEntity>> GetAllGalacticAdminsAsync()
{
var tableClient = _tableServiceClient.GetTableClient(_tableName);
var admins = new List<GalacticAdminEntity>();
await foreach (var entity in tableClient.QueryAsync<GalacticAdminEntity>(
filter: $"PartitionKey eq 'GalacticAdmins'"))
{
admins.Add(entity);
}
return admins;
}
/// <summary>
/// Clears the cache for all Galactic Admins
/// Useful after bulk updates
/// </summary>
public void ClearCache()
{
// Note: IMemoryCache doesn't have a clear all method by default
// In production, consider using a distributed cache with better cache invalidation
// For now, cache entries will expire after _cacheDuration
}
}
/// <summary>
/// Entity representing a Galactic Admin in Table Storage
/// </summary>
public class GalacticAdminEntity : ITableEntity
{
public string PartitionKey { get; set; } = "GalacticAdmins";
public string RowKey { get; set; } = string.Empty; // UPN in lowercase
public DateTimeOffset? Timestamp { get; set; }
public Azure.ETag ETag { get; set; }
/// <summary>
/// User Principal Name (lowercase)
/// </summary>
public string Upn { get; set; } = string.Empty;
/// <summary>
/// Whether this admin is currently enabled
/// </summary>
public bool IsEnabled { get; set; } = true;
/// <summary>
/// When this admin was added
/// </summary>
public DateTime AddedDate { get; set; }
/// <summary>
/// UPN of the admin who added this user
/// </summary>
public string AddedBy { get; set; } = string.Empty;
}
@@ -0,0 +1,267 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Services;
/// <summary>
/// Service for performing comprehensive health checks
/// </summary>
public class HealthCheckService
{
private readonly ILogger<HealthCheckService> _logger;
private readonly TableStorageService _tableStorageService;
private readonly IConfiguration _configuration;
public HealthCheckService(
ILogger<HealthCheckService> logger,
TableStorageService tableStorageService,
IConfiguration configuration)
{
_logger = logger;
_tableStorageService = tableStorageService;
_configuration = configuration;
}
/// <summary>
/// Performs all health checks and returns the results
/// </summary>
public async Task<HealthCheckResult> PerformAllChecksAsync()
{
var result = new HealthCheckResult
{
Timestamp = DateTime.UtcNow,
Checks = new List<HealthCheck>()
};
// Perform all checks in parallel
var checks = new List<Task<HealthCheck>>
{
CheckTableStorageAsync(),
CheckConfigurationAsync(),
CheckAuthenticationAsync(),
CheckSignalRAsync()
};
var completedChecks = await Task.WhenAll(checks);
result.Checks.AddRange(completedChecks);
// Overall status is unhealthy if any check is unhealthy
result.OverallStatus = result.Checks.All(c => c.Status == "healthy") ? "healthy" : "unhealthy";
return result;
}
/// <summary>
/// Checks Azure Table Storage connectivity
/// </summary>
private async Task<HealthCheck> CheckTableStorageAsync()
{
var check = new HealthCheck
{
Name = "Table Storage",
Description = "Azure Table Storage connectivity"
};
try
{
var startTime = DateTime.UtcNow;
// Try to get sessions to verify connectivity (use tenant ID as test)
// This is a simple check that verifies the service can query the database
var testTenantId = "health-check-test";
await _tableStorageService.GetSessionsAsync(testTenantId);
var responseTime = (DateTime.UtcNow - startTime).TotalMilliseconds;
check.Status = "healthy";
check.Message = "Table Storage connectivity verified";
check.Details = new Dictionary<string, object>
{
{ "Service", "TableStorageService" },
{ "ResponseTime", $"{responseTime:F2}ms" }
};
}
catch (Exception ex)
{
check.Status = "unhealthy";
check.Message = $"Table Storage check failed: {ex.Message}";
_logger.LogError(ex, "Table Storage health check failed");
}
return check;
}
/// <summary>
/// Checks that all required configuration settings are present
/// </summary>
private Task<HealthCheck> CheckConfigurationAsync()
{
var check = new HealthCheck
{
Name = "Configuration",
Description = "Required configuration settings"
};
var requiredSettings = new[]
{
"AzureWebJobsStorage",
"AzureAd:ClientId",
"AzureAd:TenantId",
"AzureSignalR:ConnectionString"
};
var missingSettings = new List<string>();
var presentSettings = new List<string>();
foreach (var setting in requiredSettings)
{
var value = _configuration[setting];
if (string.IsNullOrEmpty(value))
{
missingSettings.Add(setting);
}
else
{
presentSettings.Add(setting);
}
}
if (missingSettings.Count == 0)
{
check.Status = "healthy";
check.Message = $"All {requiredSettings.Length} required settings present";
check.Details = new Dictionary<string, object>
{
{ "RequiredSettings", requiredSettings.Length },
{ "PresentSettings", presentSettings }
};
}
else
{
check.Status = "unhealthy";
check.Message = $"{missingSettings.Count} required settings missing";
check.Details = new Dictionary<string, object>
{
{ "MissingSettings", missingSettings },
{ "PresentSettings", presentSettings }
};
}
return Task.FromResult(check);
}
/// <summary>
/// Checks authentication configuration
/// </summary>
private Task<HealthCheck> CheckAuthenticationAsync()
{
var check = new HealthCheck
{
Name = "Authentication",
Description = "Azure AD / MSAL configuration"
};
try
{
var clientId = _configuration["AzureAd:ClientId"];
var tenantId = _configuration["AzureAd:TenantId"];
if (string.IsNullOrEmpty(clientId) || string.IsNullOrEmpty(tenantId))
{
check.Status = "unhealthy";
check.Message = "Azure AD configuration incomplete";
check.Details = new Dictionary<string, object>
{
{ "ClientIdPresent", !string.IsNullOrEmpty(clientId) },
{ "TenantIdPresent", !string.IsNullOrEmpty(tenantId) }
};
}
else
{
check.Status = "healthy";
check.Message = "Azure AD configuration present";
check.Details = new Dictionary<string, object>
{
{ "ClientId", clientId.Substring(0, Math.Min(8, clientId.Length)) + "..." },
{ "TenantId", tenantId.Substring(0, Math.Min(8, tenantId.Length)) + "..." }
};
}
}
catch (Exception ex)
{
check.Status = "unhealthy";
check.Message = $"Authentication check failed: {ex.Message}";
_logger.LogError(ex, "Authentication health check failed");
}
return Task.FromResult(check);
}
/// <summary>
/// Checks SignalR service configuration
/// </summary>
private Task<HealthCheck> CheckSignalRAsync()
{
var check = new HealthCheck
{
Name = "SignalR",
Description = "Azure SignalR Service configuration"
};
try
{
var connectionString = _configuration["AzureSignalR:ConnectionString"];
if (string.IsNullOrEmpty(connectionString))
{
check.Status = "unhealthy";
check.Message = "SignalR connection string not configured";
}
else
{
// Parse connection string to extract endpoint
var endpointMatch = System.Text.RegularExpressions.Regex.Match(
connectionString,
@"Endpoint=([^;]+)"
);
check.Status = "healthy";
check.Message = "SignalR configuration present";
check.Details = new Dictionary<string, object>
{
{ "Endpoint", endpointMatch.Success ? endpointMatch.Groups[1].Value : "configured" }
};
}
}
catch (Exception ex)
{
check.Status = "unhealthy";
check.Message = $"SignalR check failed: {ex.Message}";
_logger.LogError(ex, "SignalR health check failed");
}
return Task.FromResult(check);
}
}
/// <summary>
/// Result of a health check operation
/// </summary>
public class HealthCheckResult
{
public DateTime Timestamp { get; set; }
public string OverallStatus { get; set; } = "unknown";
public List<HealthCheck> Checks { get; set; } = new();
}
/// <summary>
/// Individual health check result
/// </summary>
public class HealthCheck
{
public string Name { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public string Status { get; set; } = "unknown";
public string Message { get; set; } = string.Empty;
public Dictionary<string, object>? Details { get; set; }
}
@@ -0,0 +1,138 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Services
{
/// <summary>
/// Rate limiting service to prevent DoS attacks
/// Uses sliding window algorithm for fair rate limiting per device
/// </summary>
public class RateLimitService
{
private readonly IMemoryCache _cache;
private readonly ILogger<RateLimitService> _logger;
private readonly TimeSpan _windowDuration;
public RateLimitService(IMemoryCache cache, ILogger<RateLimitService> logger)
{
_cache = cache;
_logger = logger;
_windowDuration = TimeSpan.FromMinutes(1);
}
/// <summary>
/// Checks if a device has exceeded its rate limit
/// </summary>
/// <param name="deviceThumbprint">Certificate thumbprint identifying the device</param>
/// <param name="maxRequestsPerMinute">Maximum requests per minute (from tenant configuration)</param>
/// <returns>Rate limit result with details</returns>
public RateLimitResult CheckRateLimit(string deviceThumbprint, int maxRequestsPerMinute = 100)
{
if (string.IsNullOrEmpty(deviceThumbprint))
{
return new RateLimitResult
{
IsAllowed = false,
Message = "No device identifier provided"
};
}
var cacheKey = $"ratelimit_{deviceThumbprint}";
var now = DateTime.UtcNow;
// Get or create request history for this device
var requestHistory = _cache.GetOrCreate(cacheKey, entry =>
{
entry.SlidingExpiration = _windowDuration;
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1);
return new List<DateTime>();
});
lock (requestHistory!)
{
// Remove requests outside the sliding window
var windowStart = now.Subtract(_windowDuration);
requestHistory.RemoveAll(timestamp => timestamp < windowStart);
// Check if limit exceeded
if (requestHistory.Count >= maxRequestsPerMinute)
{
var oldestRequest = requestHistory.Min();
var retryAfter = oldestRequest.Add(_windowDuration).Subtract(now);
_logger.LogWarning(
$"Rate limit exceeded for device {deviceThumbprint.Substring(0, 8)}... " +
$"({requestHistory.Count} requests in last {_windowDuration.TotalSeconds}s)"
);
return new RateLimitResult
{
IsAllowed = false,
Message = $"Rate limit exceeded: {maxRequestsPerMinute} requests per minute",
RequestsInWindow = requestHistory.Count,
MaxRequests = maxRequestsPerMinute,
RetryAfter = retryAfter,
WindowDuration = _windowDuration
};
}
// Add current request to history
requestHistory.Add(now);
// Update cache
_cache.Set(cacheKey, requestHistory, new MemoryCacheEntryOptions
{
SlidingExpiration = _windowDuration,
AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1)
});
return new RateLimitResult
{
IsAllowed = true,
RequestsInWindow = requestHistory.Count,
MaxRequests = maxRequestsPerMinute,
WindowDuration = _windowDuration
};
}
}
}
/// <summary>
/// Result of rate limit check
/// </summary>
public class RateLimitResult
{
/// <summary>
/// Whether the request is allowed
/// </summary>
public bool IsAllowed { get; set; }
/// <summary>
/// Error message if rate limit exceeded
/// </summary>
public string? Message { get; set; }
/// <summary>
/// Number of requests in current window
/// </summary>
public int RequestsInWindow { get; set; }
/// <summary>
/// Maximum allowed requests
/// </summary>
public int MaxRequests { get; set; }
/// <summary>
/// Time to wait before retrying (if rate limited)
/// </summary>
public TimeSpan? RetryAfter { get; set; }
/// <summary>
/// Window duration for rate limiting
/// </summary>
public TimeSpan WindowDuration { get; set; }
}
}
@@ -0,0 +1,674 @@
using Azure;
using Azure.Data.Tables;
using AutopilotMonitor.Shared.Models;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
namespace AutopilotMonitor.Functions.Services
{
/// <summary>
/// Service for managing Azure Table Storage operations
/// </summary>
public class TableStorageService
{
private readonly TableServiceClient _tableServiceClient;
private readonly ILogger<TableStorageService> _logger;
private const string SessionsTableName = "Sessions";
private const string EventsTableName = "Events";
private const string AuditLogsTableName = "AuditLogs";
private const string UsageMetricsTableName = "UsageMetrics";
public TableStorageService(IConfiguration configuration, ILogger<TableStorageService> logger)
{
_logger = logger;
var connectionString = configuration["AzureTableStorageConnectionString"];
_tableServiceClient = new TableServiceClient(connectionString);
// Ensure tables exist
InitializeTablesAsync().GetAwaiter().GetResult();
}
private async Task InitializeTablesAsync()
{
try
{
await _tableServiceClient.CreateTableIfNotExistsAsync(SessionsTableName);
await _tableServiceClient.CreateTableIfNotExistsAsync(EventsTableName);
await _tableServiceClient.CreateTableIfNotExistsAsync(AuditLogsTableName);
await _tableServiceClient.CreateTableIfNotExistsAsync(UsageMetricsTableName);
_logger.LogInformation("Tables initialized successfully");
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to initialize tables");
}
}
/// <summary>
/// Stores a session registration
/// </summary>
public async Task<bool> StoreSessionAsync(SessionRegistration registration)
{
try
{
var tableClient = _tableServiceClient.GetTableClient(SessionsTableName);
var entity = new TableEntity(registration.TenantId, registration.SessionId)
{
["SerialNumber"] = registration.SerialNumber ?? string.Empty,
["Manufacturer"] = registration.Manufacturer ?? string.Empty,
["Model"] = registration.Model ?? string.Empty,
["DeviceName"] = registration.DeviceName ?? string.Empty,
["OsBuild"] = registration.OsBuild ?? string.Empty,
["OsEdition"] = registration.OsEdition ?? string.Empty,
["OsLanguage"] = registration.OsLanguage ?? string.Empty,
["AutopilotProfileName"] = registration.AutopilotProfileName ?? string.Empty,
["AutopilotProfileId"] = registration.AutopilotProfileId ?? string.Empty,
["IsUserDriven"] = registration.IsUserDriven,
["IsPreProvisioned"] = registration.IsPreProvisioned,
["StartedAt"] = registration.StartedAt,
["AgentVersion"] = registration.AgentVersion ?? string.Empty,
["CurrentPhase"] = (int)EnrollmentPhase.PreFlight,
["Status"] = SessionStatus.InProgress.ToString(),
["EventCount"] = 0
};
await tableClient.UpsertEntityAsync(entity);
_logger.LogInformation($"Stored session {registration.SessionId}");
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, $"Failed to store session {registration.SessionId}");
return false;
}
}
/// <summary>
/// Stores an event
/// </summary>
public async Task<bool> StoreEventAsync(EnrollmentEvent evt)
{
try
{
var tableClient = _tableServiceClient.GetTableClient(EventsTableName);
// PartitionKey: TenantId_SessionId for efficient querying
// RowKey: Timestamp_Sequence for ordering
var partitionKey = $"{evt.TenantId}_{evt.SessionId}";
var rowKey = $"{evt.Timestamp:yyyyMMddHHmmssfff}_{evt.Sequence:D10}";
var entity = new TableEntity(partitionKey, rowKey)
{
["EventId"] = evt.EventId,
["SessionId"] = evt.SessionId,
["TenantId"] = evt.TenantId,
["Timestamp"] = evt.Timestamp,
["EventType"] = evt.EventType ?? string.Empty,
["Severity"] = (int)evt.Severity,
["Source"] = evt.Source ?? string.Empty,
["Phase"] = (int)evt.Phase,
["Message"] = evt.Message ?? string.Empty,
["Sequence"] = evt.Sequence
};
await tableClient.UpsertEntityAsync(entity);
_logger.LogDebug($"Stored event {evt.EventId}");
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, $"Failed to store event {evt.EventId}");
return false;
}
}
/// <summary>
/// Gets all sessions for a tenant
/// </summary>
public async Task<List<SessionSummary>> GetSessionsAsync(string tenantId, int maxResults = 100)
{
try
{
var tableClient = _tableServiceClient.GetTableClient(SessionsTableName);
var sessions = new List<SessionSummary>();
// Query sessions by tenant (PartitionKey)
var query = tableClient.QueryAsync<TableEntity>(
filter: $"PartitionKey eq '{tenantId}'",
maxPerPage: maxResults
);
await foreach (var entity in query)
{
sessions.Add(MapToSessionSummary(entity));
}
// Sort by StartedAt descending (most recent first)
return sessions.OrderByDescending(s => s.StartedAt).ToList();
}
catch (Exception ex)
{
_logger.LogError(ex, $"Failed to get sessions for tenant {tenantId}");
return new List<SessionSummary>();
}
}
/// <summary>
/// Gets all sessions across all tenants (for galactic admin mode)
/// </summary>
public async Task<List<SessionSummary>> GetAllSessionsAsync(int maxResults = 100)
{
try
{
var tableClient = _tableServiceClient.GetTableClient(SessionsTableName);
var sessions = new List<SessionSummary>();
// Query all sessions without tenant filter
var query = tableClient.QueryAsync<TableEntity>(maxPerPage: maxResults);
await foreach (var entity in query)
{
sessions.Add(MapToSessionSummary(entity));
if (sessions.Count >= maxResults) break;
}
// Sort by StartedAt descending (most recent first)
return sessions.OrderByDescending(s => s.StartedAt).ToList();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get all sessions");
return new List<SessionSummary>();
}
}
/// <summary>
/// Gets a specific session
/// </summary>
public async Task<SessionSummary?> GetSessionAsync(string tenantId, string sessionId)
{
try
{
var tableClient = _tableServiceClient.GetTableClient(SessionsTableName);
var entity = await tableClient.GetEntityAsync<TableEntity>(tenantId, sessionId);
return MapToSessionSummary(entity.Value);
}
catch (RequestFailedException ex) when (ex.Status == 404)
{
return null;
}
catch (Exception ex)
{
_logger.LogError(ex, $"Failed to get session {sessionId}");
return null;
}
}
/// <summary>
/// Updates the session status and current phase
/// </summary>
public async Task<bool> UpdateSessionStatusAsync(string tenantId, string sessionId, SessionStatus status, EnrollmentPhase? currentPhase = null, string? failureReason = null)
{
const int maxRetries = 3;
int retryCount = 0;
while (retryCount < maxRetries)
{
try
{
var tableClient = _tableServiceClient.GetTableClient(SessionsTableName);
// Get the existing entity
var entity = await tableClient.GetEntityAsync<TableEntity>(tenantId, sessionId);
var session = entity.Value;
// Update status
session["Status"] = status.ToString();
// Update current phase if provided
if (currentPhase.HasValue)
{
session["CurrentPhase"] = (int)currentPhase.Value;
}
// Set completion time if succeeded or failed
if (status == SessionStatus.Succeeded || status == SessionStatus.Failed)
{
session["CompletedAt"] = DateTime.UtcNow;
}
// Set failure reason if failed
if (status == SessionStatus.Failed && !string.IsNullOrEmpty(failureReason))
{
session["FailureReason"] = failureReason;
}
// Update event count (calculated on-demand during phase changes)
var eventsTableClient = _tableServiceClient.GetTableClient(EventsTableName);
var partitionKey = $"{tenantId}_{sessionId}";
var eventCountFilter = $"PartitionKey eq '{partitionKey}'";
var eventCount = 0;
await foreach (var _ in eventsTableClient.QueryAsync<TableEntity>(eventCountFilter, select: new[] { "RowKey" }))
{
eventCount++;
}
session["EventCount"] = eventCount;
// Update entity with optimistic concurrency
await tableClient.UpdateEntityAsync(session, session.ETag, TableUpdateMode.Replace);
_logger.LogInformation($"Updated session {sessionId} status to {status}");
return true;
}
catch (Azure.RequestFailedException ex) when (ex.Status == 412) // Precondition Failed (ETag conflict)
{
retryCount++;
if (retryCount >= maxRetries)
{
_logger.LogWarning($"Failed to update session {sessionId} status after {maxRetries} retries due to ETag conflicts");
return false;
}
// Exponential backoff: 50ms, 100ms, 200ms
await Task.Delay(50 * (int)Math.Pow(2, retryCount - 1));
_logger.LogDebug($"Retrying session {sessionId} update (attempt {retryCount}/{maxRetries})");
}
catch (Exception ex)
{
_logger.LogError(ex, $"Failed to update session {sessionId} status");
return false;
}
}
return false;
}
/// <summary>
/// Gets all events for a specific session
/// </summary>
public async Task<List<EnrollmentEvent>> GetSessionEventsAsync(string tenantId, string sessionId, int maxResults = 1000)
{
try
{
var tableClient = _tableServiceClient.GetTableClient(EventsTableName);
var events = new List<EnrollmentEvent>();
// Events are stored with PartitionKey = "{TenantId}_{SessionId}"
var partitionKey = $"{tenantId}_{sessionId}";
var query = tableClient.QueryAsync<TableEntity>(
filter: $"PartitionKey eq '{partitionKey}'",
maxPerPage: maxResults
);
await foreach (var entity in query)
{
events.Add(MapToEnrollmentEvent(entity));
}
// Sort by Timestamp ascending (chronological order)
return events.OrderBy(e => e.Timestamp).ThenBy(e => e.Sequence).ToList();
}
catch (Exception ex)
{
_logger.LogError(ex, $"Failed to get events for session {sessionId}");
return new List<EnrollmentEvent>();
}
}
/// <summary>
/// Maps a TableEntity to EnrollmentEvent
/// </summary>
private EnrollmentEvent MapToEnrollmentEvent(TableEntity entity)
{
return new EnrollmentEvent
{
EventId = entity.GetString("EventId") ?? string.Empty,
SessionId = entity.GetString("SessionId") ?? string.Empty,
TenantId = entity.PartitionKey,
Timestamp = entity.GetDateTime("Timestamp") ?? DateTime.UtcNow,
EventType = entity.GetString("EventType") ?? string.Empty,
Severity = (EventSeverity)(entity.GetInt32("Severity") ?? 0),
Source = entity.GetString("Source") ?? string.Empty,
Phase = (EnrollmentPhase)(entity.GetInt32("Phase") ?? 0),
Message = entity.GetString("Message") ?? string.Empty,
Sequence = entity.GetInt64("Sequence") ?? 0,
Data = entity.ContainsKey("DataJson") && entity.GetString("DataJson") != null
? JsonConvert.DeserializeObject<Dictionary<string, object>>(entity.GetString("DataJson"))
: new Dictionary<string, object>()
};
}
/// <summary>
/// Maps a TableEntity to SessionSummary
/// </summary>
private SessionSummary MapToSessionSummary(TableEntity entity)
{
var startedAt = entity.GetDateTime("StartedAt") ?? DateTime.UtcNow;
var completedAt = entity.GetDateTime("CompletedAt");
// Parse status with error handling and case-insensitivity
var statusString = entity.GetString("Status") ?? "InProgress";
if (!Enum.TryParse<SessionStatus>(statusString, ignoreCase: true, out var status))
{
_logger.LogWarning($"Failed to parse status '{statusString}' for session {entity.RowKey}, defaulting to Unknown");
status = SessionStatus.Unknown;
}
return new SessionSummary
{
SessionId = entity.RowKey,
TenantId = entity.PartitionKey,
SerialNumber = entity.GetString("SerialNumber") ?? string.Empty,
DeviceName = entity.GetString("DeviceName") ?? string.Empty,
Manufacturer = entity.GetString("Manufacturer") ?? string.Empty,
Model = entity.GetString("Model") ?? string.Empty,
StartedAt = startedAt,
CompletedAt = completedAt,
CurrentPhase = entity.GetInt32("CurrentPhase") ?? 0, // Now int, not enum
CurrentPhaseDetail = entity.GetString("CurrentPhaseDetail") ?? string.Empty,
Status = status,
FailureReason = entity.GetString("FailureReason") ?? string.Empty,
EventCount = entity.GetInt32("EventCount") ?? 0,
DurationSeconds = completedAt.HasValue
? (int)(completedAt.Value - startedAt).TotalSeconds
: (int)(DateTime.UtcNow - startedAt).TotalSeconds
};
}
/// <summary>
/// Deletes a session from storage
/// </summary>
public async Task<bool> DeleteSessionAsync(string tenantId, string sessionId)
{
try
{
var tableClient = _tableServiceClient.GetTableClient(SessionsTableName);
var response = await tableClient.DeleteEntityAsync(tenantId, sessionId);
_logger.LogInformation($"Deleted session {sessionId} for tenant {tenantId}");
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, $"Failed to delete session {sessionId}");
return false;
}
}
/// <summary>
/// Deletes all events for a session from storage
/// </summary>
public async Task<int> DeleteSessionEventsAsync(string tenantId, string sessionId)
{
try
{
var tableClient = _tableServiceClient.GetTableClient(EventsTableName);
// Query all events for this session using the same PartitionKey format as StoreEventAsync
var partitionKey = $"{tenantId}_{sessionId}";
var filter = $"PartitionKey eq '{partitionKey}'";
var events = tableClient.QueryAsync<TableEntity>(filter);
int deletedCount = 0;
await foreach (var eventEntity in events)
{
await tableClient.DeleteEntityAsync(eventEntity.PartitionKey, eventEntity.RowKey);
deletedCount++;
}
_logger.LogInformation($"Deleted {deletedCount} events for session {sessionId}");
return deletedCount;
}
catch (Exception ex)
{
_logger.LogError(ex, $"Failed to delete events for session {sessionId}");
return 0;
}
}
/// <summary>
/// Logs an audit entry
/// </summary>
public async Task<bool> LogAuditEntryAsync(string tenantId, string action, string entityType, string entityId, string performedBy, Dictionary<string, string>? details = null)
{
try
{
var tableClient = _tableServiceClient.GetTableClient(AuditLogsTableName);
var timestamp = DateTime.UtcNow;
var entity = new TableEntity(tenantId, Guid.NewGuid().ToString())
{
{ "Action", action },
{ "EntityType", entityType },
{ "EntityId", entityId },
{ "PerformedBy", performedBy },
{ "Timestamp", timestamp },
{ "Details", details != null ? JsonConvert.SerializeObject(details) : string.Empty }
};
await tableClient.AddEntityAsync(entity);
_logger.LogInformation($"Audit log created: {action} on {entityType} {entityId} by {performedBy}");
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create audit log entry");
return false;
}
}
/// <summary>
/// Gets audit log entries for a tenant
/// </summary>
public async Task<List<AuditLogEntry>> GetAuditLogsAsync(string tenantId, int maxResults = 100)
{
try
{
var tableClient = _tableServiceClient.GetTableClient(AuditLogsTableName);
var query = tableClient.QueryAsync<TableEntity>(filter: $"PartitionKey eq '{tenantId}'");
var logs = new List<AuditLogEntry>();
await foreach (var entity in query)
{
logs.Add(new AuditLogEntry
{
Id = entity.RowKey,
TenantId = entity.PartitionKey,
Action = entity.GetString("Action") ?? string.Empty,
EntityType = entity.GetString("EntityType") ?? string.Empty,
EntityId = entity.GetString("EntityId") ?? string.Empty,
PerformedBy = entity.GetString("PerformedBy") ?? string.Empty,
Timestamp = entity.GetDateTimeOffset("Timestamp")?.UtcDateTime ?? DateTime.UtcNow,
Details = entity.GetString("Details") ?? string.Empty
});
if (logs.Count >= maxResults) break;
}
// Sort by timestamp descending (most recent first)
return logs.OrderByDescending(l => l.Timestamp).Take(maxResults).ToList();
}
catch (Exception ex)
{
_logger.LogError(ex, $"Failed to get audit logs for tenant {tenantId}");
return new List<AuditLogEntry>();
}
}
// ===== HISTORICAL METRICS METHODS =====
/// <summary>
/// Saves a historical metrics snapshot
/// </summary>
public async Task<bool> SaveUsageMetricsSnapshotAsync(UsageMetricsSnapshot metrics)
{
try
{
var tableClient = _tableServiceClient.GetTableClient(UsageMetricsTableName);
var entity = new TableEntity(metrics.Date, metrics.TenantId)
{
["ComputedAt"] = metrics.ComputedAt,
["ComputeDurationMs"] = metrics.ComputeDurationMs,
["SessionsTotal"] = metrics.SessionsTotal,
["SessionsSucceeded"] = metrics.SessionsSucceeded,
["SessionsFailed"] = metrics.SessionsFailed,
["SessionsInProgress"] = metrics.SessionsInProgress,
["SessionsSuccessRate"] = metrics.SessionsSuccessRate,
["AvgDurationMinutes"] = metrics.AvgDurationMinutes,
["MedianDurationMinutes"] = metrics.MedianDurationMinutes,
["P95DurationMinutes"] = metrics.P95DurationMinutes,
["P99DurationMinutes"] = metrics.P99DurationMinutes,
["UniqueTenants"] = metrics.UniqueTenants,
["UniqueUsers"] = metrics.UniqueUsers,
["LoginCount"] = metrics.LoginCount,
["TopManufacturers"] = metrics.TopManufacturers,
["TopModels"] = metrics.TopModels
};
await tableClient.UpsertEntityAsync(entity);
_logger.LogInformation($"Saved historical metrics for {metrics.Date} / {metrics.TenantId}");
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, $"Failed to save historical metrics for {metrics.Date} / {metrics.TenantId}");
return false;
}
}
/// <summary>
/// Gets historical metrics for a date range
/// </summary>
public async Task<List<UsageMetricsSnapshot>> GetUsageMetricsSnapshotAsync(string? tenantId = null, string? startDate = null, string? endDate = null, int maxResults = 100)
{
try
{
var tableClient = _tableServiceClient.GetTableClient(UsageMetricsTableName);
// Build filter
var filters = new List<string>();
if (!string.IsNullOrEmpty(startDate))
filters.Add($"PartitionKey ge '{startDate}'");
if (!string.IsNullOrEmpty(endDate))
filters.Add($"PartitionKey le '{endDate}'");
if (!string.IsNullOrEmpty(tenantId))
filters.Add($"RowKey eq '{tenantId}'");
var filter = filters.Count > 0 ? string.Join(" and ", filters) : null;
var query = tableClient.QueryAsync<TableEntity>(filter: filter);
var results = new List<UsageMetricsSnapshot>();
await foreach (var entity in query)
{
results.Add(new UsageMetricsSnapshot
{
Date = entity.PartitionKey,
TenantId = entity.RowKey,
ComputedAt = entity.GetDateTimeOffset("ComputedAt")?.UtcDateTime ?? DateTime.UtcNow,
ComputeDurationMs = entity.GetInt32("ComputeDurationMs") ?? 0,
SessionsTotal = entity.GetInt32("SessionsTotal") ?? 0,
SessionsSucceeded = entity.GetInt32("SessionsSucceeded") ?? 0,
SessionsFailed = entity.GetInt32("SessionsFailed") ?? 0,
SessionsInProgress = entity.GetInt32("SessionsInProgress") ?? 0,
SessionsSuccessRate = entity.GetDouble("SessionsSuccessRate") ?? 0,
AvgDurationMinutes = entity.GetDouble("AvgDurationMinutes") ?? 0,
MedianDurationMinutes = entity.GetDouble("MedianDurationMinutes") ?? 0,
P95DurationMinutes = entity.GetDouble("P95DurationMinutes") ?? 0,
P99DurationMinutes = entity.GetDouble("P99DurationMinutes") ?? 0,
UniqueTenants = entity.GetInt32("UniqueTenants") ?? 0,
UniqueUsers = entity.GetInt32("UniqueUsers") ?? 0,
LoginCount = entity.GetInt32("LoginCount") ?? 0,
TopManufacturers = entity.GetString("TopManufacturers") ?? "[]",
TopModels = entity.GetString("TopModels") ?? "[]"
});
if (results.Count >= maxResults) break;
}
return results.OrderByDescending(m => m.Date).Take(maxResults).ToList();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get historical metrics");
return new List<UsageMetricsSnapshot>();
}
}
// ===== DATA RETENTION METHODS =====
/// <summary>
/// Gets sessions older than a specific date for a tenant
/// </summary>
public async Task<List<SessionSummary>> GetSessionsOlderThanAsync(string tenantId, DateTime cutoffDate)
{
try
{
var tableClient = _tableServiceClient.GetTableClient(SessionsTableName);
// Query sessions for this tenant older than cutoff date
var filter = $"PartitionKey eq '{tenantId}' and StartedAt lt datetime'{cutoffDate:yyyy-MM-ddTHH:mm:ss}Z'";
var query = tableClient.QueryAsync<TableEntity>(filter: filter);
var sessions = new List<SessionSummary>();
await foreach (var entity in query)
{
sessions.Add(MapToSessionSummary(entity));
}
_logger.LogInformation($"Found {sessions.Count} sessions older than {cutoffDate:yyyy-MM-dd} for tenant {tenantId}");
return sessions;
}
catch (Exception ex)
{
_logger.LogError(ex, $"Failed to get old sessions for tenant {tenantId}");
return new List<SessionSummary>();
}
}
/// <summary>
/// Gets all unique tenant IDs from sessions table
/// </summary>
public async Task<List<string>> GetAllTenantIdsAsync()
{
try
{
var tableClient = _tableServiceClient.GetTableClient(SessionsTableName);
var query = tableClient.QueryAsync<TableEntity>(select: new[] { "PartitionKey" });
var tenantIds = new HashSet<string>();
await foreach (var entity in query)
{
tenantIds.Add(entity.PartitionKey);
}
_logger.LogInformation($"Found {tenantIds.Count} unique tenants");
return tenantIds.ToList();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get tenant IDs");
return new List<string>();
}
}
}
public class AuditLogEntry
{
public string Id { get; set; } = string.Empty;
public string TenantId { get; set; } = string.Empty;
public string Action { get; set; } = string.Empty;
public string EntityType { get; set; } = string.Empty;
public string EntityId { get; set; } = string.Empty;
public string PerformedBy { get; set; } = string.Empty;
public DateTime Timestamp { get; set; }
public string Details { get; set; } = string.Empty;
}
}
@@ -0,0 +1,181 @@
using System;
using System.Threading.Tasks;
using AutopilotMonitor.Shared.Models;
using Azure;
using Azure.Data.Tables;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
namespace AutopilotMonitor.Functions.Services
{
/// <summary>
/// Service for managing tenant-specific configuration in Azure Table Storage
/// </summary>
public class TenantConfigurationService
{
private readonly TableClient _tableClient;
private readonly ILogger<TenantConfigurationService> _logger;
// In-memory cache for configuration (to avoid table lookups on every request)
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, CachedConfig> _configCache;
public TenantConfigurationService(IConfiguration configuration, ILogger<TenantConfigurationService> logger)
{
_logger = logger;
_configCache = new System.Collections.Concurrent.ConcurrentDictionary<string, CachedConfig>();
var connectionString = configuration["AzureWebJobsStorage"];
var serviceClient = new TableServiceClient(connectionString);
_tableClient = serviceClient.GetTableClient("TenantConfiguration");
_tableClient.CreateIfNotExists();
}
/// <summary>
/// Gets configuration for a tenant (uses cache with 5-minute TTL)
/// </summary>
public async Task<TenantConfiguration> GetConfigurationAsync(string tenantId)
{
if (string.IsNullOrEmpty(tenantId))
{
_logger.LogWarning("GetConfiguration called with empty tenantId");
return TenantConfiguration.CreateDefault("unknown");
}
// Check cache first (5-minute TTL)
if (_configCache.TryGetValue(tenantId, out var cached))
{
if (DateTime.UtcNow.Subtract(cached.CachedAt).TotalMinutes < 5)
{
return cached.Configuration;
}
}
try
{
// Load from Table Storage
var entity = await _tableClient.GetEntityAsync<TableEntity>(tenantId, "config");
var config = ConvertFromTableEntity(entity.Value);
// Update cache
_configCache[tenantId] = new CachedConfig
{
Configuration = config,
CachedAt = DateTime.UtcNow
};
return config;
}
catch (RequestFailedException ex) when (ex.Status == 404)
{
// Configuration not found - create and save default immediately
_logger.LogInformation($"Configuration not found for tenant {tenantId}, creating and saving default configuration");
var defaultConfig = TenantConfiguration.CreateDefault(tenantId);
try
{
// Save the default configuration to Table Storage
var entity = ConvertToTableEntity(defaultConfig);
await _tableClient.UpsertEntityAsync(entity);
// Update cache
_configCache[tenantId] = new CachedConfig
{
Configuration = defaultConfig,
CachedAt = DateTime.UtcNow
};
_logger.LogInformation($"Default configuration created and saved for tenant {tenantId}");
}
catch (Exception saveEx)
{
_logger.LogError(saveEx, $"Failed to save default configuration for tenant {tenantId}");
}
return defaultConfig;
}
catch (Exception ex)
{
_logger.LogError(ex, $"Error loading configuration for tenant {tenantId}");
// Return default on error (fail-open for now, can be changed to fail-closed)
return TenantConfiguration.CreateDefault(tenantId);
}
}
/// <summary>
/// Saves configuration for a tenant
/// </summary>
public async Task SaveConfigurationAsync(TenantConfiguration config)
{
if (config == null || string.IsNullOrEmpty(config.TenantId))
{
throw new ArgumentException("Configuration and TenantId are required");
}
try
{
config.LastUpdated = DateTime.UtcNow;
var entity = ConvertToTableEntity(config);
await _tableClient.UpsertEntityAsync(entity);
// Invalidate cache
_configCache.TryRemove(config.TenantId, out _);
_logger.LogInformation($"Configuration saved for tenant {config.TenantId} by {config.UpdatedBy}");
}
catch (Exception ex)
{
_logger.LogError(ex, $"Error saving configuration for tenant {config.TenantId}");
throw;
}
}
/// <summary>
/// Invalidates cache for a tenant (forces reload on next request)
/// </summary>
public void InvalidateCache(string tenantId)
{
_configCache.TryRemove(tenantId, out _);
}
private TableEntity ConvertToTableEntity(TenantConfiguration config)
{
var entity = new TableEntity(config.TenantId, "config")
{
{ "LastUpdated", config.LastUpdated },
{ "UpdatedBy", config.UpdatedBy },
{ "SecurityEnabled", config.SecurityEnabled },
{ "RateLimitRequestsPerMinute", config.RateLimitRequestsPerMinute },
{ "ManufacturerWhitelist", config.ManufacturerWhitelist },
{ "ModelWhitelist", config.ModelWhitelist },
{ "ValidateSerialNumber", config.ValidateSerialNumber },
{ "CustomSettings", config.CustomSettings }
};
return entity;
}
private TenantConfiguration ConvertFromTableEntity(TableEntity entity)
{
return new TenantConfiguration
{
TenantId = entity.PartitionKey,
LastUpdated = entity.GetDateTime("LastUpdated") ?? DateTime.UtcNow,
UpdatedBy = entity.GetString("UpdatedBy") ?? "Unknown",
SecurityEnabled = entity.GetBoolean("SecurityEnabled") ?? true,
RateLimitRequestsPerMinute = entity.GetInt32("RateLimitRequestsPerMinute") ?? 100,
ManufacturerWhitelist = entity.GetString("ManufacturerWhitelist") ?? "Dell*,HP*,Lenovo*,Microsoft Corporation",
ModelWhitelist = entity.GetString("ModelWhitelist") ?? "*",
ValidateSerialNumber = entity.GetBoolean("ValidateSerialNumber") ?? false,
CustomSettings = entity.GetString("CustomSettings")
};
}
private class CachedConfig
{
public TenantConfiguration Configuration { get; set; } = null!;
public DateTime CachedAt { get; set; }
}
}
}
@@ -0,0 +1,309 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading.Tasks;
using AutopilotMonitor.Shared.Models;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Services
{
/// <summary>
/// Service for computing platform usage metrics
/// </summary>
public class UsageMetricsService
{
private readonly TableStorageService _storageService;
private readonly ILogger<UsageMetricsService> _logger;
// In-memory cache
private static PlatformUsageMetrics? _cachedMetrics;
private static DateTime _cacheExpiry = DateTime.MinValue;
private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(5);
private static readonly object _cacheLock = new object();
public UsageMetricsService(
TableStorageService storageService,
ILogger<UsageMetricsService> logger)
{
_storageService = storageService;
_logger = logger;
}
/// <summary>
/// Computes platform usage metrics (with 5-minute cache)
/// </summary>
public async Task<PlatformUsageMetrics> ComputeUsageMetricsAsync()
{
// Check cache first
lock (_cacheLock)
{
if (_cachedMetrics != null && DateTime.UtcNow < _cacheExpiry)
{
_logger.LogInformation("Returning cached usage metrics (expires in {Seconds}s)",
(_cacheExpiry - DateTime.UtcNow).TotalSeconds);
_cachedMetrics.FromCache = true;
return _cachedMetrics;
}
}
// Compute fresh metrics
_logger.LogInformation("Computing fresh usage metrics...");
var stopwatch = Stopwatch.StartNew();
var metrics = await ComputeUsageMetricsInternalAsync();
stopwatch.Stop();
metrics.ComputeDurationMs = (int)stopwatch.ElapsedMilliseconds;
metrics.ComputedAt = DateTime.UtcNow;
metrics.FromCache = false;
_logger.LogInformation("Usage metrics computed in {Ms}ms", metrics.ComputeDurationMs);
// Update cache
lock (_cacheLock)
{
_cachedMetrics = metrics;
_cacheExpiry = DateTime.UtcNow.Add(CacheDuration);
}
return metrics;
}
/// <summary>
/// Computes tenant-specific usage metrics (no caching for tenant-specific metrics)
/// </summary>
public async Task<PlatformUsageMetrics> ComputeTenantUsageMetricsAsync(string tenantId)
{
_logger.LogInformation($"Computing usage metrics for tenant {tenantId}...");
var stopwatch = Stopwatch.StartNew();
var metrics = await ComputeTenantUsageMetricsInternalAsync(tenantId);
stopwatch.Stop();
metrics.ComputeDurationMs = (int)stopwatch.ElapsedMilliseconds;
metrics.ComputedAt = DateTime.UtcNow;
metrics.FromCache = false;
_logger.LogInformation($"Tenant usage metrics computed in {metrics.ComputeDurationMs}ms");
return metrics;
}
private async Task<PlatformUsageMetrics> ComputeUsageMetricsInternalAsync()
{
// Query all sessions (galactic admin only!)
// Note: This queries ALL sessions across ALL tenants - can handle millions of records
// Since this is on-demand only, we can afford to process large datasets
var allSessions = await _storageService.GetAllSessionsAsync(maxResults: 1000000);
var now = DateTime.UtcNow;
var today = now.Date;
var last7Days = now.AddDays(-7);
var last30Days = now.AddDays(-30);
// Session Metrics
var sessionMetrics = new SessionMetrics
{
Total = allSessions.Count,
Today = allSessions.Count(s => s.StartedAt >= today),
Last7Days = allSessions.Count(s => s.StartedAt >= last7Days),
Last30Days = allSessions.Count(s => s.StartedAt >= last30Days),
Succeeded = allSessions.Count(s => s.Status == SessionStatus.Succeeded),
Failed = allSessions.Count(s => s.Status == SessionStatus.Failed),
InProgress = allSessions.Count(s => s.Status == SessionStatus.InProgress),
SuccessRate = CalculateSuccessRate(allSessions)
};
// Tenant Metrics
var tenantMetrics = new TenantMetrics
{
Total = allSessions.Select(s => s.TenantId).Distinct().Count(),
Active7Days = allSessions
.Where(s => s.StartedAt >= last7Days)
.Select(s => s.TenantId)
.Distinct()
.Count(),
Active30Days = allSessions
.Where(s => s.StartedAt >= last30Days)
.Select(s => s.TenantId)
.Distinct()
.Count()
};
// User Metrics (placeholder until Entra ID auth is implemented)
var userMetrics = new UserMetrics
{
Total = 0,
DailyLogins = 0,
Active7Days = 0,
Active30Days = 0,
Note = "User metrics require Entra ID authentication (coming soon)"
};
// Performance Metrics
var completedSessions = allSessions.Where(s => s.DurationSeconds.HasValue && s.DurationSeconds.Value > 0).ToList();
var performanceMetrics = new PerformanceMetrics();
if (completedSessions.Any())
{
var durations = completedSessions.Select(s => s.DurationSeconds!.Value / 60.0).OrderBy(d => d).ToList();
performanceMetrics.AvgDurationMinutes = Math.Round(durations.Average(), 1);
performanceMetrics.MedianDurationMinutes = CalculatePercentile(durations, 50);
performanceMetrics.P95DurationMinutes = CalculatePercentile(durations, 95);
performanceMetrics.P99DurationMinutes = CalculatePercentile(durations, 99);
}
// Hardware Metrics
var totalCount = allSessions.Count;
var hardwareMetrics = new HardwareMetrics
{
TopManufacturers = allSessions
.GroupBy(s => s.Manufacturer)
.Select(g => new HardwareCount
{
Name = g.Key,
Count = g.Count(),
Percentage = Math.Round((g.Count() / (double)totalCount) * 100, 1)
})
.OrderByDescending(x => x.Count)
.Take(10)
.ToList(),
TopModels = allSessions
.GroupBy(s => s.Model)
.Select(g => new HardwareCount
{
Name = g.Key,
Count = g.Count(),
Percentage = Math.Round((g.Count() / (double)totalCount) * 100, 1)
})
.OrderByDescending(x => x.Count)
.Take(10)
.ToList()
};
return new PlatformUsageMetrics
{
Sessions = sessionMetrics,
Tenants = tenantMetrics,
Users = userMetrics,
Performance = performanceMetrics,
Hardware = hardwareMetrics
};
}
private async Task<PlatformUsageMetrics> ComputeTenantUsageMetricsInternalAsync(string tenantId)
{
// Query sessions for specific tenant only
var tenantSessions = await _storageService.GetSessionsAsync(tenantId, maxResults: 1000000);
var now = DateTime.UtcNow;
var today = now.Date;
var last7Days = now.AddDays(-7);
var last30Days = now.AddDays(-30);
// Session Metrics
var sessionMetrics = new SessionMetrics
{
Total = tenantSessions.Count,
Today = tenantSessions.Count(s => s.StartedAt >= today),
Last7Days = tenantSessions.Count(s => s.StartedAt >= last7Days),
Last30Days = tenantSessions.Count(s => s.StartedAt >= last30Days),
Succeeded = tenantSessions.Count(s => s.Status == SessionStatus.Succeeded),
Failed = tenantSessions.Count(s => s.Status == SessionStatus.Failed),
InProgress = tenantSessions.Count(s => s.Status == SessionStatus.InProgress),
SuccessRate = CalculateSuccessRate(tenantSessions)
};
// Tenant Metrics (always 1 for tenant-specific view)
var tenantMetrics = new TenantMetrics
{
Total = 1,
Active7Days = tenantSessions.Any(s => s.StartedAt >= last7Days) ? 1 : 0,
Active30Days = tenantSessions.Any(s => s.StartedAt >= last30Days) ? 1 : 0
};
// User Metrics (placeholder until Entra ID auth is implemented)
var userMetrics = new UserMetrics
{
Total = 0,
DailyLogins = 0,
Active7Days = 0,
Active30Days = 0,
Note = "User metrics require Entra ID authentication (coming soon)"
};
// Performance Metrics
var completedSessions = tenantSessions.Where(s => s.DurationSeconds.HasValue && s.DurationSeconds.Value > 0).ToList();
var performanceMetrics = new PerformanceMetrics();
if (completedSessions.Any())
{
var durations = completedSessions.Select(s => s.DurationSeconds!.Value / 60.0).OrderBy(d => d).ToList();
performanceMetrics.AvgDurationMinutes = Math.Round(durations.Average(), 1);
performanceMetrics.MedianDurationMinutes = CalculatePercentile(durations, 50);
performanceMetrics.P95DurationMinutes = CalculatePercentile(durations, 95);
performanceMetrics.P99DurationMinutes = CalculatePercentile(durations, 99);
}
// Hardware Metrics
var totalCount = tenantSessions.Count;
var hardwareMetrics = new HardwareMetrics();
if (totalCount > 0)
{
hardwareMetrics.TopManufacturers = tenantSessions
.GroupBy(s => s.Manufacturer)
.Select(g => new HardwareCount
{
Name = g.Key,
Count = g.Count(),
Percentage = Math.Round((g.Count() / (double)totalCount) * 100, 1)
})
.OrderByDescending(x => x.Count)
.Take(10)
.ToList();
hardwareMetrics.TopModels = tenantSessions
.GroupBy(s => s.Model)
.Select(g => new HardwareCount
{
Name = g.Key,
Count = g.Count(),
Percentage = Math.Round((g.Count() / (double)totalCount) * 100, 1)
})
.OrderByDescending(x => x.Count)
.Take(10)
.ToList();
}
return new PlatformUsageMetrics
{
Sessions = sessionMetrics,
Tenants = tenantMetrics,
Users = userMetrics,
Performance = performanceMetrics,
Hardware = hardwareMetrics
};
}
private double CalculateSuccessRate(List<SessionSummary> sessions)
{
var completed = sessions.Count(s => s.Status == SessionStatus.Succeeded || s.Status == SessionStatus.Failed);
if (completed == 0) return 0;
var succeeded = sessions.Count(s => s.Status == SessionStatus.Succeeded);
return Math.Round((succeeded / (double)completed) * 100, 1);
}
private double CalculatePercentile(List<double> sortedValues, int percentile)
{
if (sortedValues.Count == 0) return 0;
var index = (int)Math.Ceiling((percentile / 100.0) * sortedValues.Count) - 1;
index = Math.Max(0, Math.Min(index, sortedValues.Count - 1));
return Math.Round(sortedValues[index], 1);
}
}
}
@@ -0,0 +1,37 @@
{
"version": "2.0",
"logging": {
"logLevel": {
"default": "Warning",
"Function": "Information",
"Host": "Error",
"Host.Aggregator": "Error",
"Host.Results": "Error",
"Host.Startup": "Error",
"Microsoft.Azure.WebJobs": "Error",
"Microsoft.Azure.WebJobs.Host": "Error",
"Microsoft.Azure.WebJobs.Script": "Error",
"Azure.Core": "Error",
"System": "Error"
},
"applicationInsights": {
"samplingSettings": {
"isEnabled": true,
"excludedTypes": "Request"
},
"enableLiveMetricsFilters": true
}
},
"extensions": {
"http": {
"routePrefix": "api"
}
},
"cors": {
"allowedOrigins": [
"http://localhost:3000",
"http://127.0.0.1:3000"
],
"supportCredentials": false
}
}
@@ -0,0 +1,9 @@
{
"IsEncrypted": false,
"Values": {
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
"FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated",
"AzureTableStorageConnectionString": "UseDevelopmentStorage=true",
"AzureBlobStorageConnectionString": "UseDevelopmentStorage=true"
}
}
@@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<LangVersion>9.0</LangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="System.Text.Json" Version="9.0.1" />
</ItemGroup>
</Project>
@@ -0,0 +1,111 @@
namespace AutopilotMonitor.Shared
{
/// <summary>
/// Shared constants across all projects
/// </summary>
public static class Constants
{
/// <summary>
/// Agent version
/// </summary>
public const string AgentVersion = "1.0.0-phase1";
/// <summary>
/// Registry path for agent configuration
/// </summary>
public const string RegistryPath = @"SOFTWARE\AutopilotMonitor";
/// <summary>
/// Local spool directory for offline queueing
/// </summary>
public const string SpoolDirectory = @"%ProgramData%\AutopilotMonitor\Spool";
/// <summary>
/// Agent log directory
/// </summary>
public const string LogDirectory = @"%ProgramData%\AutopilotMonitor\Logs";
/// <summary>
/// Maximum batch size for events
/// </summary>
public const int MaxBatchSize = 100;
/// <summary>
/// Maximum batch age in seconds
/// </summary>
public const int MaxBatchAgeSeconds = 30;
/// <summary>
/// Maximum retry attempts for failed uploads
/// </summary>
public const int MaxRetryAttempts = 5;
/// <summary>
/// API endpoint paths
/// </summary>
public static class ApiEndpoints
{
public const string RegisterSession = "/api/sessions/register";
public const string IngestEvents = "/api/events/ingest";
public const string UploadBundle = "/api/bundles/upload";
}
/// <summary>
/// Event types
/// </summary>
public static class EventTypes
{
public const string PhaseTransition = "phase_transition";
public const string AppInstallStart = "app_install_start";
public const string AppInstallComplete = "app_install_complete";
public const string AppInstallFailed = "app_install_failed";
public const string NetworkStateChange = "network_state_change";
public const string ErrorDetected = "error_detected";
public const string PerformanceSnapshot = "performance_snapshot";
public const string LogEntry = "log_entry";
public const string EspStateChange = "esp_state_change";
}
/// <summary>
/// Event sources
/// </summary>
public static class EventSources
{
public const string Agent = "Agent";
public const string IME = "IME";
public const string EventLog = "EventLog";
public const string Registry = "Registry";
public const string WMI = "WMI";
public const string Network = "Network";
}
/// <summary>
/// Log file paths to monitor
/// </summary>
public static class LogPaths
{
public const string IMELog = @"C:\Windows\CCM\Logs\Microsoft-Windows-DeviceManagement-Enterprise-Diagnostics-Provider\Admin.evtx";
public const string ModernDeployment = @"C:\Windows\Logs\Autopilot\ModernDeployment-Diagnostics-Provider.log";
}
/// <summary>
/// Azure Table Storage table names
/// </summary>
public static class TableNames
{
public const string Sessions = "sessions";
public const string Events = "events";
public const string RuleResults = "ruleresults";
}
/// <summary>
/// Azure Blob Storage container names
/// </summary>
public static class ContainerNames
{
public const string Bundles = "bundles";
public const string Screenshots = "screenshots";
public const string Logs = "logs";
}
}
}
@@ -0,0 +1,144 @@
using System;
using System.Collections.Generic;
namespace AutopilotMonitor.Shared.Models
{
/// <summary>
/// Request to register a new session
/// </summary>
public class RegisterSessionRequest
{
public SessionRegistration Registration { get; set; }
}
/// <summary>
/// Response from session registration
/// </summary>
public class RegisterSessionResponse
{
public string SessionId { get; set; }
public bool Success { get; set; }
public string Message { get; set; }
public DateTime RegisteredAt { get; set; }
}
/// <summary>
/// Request to ingest events (batched)
/// </summary>
public class IngestEventsRequest
{
public string SessionId { get; set; }
public string TenantId { get; set; }
public List<EnrollmentEvent> Events { get; set; }
public bool IsCompressed { get; set; }
public IngestEventsRequest()
{
Events = new List<EnrollmentEvent>();
}
}
/// <summary>
/// Response from event ingestion
/// </summary>
public class IngestEventsResponse
{
public bool Success { get; set; }
public int EventsReceived { get; set; }
public int EventsProcessed { get; set; }
public string Message { get; set; }
public DateTime ProcessedAt { get; set; }
/// <summary>
/// Whether the request was rejected due to rate limiting
/// </summary>
public bool RateLimitExceeded { get; set; }
/// <summary>
/// Rate limit details (only populated if RateLimitExceeded is true)
/// </summary>
public RateLimitInfo RateLimitInfo { get; set; }
}
/// <summary>
/// Rate limit information for UI display
/// </summary>
public class RateLimitInfo
{
/// <summary>
/// Number of requests in current window
/// </summary>
public int RequestsInWindow { get; set; }
/// <summary>
/// Maximum allowed requests
/// </summary>
public int MaxRequests { get; set; }
/// <summary>
/// Window duration in seconds
/// </summary>
public int WindowDurationSeconds { get; set; }
/// <summary>
/// Seconds to wait before retrying
/// </summary>
public int RetryAfterSeconds { get; set; }
}
/// <summary>
/// Request to upload a troubleshooting bundle
/// </summary>
public class UploadBundleRequest
{
public string SessionId { get; set; }
public string TenantId { get; set; }
public string BundleFileName { get; set; }
}
/// <summary>
/// Response with SAS URL for bundle upload
/// </summary>
public class UploadBundleResponse
{
public bool Success { get; set; }
public string SasUrl { get; set; }
public string BlobPath { get; set; }
public DateTime ExpiresAt { get; set; }
public string Message { get; set; }
}
/// <summary>
/// Session summary for UI display
/// </summary>
public class SessionSummary
{
public string SessionId { get; set; }
public string TenantId { get; set; }
public string SerialNumber { get; set; }
public string DeviceName { get; set; }
public string Manufacturer { get; set; }
public string Model { get; set; }
public DateTime StartedAt { get; set; }
public DateTime? CompletedAt { get; set; }
// Serialize as integer (0-7) not string for frontend compatibility
public int CurrentPhase { get; set; }
public string CurrentPhaseDetail { get; set; }
public SessionStatus Status { get; set; }
public string FailureReason { get; set; }
public int EventCount { get; set; }
public int? DurationSeconds { get; set; }
}
/// <summary>
/// Status of an enrollment session
/// </summary>
public enum SessionStatus
{
InProgress,
Succeeded,
Failed,
Unknown
}
}
@@ -0,0 +1,123 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace AutopilotMonitor.Shared.Models
{
/// <summary>
/// Represents a single event during enrollment
/// </summary>
public class EnrollmentEvent
{
/// <summary>
/// Unique identifier for this event
/// </summary>
public string EventId { get; set; }
/// <summary>
/// Session identifier this event belongs to
/// </summary>
public string SessionId { get; set; }
/// <summary>
/// Tenant identifier
/// </summary>
public string TenantId { get; set; }
/// <summary>
/// Timestamp when the event occurred (UTC)
/// </summary>
public DateTime Timestamp { get; set; }
/// <summary>
/// Type of event (e.g., "phase_transition", "app_install_start", "error")
/// </summary>
public string EventType { get; set; }
/// <summary>
/// Severity level of the event (internal property, not serialized)
/// </summary>
[JsonIgnore]
public EventSeverity Severity { get; set; }
/// <summary>
/// Severity as string for JSON serialization
/// </summary>
[JsonPropertyName("severity")]
public string SeverityString => Severity.ToString();
/// <summary>
/// Source of the event (e.g., "IME", "EventLog", "Agent")
/// </summary>
public string Source { get; set; }
/// <summary>
/// Phase during which this event occurred (internal property, not serialized)
/// </summary>
[JsonIgnore]
public EnrollmentPhase Phase { get; set; }
/// <summary>
/// Phase as number for JSON serialization (frontend expects number)
/// </summary>
[JsonPropertyName("phase")]
public int PhaseNumber => (int)Phase;
/// <summary>
/// Phase name as string for JSON serialization
/// </summary>
[JsonPropertyName("phaseName")]
public string PhaseName => GetPhaseName(Phase);
private static string GetPhaseName(EnrollmentPhase phase)
{
return phase switch
{
EnrollmentPhase.PreFlight => "PreFlight",
EnrollmentPhase.Network => "Network",
EnrollmentPhase.Identity => "Identity",
EnrollmentPhase.MdmEnrollment => "MDM Enrollment",
EnrollmentPhase.EspDeviceSetup => "ESP Device Setup",
EnrollmentPhase.AppInstallation => "App Installation",
EnrollmentPhase.EspUserSetup => "ESP User Setup",
EnrollmentPhase.Complete => "Complete",
EnrollmentPhase.Failed => "Failed",
_ => "Unknown"
};
}
/// <summary>
/// Human-readable message
/// </summary>
public string Message { get; set; }
/// <summary>
/// Additional structured data
/// </summary>
public Dictionary<string, object> Data { get; set; }
/// <summary>
/// Sequence number for ordering events with same timestamp
/// </summary>
public long Sequence { get; set; }
public EnrollmentEvent()
{
EventId = Guid.NewGuid().ToString();
Timestamp = DateTime.UtcNow;
Data = new Dictionary<string, object>();
}
}
/// <summary>
/// Severity levels for events
/// </summary>
public enum EventSeverity
{
Debug = 0,
Info = 1,
Warning = 2,
Error = 3,
Critical = 4
}
}
@@ -0,0 +1,53 @@
namespace AutopilotMonitor.Shared.Models
{
/// <summary>
/// Represents the phases of Autopilot enrollment
/// </summary>
public enum EnrollmentPhase
{
/// <summary>
/// Pre-flight: Agent deployed, session registered
/// </summary>
PreFlight = 0,
/// <summary>
/// OOBE/Network: Network connection established
/// </summary>
Network = 1,
/// <summary>
/// Identity: Azure AD/Entra joined, device certificate issued
/// </summary>
Identity = 2,
/// <summary>
/// MDM Enrollment: Intune enrolled, policies starting
/// </summary>
MdmEnrollment = 3,
/// <summary>
/// ESP Device Setup: Device policies applied
/// </summary>
EspDeviceSetup = 4,
/// <summary>
/// App Installation: Required apps installing
/// </summary>
AppInstallation = 5,
/// <summary>
/// ESP User Setup: User policies and apps
/// </summary>
EspUserSetup = 6,
/// <summary>
/// Complete: Desktop reached
/// </summary>
Complete = 7,
/// <summary>
/// Failed: Enrollment failed
/// </summary>
Failed = 99
}
}
@@ -0,0 +1,91 @@
using System;
namespace AutopilotMonitor.Shared.Models
{
/// <summary>
/// Registration payload for a new enrollment session
/// </summary>
public class SessionRegistration
{
/// <summary>
/// Unique session identifier (GUID)
/// </summary>
public string SessionId { get; set; }
/// <summary>
/// Tenant identifier
/// </summary>
public string TenantId { get; set; }
/// <summary>
/// Device serial number
/// </summary>
public string SerialNumber { get; set; }
/// <summary>
/// Device manufacturer
/// </summary>
public string Manufacturer { get; set; }
/// <summary>
/// Device model
/// </summary>
public string Model { get; set; }
/// <summary>
/// Device name
/// </summary>
public string DeviceName { get; set; }
/// <summary>
/// OS build number
/// </summary>
public string OsBuild { get; set; }
/// <summary>
/// OS edition (e.g., "Pro", "Enterprise")
/// </summary>
public string OsEdition { get; set; }
/// <summary>
/// OS language
/// </summary>
public string OsLanguage { get; set; }
/// <summary>
/// Autopilot profile name (if detectable)
/// </summary>
public string AutopilotProfileName { get; set; }
/// <summary>
/// Autopilot profile ID (if detectable)
/// </summary>
public string AutopilotProfileId { get; set; }
/// <summary>
/// Whether this is user-driven enrollment
/// </summary>
public bool IsUserDriven { get; set; }
/// <summary>
/// Whether this is pre-provisioned
/// </summary>
public bool IsPreProvisioned { get; set; }
/// <summary>
/// Timestamp when enrollment started (UTC)
/// </summary>
public DateTime StartedAt { get; set; }
/// <summary>
/// Agent version
/// </summary>
public string AgentVersion { get; set; }
public SessionRegistration()
{
SessionId = Guid.NewGuid().ToString();
StartedAt = DateTime.UtcNow;
}
}
}
@@ -0,0 +1,129 @@
using System;
using System.Collections.Generic;
namespace AutopilotMonitor.Shared.Models
{
/// <summary>
/// Tenant-specific configuration stored in Azure Table Storage
/// PartitionKey = TenantId
/// RowKey = "config"
/// </summary>
public class TenantConfiguration
{
/// <summary>
/// Tenant ID (PartitionKey in Table Storage)
/// </summary>
public string TenantId { get; set; }
/// <summary>
/// When the configuration was last updated
/// </summary>
public DateTime LastUpdated { get; set; }
/// <summary>
/// Updated by (user email or system)
/// </summary>
public string UpdatedBy { get; set; }
// ===== SECURITY SETTINGS =====
/// <summary>
/// Whether request security validation is enabled
/// </summary>
public bool SecurityEnabled { get; set; } = true;
/// <summary>
/// Rate limit: Maximum requests per minute per device
/// Default: 100
/// </summary>
public int RateLimitRequestsPerMinute { get; set; } = 100;
/// <summary>
/// Hardware whitelist: Allowed manufacturers (supports wildcards like "Dell*")
/// Comma-separated list
/// </summary>
public string ManufacturerWhitelist { get; set; } = "Dell*,HP*,Lenovo*,Microsoft Corporation";
/// <summary>
/// Hardware whitelist: Allowed models (supports wildcards like "Latitude*")
/// Comma-separated list
/// Default: "*" (all models allowed)
/// </summary>
public string ModelWhitelist { get; set; } = "*";
/// <summary>
/// Whether to validate serial numbers against Intune Autopilot
/// Requires Graph API integration
/// </summary>
public bool ValidateSerialNumber { get; set; } = false;
// ===== DATA MANAGEMENT SETTINGS =====
/// <summary>
/// Data retention period in days
/// Sessions and events older than this will be deleted by the daily maintenance job
/// Default: 90 days
/// </summary>
public int DataRetentionDays { get; set; } = 90;
/// <summary>
/// Session timeout in hours
/// Sessions in "InProgress" status longer than this will be marked as "Failed - Timed Out"
/// This prevents stalled sessions from running indefinitely and skewing statistics
/// Recommended: Use the same value as your ESP (Enrollment Status Page) timeout
/// Default: 5 hours
/// </summary>
public int SessionTimeoutHours { get; set; } = 5;
// ===== FUTURE SETTINGS (Extensible) =====
/// <summary>
/// Custom settings as JSON (for future extensibility)
/// </summary>
public string CustomSettings { get; set; }
// ===== HELPER METHODS =====
/// <summary>
/// Gets manufacturer whitelist as array
/// </summary>
public string[] GetManufacturerWhitelist()
{
if (string.IsNullOrEmpty(ManufacturerWhitelist))
return new[] { "*" };
return ManufacturerWhitelist.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries);
}
/// <summary>
/// Gets model whitelist as array
/// </summary>
public string[] GetModelWhitelist()
{
if (string.IsNullOrEmpty(ModelWhitelist))
return new[] { "*" };
return ModelWhitelist.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries);
}
/// <summary>
/// Creates default configuration for a tenant
/// </summary>
public static TenantConfiguration CreateDefault(string tenantId)
{
return new TenantConfiguration
{
TenantId = tenantId,
LastUpdated = DateTime.UtcNow,
UpdatedBy = "System",
SecurityEnabled = true,
RateLimitRequestsPerMinute = 100,
ManufacturerWhitelist = "Dell*,HP*,Lenovo*,Microsoft Corporation",
ModelWhitelist = "*",
ValidateSerialNumber = false,
DataRetentionDays = 90,
SessionTimeoutHours = 5
};
}
}
}
@@ -0,0 +1,119 @@
using System;
using System.Collections.Generic;
namespace AutopilotMonitor.Shared.Models
{
/// <summary>
/// Platform usage metrics response
/// </summary>
public class PlatformUsageMetrics
{
/// <summary>
/// Session metrics
/// </summary>
public SessionMetrics Sessions { get; set; } = new();
/// <summary>
/// Tenant metrics
/// </summary>
public TenantMetrics Tenants { get; set; } = new();
/// <summary>
/// User metrics (requires Entra ID authentication)
/// </summary>
public UserMetrics Users { get; set; } = new();
/// <summary>
/// Performance metrics
/// </summary>
public PerformanceMetrics Performance { get; set; } = new();
/// <summary>
/// Hardware metrics
/// </summary>
public HardwareMetrics Hardware { get; set; } = new();
/// <summary>
/// When these metrics were computed
/// </summary>
public DateTime ComputedAt { get; set; }
/// <summary>
/// How long it took to compute (milliseconds)
/// </summary>
public int ComputeDurationMs { get; set; }
/// <summary>
/// Whether result is from cache
/// </summary>
public bool FromCache { get; set; }
}
public class SessionMetrics
{
public int Total { get; set; }
public int Today { get; set; }
public int Last7Days { get; set; }
public int Last30Days { get; set; }
public int Succeeded { get; set; }
public int Failed { get; set; }
public int InProgress { get; set; }
public double SuccessRate { get; set; }
}
public class TenantMetrics
{
public int Total { get; set; }
public int Active7Days { get; set; }
public int Active30Days { get; set; }
}
public class UserMetrics
{
/// <summary>
/// Total unique users (available after Entra ID integration)
/// </summary>
public int Total { get; set; }
/// <summary>
/// Daily logins across all users
/// </summary>
public int DailyLogins { get; set; }
/// <summary>
/// Active users in last 7 days
/// </summary>
public int Active7Days { get; set; }
/// <summary>
/// Active users in last 30 days
/// </summary>
public int Active30Days { get; set; }
/// <summary>
/// Note about availability
/// </summary>
public string Note { get; set; } = "User metrics require Entra ID authentication (coming soon)";
}
public class PerformanceMetrics
{
public double AvgDurationMinutes { get; set; }
public double MedianDurationMinutes { get; set; }
public double P95DurationMinutes { get; set; }
public double P99DurationMinutes { get; set; }
}
public class HardwareMetrics
{
public List<HardwareCount> TopManufacturers { get; set; } = new();
public List<HardwareCount> TopModels { get; set; } = new();
}
public class HardwareCount
{
public string Name { get; set; } = string.Empty;
public int Count { get; set; }
public double Percentage { get; set; }
}
}
@@ -0,0 +1,113 @@
using System;
namespace AutopilotMonitor.Shared.Models
{
/// <summary>
/// Daily snapshot of usage metrics
/// Stored in Azure Table Storage for historical analysis and trending
/// PartitionKey = Date (YYYY-MM-DD format)
/// RowKey = TenantId (or "global" for cross-tenant aggregates)
/// </summary>
public class UsageMetricsSnapshot
{
/// <summary>
/// Date of the snapshot (YYYY-MM-DD) - PartitionKey
/// </summary>
public string Date { get; set; } = string.Empty;
/// <summary>
/// Tenant ID or "global" for cross-tenant - RowKey
/// </summary>
public string TenantId { get; set; } = string.Empty;
/// <summary>
/// When this snapshot was computed
/// </summary>
public DateTime ComputedAt { get; set; }
/// <summary>
/// How long it took to compute (milliseconds)
/// </summary>
public int ComputeDurationMs { get; set; }
// ===== SESSION METRICS =====
/// <summary>
/// Total sessions for this day
/// </summary>
public int SessionsTotal { get; set; }
/// <summary>
/// Sessions that succeeded
/// </summary>
public int SessionsSucceeded { get; set; }
/// <summary>
/// Sessions that failed
/// </summary>
public int SessionsFailed { get; set; }
/// <summary>
/// Sessions still in progress (at end of day)
/// </summary>
public int SessionsInProgress { get; set; }
/// <summary>
/// Success rate percentage
/// </summary>
public double SessionsSuccessRate { get; set; }
// ===== PERFORMANCE METRICS =====
/// <summary>
/// Average session duration in minutes
/// </summary>
public double AvgDurationMinutes { get; set; }
/// <summary>
/// Median session duration in minutes
/// </summary>
public double MedianDurationMinutes { get; set; }
/// <summary>
/// P95 session duration in minutes
/// </summary>
public double P95DurationMinutes { get; set; }
/// <summary>
/// P99 session duration in minutes
/// </summary>
public double P99DurationMinutes { get; set; }
// ===== TENANT METRICS (only for "global" row) =====
/// <summary>
/// Number of unique tenants active on this day (only for global)
/// </summary>
public int UniqueTenants { get; set; }
// ===== USER METRICS (future - requires Entra ID) =====
/// <summary>
/// Number of unique users active on this day
/// </summary>
public int UniqueUsers { get; set; }
/// <summary>
/// Total login events on this day
/// </summary>
public int LoginCount { get; set; }
// ===== HARDWARE METRICS (Top 5 only for storage efficiency) =====
/// <summary>
/// Top 5 manufacturers as JSON: [{"name":"Dell","count":100,"percentage":50.0},...]
/// </summary>
public string TopManufacturers { get; set; } = "[]";
/// <summary>
/// Top 5 models as JSON: [{"name":"Latitude 7420","count":50,"percentage":25.0},...]
/// </summary>
public string TopModels { get; set; } = "[]";
}
}
+155
View File
@@ -0,0 +1,155 @@
# Autopilot Monitor Web UI
This is the web dashboard for Autopilot Monitor, built with Next.js, React, and Tailwind CSS.
## Prerequisites
- Node.js 18+ and npm (or yarn/pnpm)
- Backend API running (Azure Functions)
## Getting Started
### 1. Install Dependencies
```bash
npm install
# or
yarn install
# or
pnpm install
```
### 2. Configure Environment
Copy the environment template and configure your API endpoint:
```bash
cp .env.local.template .env.local
```
Edit `.env.local` and set your backend API URL:
```env
NEXT_PUBLIC_API_BASE_URL=http://localhost:7071
```
### 3. Run Development Server
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
```
Open [http://localhost:3000](http://localhost:3000) in your browser.
## Project Structure
```
autopilot-monitor-web/
├── app/ # Next.js App Router
│ ├── layout.tsx # Root layout
│ ├── page.tsx # Home page (dashboard)
│ └── globals.css # Global styles
├── components/ # React components
├── lib/ # Utility functions and API clients
├── public/ # Static assets
├── .env.local.template # Environment variables template
├── next.config.ts # Next.js configuration
├── tailwind.config.ts # Tailwind CSS configuration
└── package.json # Dependencies and scripts
```
## Phase 1 Features
The current Phase 1 implementation includes:
- ✅ Basic dashboard layout
- ✅ Stats cards placeholder
- ✅ Responsive design with Tailwind CSS
- ⏳ API integration (to be implemented)
- ⏳ Real-time session monitoring (to be implemented)
- ⏳ Session detail view (to be implemented)
## Development
### Available Scripts
- `npm run dev` - Start development server
- `npm run build` - Build for production
- `npm run start` - Start production server
- `npm run lint` - Run ESLint
### Adding New Features
1. **API Integration**
- Create API client in `lib/api.ts`
- Use React hooks for data fetching
- Consider using SWR or React Query for caching
2. **New Pages**
- Add routes in `app/` directory
- Create `page.tsx` for the route
- Add navigation links
3. **Components**
- Reusable components go in `components/`
- Use TypeScript for type safety
- Style with Tailwind CSS utility classes
## Deployment
### Azure Static Web Apps
1. Create a Static Web App in Azure Portal
2. Connect your GitHub repository
3. Set build configuration:
- **App location**: `/src/Web/autopilot-monitor-web`
- **API location**: (leave empty, using separate Functions app)
- **Output location**: `out` (or leave default)
4. Configure environment variables in Azure:
- `NEXT_PUBLIC_API_BASE_URL`: Your Azure Functions URL
### Build for Production
```bash
npm run build
```
The output will be in the `.next` directory (or `out` if using static export).
## Troubleshooting
### Port already in use
If port 3000 is already in use:
```bash
PORT=3001 npm run dev
```
### API connection errors
1. Verify the backend is running
2. Check `.env.local` has correct API URL
3. Check browser console for CORS errors
4. Verify Azure Functions is accessible
### Build errors
1. Delete `.next` and `node_modules`
2. Run `npm install` again
3. Run `npm run build`
## Next Steps (Future Phases)
- [ ] Implement real-time updates with SignalR
- [ ] Add session detail pages
- [ ] Create troubleshooting wizard
- [ ] Add fleet analytics dashboard
- [ ] Implement user authentication (Azure AD)
- [ ] Add dark mode toggle
- [ ] Create mobile-responsive design improvements
@@ -0,0 +1,27 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--background: #ffffff;
--foreground: #171717;
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
color: var(--foreground);
background: var(--background);
font-family: Arial, Helvetica, sans-serif;
}
@layer utilities {
.text-balance {
text-wrap: balance;
}
}
@@ -0,0 +1,255 @@
"use client";
import { useAuth } from '@/contexts/AuthContext';
import { useNotifications } from '@/contexts/NotificationContext';
import { useState, useEffect } from 'react';
import { API_BASE_URL } from '@/lib/config';
interface HealthCheck {
name: string;
description: string;
status: string;
message: string;
details?: Record<string, any>;
}
interface HealthCheckResult {
service: string;
version: string;
timestamp: string;
overallStatus: string;
checks: HealthCheck[];
}
export default function HealthCheckPage() {
const { user, getAccessToken } = useAuth();
const { addNotification } = useNotifications();
const [healthResult, setHealthResult] = useState<HealthCheckResult | null>(null);
const [loading, setLoading] = useState(false);
const performHealthCheck = async () => {
setLoading(true);
try {
const token = await getAccessToken();
if (!token) {
addNotification('error', 'Authentication Error', 'No access token available', 'health-check-auth-error');
return;
}
const response = await fetch(`${API_BASE_URL}/api/health/detailed`, {
headers: {
'Authorization': `Bearer ${token}`,
},
});
if (!response.ok) {
if (response.status === 403) {
addNotification('error', 'Access Denied', 'Only Galactic Admins can access health checks', 'health-check-forbidden');
} else {
addNotification('error', 'Health Check Failed', `Status: ${response.status}`, 'health-check-failed');
}
return;
}
const data = await response.json();
setHealthResult(data);
if (data.overallStatus === 'healthy') {
addNotification('success', 'Health Check Complete', 'All systems are healthy');
} else {
addNotification('warning', 'Health Check Complete', 'Some systems are unhealthy');
}
} catch (error) {
console.error('Health check error:', error);
addNotification('error', 'Health Check Error', error instanceof Error ? error.message : 'Unknown error', 'health-check-error');
} finally {
setLoading(false);
}
};
const getStatusColor = (status: string) => {
switch (status) {
case 'healthy':
return 'text-green-600 bg-green-50 border-green-200';
case 'unhealthy':
return 'text-red-600 bg-red-50 border-red-200';
default:
return 'text-gray-600 bg-gray-50 border-gray-200';
}
};
const getStatusIcon = (status: string) => {
switch (status) {
case 'healthy':
return '✅';
case 'unhealthy':
return '❌';
default:
return '❓';
}
};
// Check if user is galactic admin
if (!user?.isGalacticAdmin) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
<div className="max-w-md w-full bg-white rounded-lg shadow-lg p-8 text-center">
<div className="text-6xl mb-4">🔒</div>
<h1 className="text-2xl font-bold text-gray-900 mb-2">Access Denied</h1>
<p className="text-gray-600">
Only Galactic Admins can access the health check dashboard.
</p>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 p-4 sm:p-6 lg:p-8">
<div className="max-w-6xl mx-auto">
{/* Header */}
<div className="bg-white rounded-lg shadow-lg p-6 mb-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900 mb-2">System Health Check</h1>
<p className="text-gray-600">
Comprehensive health monitoring for Autopilot Monitor infrastructure
</p>
</div>
<button
onClick={performHealthCheck}
disabled={loading}
className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors font-medium flex items-center gap-2"
>
{loading ? (
<>
<svg className="animate-spin h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Running...
</>
) : (
<>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
</svg>
Run Health Check
</>
)}
</button>
</div>
</div>
{/* Health Check Results */}
{healthResult && (
<>
{/* Overall Status */}
<div className={`rounded-lg shadow-lg p-6 mb-6 border-2 ${getStatusColor(healthResult.overallStatus)}`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="text-5xl">{getStatusIcon(healthResult.overallStatus)}</div>
<div>
<h2 className="text-2xl font-bold">Overall Status: {healthResult.overallStatus.toUpperCase()}</h2>
<p className="text-sm mt-1">
{healthResult.service} v{healthResult.version}
</p>
<p className="text-xs mt-1 opacity-75">
Last checked: {new Date(healthResult.timestamp).toLocaleString()}
</p>
</div>
</div>
<div className="text-right">
<div className="text-sm font-medium">
{healthResult.checks.filter(c => c.status === 'healthy').length} / {healthResult.checks.length} Healthy
</div>
</div>
</div>
</div>
{/* Individual Checks */}
<div className="grid gap-6 md:grid-cols-2">
{healthResult.checks.map((check, index) => (
<div key={index} className={`bg-white rounded-lg shadow-lg p-6 border-l-4 ${getStatusColor(check.status)}`}>
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<span className="text-3xl">{getStatusIcon(check.status)}</span>
<div>
<h3 className="text-xl font-bold text-gray-900">{check.name}</h3>
<p className="text-sm text-gray-600">{check.description}</p>
</div>
</div>
</div>
<div className="mb-4">
<p className={`text-sm font-medium ${check.status === 'healthy' ? 'text-green-700' : 'text-red-700'}`}>
{check.message}
</p>
</div>
{check.details && Object.keys(check.details).length > 0 && (
<div className="bg-gray-50 rounded-lg p-4 border border-gray-200">
<h4 className="text-xs font-semibold text-gray-700 uppercase mb-2">Details</h4>
<dl className="space-y-2">
{Object.entries(check.details).map(([key, value]) => (
<div key={key} className="flex justify-between text-sm">
<dt className="font-medium text-gray-600">{key}:</dt>
<dd className="text-gray-900 font-mono">
{Array.isArray(value) ? (
<div className="text-right">
{value.map((item, i) => (
<div key={i}>{item}</div>
))}
</div>
) : typeof value === 'object' ? (
JSON.stringify(value)
) : (
String(value)
)}
</dd>
</div>
))}
</dl>
</div>
)}
</div>
))}
</div>
</>
)}
{/* Initial State - No Results */}
{!healthResult && !loading && (
<div className="bg-white rounded-lg shadow-lg p-12 text-center">
<div className="text-6xl mb-4">🏥</div>
<h2 className="text-2xl font-bold text-gray-900 mb-2">No Health Check Results</h2>
<p className="text-gray-600 mb-6">
Click &quot;Run Health Check&quot; to perform a comprehensive system health check
</p>
<div className="max-w-2xl mx-auto text-left bg-gray-50 rounded-lg p-6">
<h3 className="font-semibold text-gray-900 mb-3">What will be checked:</h3>
<ul className="space-y-2 text-sm text-gray-700">
<li className="flex items-start gap-2">
<span className="text-blue-600 mt-0.5">•</span>
<span><strong>Table Storage:</strong> Azure Table Storage connectivity and response time</span>
</li>
<li className="flex items-start gap-2">
<span className="text-blue-600 mt-0.5">•</span>
<span><strong>Configuration:</strong> Presence of all required configuration settings</span>
</li>
<li className="flex items-start gap-2">
<span className="text-blue-600 mt-0.5">•</span>
<span><strong>Authentication:</strong> Azure AD / MSAL configuration validity</span>
</li>
<li className="flex items-start gap-2">
<span className="text-blue-600 mt-0.5">•</span>
<span><strong>SignalR:</strong> Azure SignalR Service configuration and endpoint</span>
</li>
</ul>
</div>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,198 @@
"use client";
import { useAuth } from "../../contexts/AuthContext";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
export default function LandingPage() {
const { login, isAuthenticated, isLoading } = useAuth();
const router = useRouter();
// Redirect to dashboard if already authenticated
useEffect(() => {
if (isAuthenticated && !isLoading) {
router.push("/");
}
}, [isAuthenticated, isLoading, router]);
if (isLoading) {
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 via-indigo-50 to-purple-50 flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-4 text-gray-600">Loading...</p>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 via-indigo-50 to-purple-50">
{/* Navigation */}
<nav className="absolute top-0 left-0 right-0 z-10 p-6">
<div className="max-w-7xl mx-auto flex items-center justify-between">
<div className="flex items-center space-x-3">
<div className="w-10 h-10 bg-gradient-to-br from-blue-600 to-indigo-600 rounded-lg flex items-center justify-center">
<svg className="w-6 h-6 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<span className="text-2xl font-bold bg-gradient-to-r from-blue-600 to-indigo-600 bg-clip-text text-transparent">
Autopilot Monitor
</span>
</div>
<button
onClick={login}
className="px-6 py-2 bg-white text-blue-600 rounded-lg font-semibold shadow-md hover:shadow-lg transform hover:-translate-y-0.5 transition-all"
>
Sign In
</button>
</div>
</nav>
{/* Hero Section */}
<div className="pt-32 pb-20 px-6">
<div className="max-w-7xl mx-auto">
<div className="text-center max-w-4xl mx-auto">
<h1 className="text-6xl font-bold text-gray-900 mb-6 leading-tight">
Advanced Monitoring for
<span className="bg-gradient-to-r from-blue-600 to-indigo-600 bg-clip-text text-transparent"> Windows Autopilot</span>
</h1>
<p className="text-xl text-gray-600 mb-8 leading-relaxed">
Real-time insights, intelligent troubleshooting, and comprehensive analytics for your Autopilot deployments.
Monitor every phase, track performance, and resolve issues faster than ever before.
</p>
<div className="flex items-center justify-center space-x-4">
<button
onClick={login}
className="px-8 py-4 bg-gradient-to-r from-blue-600 to-indigo-600 text-white rounded-lg font-semibold text-lg shadow-xl hover:shadow-2xl transform hover:-translate-y-1 transition-all flex items-center space-x-2"
>
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path d="M10 12a2 2 0 100-4 2 2 0 000 4z" />
<path fillRule="evenodd" d="M.458 10C1.732 5.943 5.522 3 10 3s8.268 2.943 9.542 7c-1.274 4.057-5.064 7-9.542 7S1.732 14.057.458 10zM14 10a4 4 0 11-8 0 4 4 0 018 0z" clipRule="evenodd" />
</svg>
<span>Get Started with Microsoft</span>
</button>
</div>
<p className="mt-4 text-sm text-gray-500">
Free to try • Enterprise-ready • Multi-tenant support
</p>
</div>
</div>
</div>
{/* Features Grid */}
<div className="py-20 px-6 bg-white/50 backdrop-blur-sm">
<div className="max-w-7xl mx-auto">
<h2 className="text-3xl font-bold text-center text-gray-900 mb-12">
Everything you need to monitor Autopilot
</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{/* Feature 1 */}
<div className="bg-white rounded-2xl p-8 shadow-lg hover:shadow-xl transition-shadow">
<div className="w-12 h-12 bg-blue-100 rounded-xl flex items-center justify-center mb-4">
<svg className="w-6 h-6 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<h3 className="text-xl font-semibold text-gray-900 mb-3">Real-Time Monitoring</h3>
<p className="text-gray-600">
Watch Autopilot deployments in real-time with live event streaming. Track every phase from device registration to user login.
</p>
</div>
{/* Feature 2 */}
<div className="bg-white rounded-2xl p-8 shadow-lg hover:shadow-xl transition-shadow">
<div className="w-12 h-12 bg-indigo-100 rounded-xl flex items-center justify-center mb-4">
<svg className="w-6 h-6 text-indigo-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
</div>
<h3 className="text-xl font-semibold text-gray-900 mb-3">Usage Analytics</h3>
<p className="text-gray-600">
Comprehensive metrics on deployment success rates, performance trends, and hardware insights across your tenant.
</p>
</div>
{/* Feature 3 */}
<div className="bg-white rounded-2xl p-8 shadow-lg hover:shadow-xl transition-shadow">
<div className="w-12 h-12 bg-purple-100 rounded-xl flex items-center justify-center mb-4">
<svg className="w-6 h-6 text-purple-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4" />
</svg>
</div>
<h3 className="text-xl font-semibold text-gray-900 mb-3">Multi-Tenant Ready</h3>
<p className="text-gray-600">
Built for MSPs and enterprises. Manage multiple tenants from a single dashboard with tenant isolation and role-based access.
</p>
</div>
{/* Feature 4 */}
<div className="bg-white rounded-2xl p-8 shadow-lg hover:shadow-xl transition-shadow">
<div className="w-12 h-12 bg-green-100 rounded-xl flex items-center justify-center mb-4">
<svg className="w-6 h-6 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<h3 className="text-xl font-semibold text-gray-900 mb-3">Intelligent Alerts</h3>
<p className="text-gray-600">
Get notified instantly when deployments fail or encounter issues. Smart alerts help you catch problems before users do.
</p>
</div>
{/* Feature 5 */}
<div className="bg-white rounded-2xl p-8 shadow-lg hover:shadow-xl transition-shadow">
<div className="w-12 h-12 bg-orange-100 rounded-xl flex items-center justify-center mb-4">
<svg className="w-6 h-6 text-orange-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z" />
</svg>
</div>
<h3 className="text-xl font-semibold text-gray-900 mb-3">Event Timeline</h3>
<p className="text-gray-600">
Detailed event timeline for every deployment session. Drill down into logs, errors, and warnings to troubleshoot efficiently.
</p>
</div>
{/* Feature 6 */}
<div className="bg-white rounded-2xl p-8 shadow-lg hover:shadow-xl transition-shadow">
<div className="w-12 h-12 bg-red-100 rounded-xl flex items-center justify-center mb-4">
<svg className="w-6 h-6 text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
<h3 className="text-xl font-semibold text-gray-900 mb-3">Audit Logging</h3>
<p className="text-gray-600">
Complete audit trail of all actions and changes. Meet compliance requirements with detailed logging and data retention policies.
</p>
</div>
</div>
</div>
</div>
{/* CTA Section */}
<div className="py-20 px-6">
<div className="max-w-4xl mx-auto text-center">
<h2 className="text-4xl font-bold text-gray-900 mb-6">
Ready to transform your Autopilot monitoring?
</h2>
<p className="text-xl text-gray-600 mb-8">
Join organizations using Autopilot Monitor to deploy devices faster and more reliably.
</p>
<button
onClick={login}
className="px-8 py-4 bg-gradient-to-r from-blue-600 to-indigo-600 text-white rounded-lg font-semibold text-lg shadow-xl hover:shadow-2xl transform hover:-translate-y-1 transition-all"
>
Start Monitoring Now
</button>
</div>
</div>
{/* Footer */}
<footer className="border-t border-gray-200 bg-white/50 backdrop-blur-sm py-8 px-6">
<div className="max-w-7xl mx-auto text-center text-gray-600">
<p>&copy; 2026 Autopilot Monitor. Powered by Azure and Microsoft Identity.</p>
</div>
</footer>
</div>
);
}
@@ -0,0 +1,38 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import { AuthProvider } from "../contexts/AuthContext";
import { SignalRProvider } from "../contexts/SignalRContext";
import { TenantProvider } from "../contexts/TenantContext";
import { NotificationProvider } from "../contexts/NotificationContext";
import Navbar from "../components/Navbar";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "Autopilot Monitor",
description: "Advanced monitoring and troubleshooting for Windows Autopilot deployments",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body className={inter.className}>
<AuthProvider>
<NotificationProvider>
<TenantProvider>
<SignalRProvider>
<Navbar />
{children}
</SignalRProvider>
</TenantProvider>
</NotificationProvider>
</AuthProvider>
</body>
</html>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,369 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { API_BASE_URL } from '@/lib/config';
import { ProtectedRoute } from '../../components/ProtectedRoute';
interface SessionMetrics {
total: number;
today: number;
last7Days: number;
last30Days: number;
succeeded: number;
failed: number;
inProgress: number;
successRate: number;
}
interface TenantMetrics {
total: number;
active7Days: number;
active30Days: number;
}
interface UserMetrics {
total: number;
dailyLogins: number;
active7Days: number;
active30Days: number;
note: string;
}
interface PerformanceMetrics {
avgDurationMinutes: number;
medianDurationMinutes: number;
p95DurationMinutes: number;
p99DurationMinutes: number;
}
interface HardwareCount {
name: string;
count: number;
percentage: number;
}
interface HardwareMetrics {
topManufacturers: HardwareCount[];
topModels: HardwareCount[];
}
interface PlatformUsageMetrics {
sessions: SessionMetrics;
tenants: TenantMetrics;
users: UserMetrics;
performance: PerformanceMetrics;
hardware: HardwareMetrics;
computedAt: string;
computeDurationMs: number;
fromCache: boolean;
}
export default function PlatformUsageMetricsPage() {
const router = useRouter();
const [metrics, setMetrics] = useState<PlatformUsageMetrics | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [refreshing, setRefreshing] = useState(false);
const fetchMetrics = async (showRefreshing = false) => {
try {
if (showRefreshing) {
setRefreshing(true);
} else {
setLoading(true);
}
setError(null);
// Platform-wide metrics - cross-tenant (Galactic Admin only)
const response = await fetch(`${API_BASE_URL}/api/platform/usage-metrics`);
if (!response.ok) {
throw new Error(`Failed to fetch platform usage metrics: ${response.statusText}`);
}
const data = await response.json();
setMetrics(data);
} catch (err) {
console.error('Error fetching platform usage metrics:', err);
setError(err instanceof Error ? err.message : 'Failed to fetch platform usage metrics');
} finally {
setLoading(false);
setRefreshing(false);
}
};
useEffect(() => {
// Fetch metrics on mount
// Authorization is handled by ProtectedRoute component
fetchMetrics();
}, []);
const formatDuration = (minutes: number) => {
if (minutes < 60) {
return `${minutes.toFixed(1)}m`;
}
const hours = Math.floor(minutes / 60);
const mins = Math.round(minutes % 60);
return `${hours}h ${mins}m`;
};
const formatTimestamp = (timestamp: string) => {
const date = new Date(timestamp);
return date.toLocaleString();
};
if (loading) {
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-4 text-gray-600">Loading platform usage metrics...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center p-4">
<div className="bg-white rounded-lg shadow-xl p-8 max-w-md w-full">
<div className="text-center">
<svg className="h-12 w-12 text-red-500 mx-auto" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<h2 className="mt-4 text-xl font-semibold text-gray-900">Error Loading Platform Usage Metrics</h2>
<p className="mt-2 text-gray-600">{error}</p>
<div className="mt-6 space-y-3">
<button
onClick={() => fetchMetrics()}
className="w-full px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
Retry
</button>
<button
onClick={() => router.push('/')}
className="w-full px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors"
>
Back to Home
</button>
</div>
</div>
</div>
</div>
);
}
if (!metrics) {
return null;
}
return (
<ProtectedRoute requireGalacticAdmin={true}>
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 p-6">
<div className="max-w-7xl mx-auto">
{/* Header */}
<div className="mb-6">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<button
onClick={() => router.push('/')}
className="p-2 hover:bg-white rounded-lg transition-colors"
title="Back to Home"
>
<svg className="h-6 w-6 text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
</button>
<div>
<h1 className="text-3xl font-bold text-gray-900">Platform Usage Metrics</h1>
<p className="text-sm text-gray-600 mt-1">
Cross-tenant metrics • Computed at {formatTimestamp(metrics.computedAt)} in {metrics.computeDurationMs}ms
{metrics.fromCache && (
<span className="ml-2 px-2 py-0.5 bg-blue-100 text-blue-700 text-xs rounded">
From Cache
</span>
)}
</p>
</div>
</div>
<button
onClick={() => fetchMetrics(true)}
disabled={refreshing}
className="px-4 py-2 bg-white text-gray-700 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center space-x-2"
>
<svg className={`h-5 w-5 ${refreshing ? 'animate-spin' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
<span>{refreshing ? 'Refreshing...' : 'Refresh'}</span>
</button>
</div>
</div>
{/* Session Statistics */}
<div className="mb-6">
<h2 className="text-xl font-semibold text-gray-900 mb-4">Sessions</h2>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-500 mb-1">Total Sessions</div>
<div className="text-3xl font-bold text-gray-900">{metrics.sessions.total.toLocaleString()}</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-500 mb-1">Today</div>
<div className="text-3xl font-bold text-blue-600">{metrics.sessions.today.toLocaleString()}</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-500 mb-1">Last 7 Days</div>
<div className="text-3xl font-bold text-indigo-600">{metrics.sessions.last7Days.toLocaleString()}</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-500 mb-1">Last 30 Days</div>
<div className="text-3xl font-bold text-purple-600">{metrics.sessions.last30Days.toLocaleString()}</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mt-4">
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-500 mb-1">Succeeded</div>
<div className="text-3xl font-bold text-green-600">{metrics.sessions.succeeded.toLocaleString()}</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-500 mb-1">Failed</div>
<div className="text-3xl font-bold text-red-600">{metrics.sessions.failed.toLocaleString()}</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-500 mb-1">In Progress</div>
<div className="text-3xl font-bold text-yellow-600">{metrics.sessions.inProgress.toLocaleString()}</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-500 mb-1">Success Rate</div>
<div className="text-3xl font-bold text-gray-900">{metrics.sessions.successRate}%</div>
</div>
</div>
</div>
{/* Tenant & User Statistics */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
{/* Tenants */}
<div>
<h2 className="text-xl font-semibold text-gray-900 mb-4">Tenants</h2>
<div className="space-y-4">
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-500 mb-1">Total Tenants</div>
<div className="text-3xl font-bold text-gray-900">{metrics.tenants.total.toLocaleString()}</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-500 mb-1">Active (Last 7 Days)</div>
<div className="text-3xl font-bold text-blue-600">{metrics.tenants.active7Days.toLocaleString()}</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-500 mb-1">Active (Last 30 Days)</div>
<div className="text-3xl font-bold text-indigo-600">{metrics.tenants.active30Days.toLocaleString()}</div>
</div>
</div>
</div>
{/* Users */}
<div>
<h2 className="text-xl font-semibold text-gray-900 mb-4">Users</h2>
<div className="bg-white rounded-lg shadow p-6">
<div className="text-center py-8">
<svg className="h-16 w-16 text-gray-300 mx-auto mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
<p className="text-gray-600 text-sm">{metrics.users.note}</p>
</div>
</div>
</div>
</div>
{/* Performance Statistics */}
<div className="mb-6">
<h2 className="text-xl font-semibold text-gray-900 mb-4">Performance</h2>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-500 mb-1">Average Duration</div>
<div className="text-3xl font-bold text-gray-900">{formatDuration(metrics.performance.avgDurationMinutes)}</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-500 mb-1">Median Duration</div>
<div className="text-3xl font-bold text-blue-600">{formatDuration(metrics.performance.medianDurationMinutes)}</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-500 mb-1">P95 Duration</div>
<div className="text-3xl font-bold text-orange-600">{formatDuration(metrics.performance.p95DurationMinutes)}</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-500 mb-1">P99 Duration</div>
<div className="text-3xl font-bold text-red-600">{formatDuration(metrics.performance.p99DurationMinutes)}</div>
</div>
</div>
</div>
{/* Hardware Statistics */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Top Manufacturers */}
<div>
<h2 className="text-xl font-semibold text-gray-900 mb-4">Top Manufacturers</h2>
<div className="bg-white rounded-lg shadow overflow-hidden">
<div className="divide-y divide-gray-200">
{metrics.hardware.topManufacturers.length > 0 ? (
metrics.hardware.topManufacturers.map((item, index) => (
<div key={index} className="p-4 hover:bg-gray-50 transition-colors">
<div className="flex items-center justify-between mb-2">
<span className="font-medium text-gray-900">{item.name || 'Unknown'}</span>
<div className="text-right">
<span className="text-sm font-semibold text-gray-900">{item.count.toLocaleString()}</span>
<span className="text-xs text-gray-500 ml-2">({item.percentage}%)</span>
</div>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
style={{ width: `${item.percentage}%` }}
></div>
</div>
</div>
))
) : (
<div className="p-8 text-center text-gray-500">No data available</div>
)}
</div>
</div>
</div>
{/* Top Models */}
<div>
<h2 className="text-xl font-semibold text-gray-900 mb-4">Top Models</h2>
<div className="bg-white rounded-lg shadow overflow-hidden">
<div className="divide-y divide-gray-200">
{metrics.hardware.topModels.length > 0 ? (
metrics.hardware.topModels.map((item, index) => (
<div key={index} className="p-4 hover:bg-gray-50 transition-colors">
<div className="flex items-center justify-between mb-2">
<span className="font-medium text-gray-900">{item.name || 'Unknown'}</span>
<div className="text-right">
<span className="text-sm font-semibold text-gray-900">{item.count.toLocaleString()}</span>
<span className="text-xs text-gray-500 ml-2">({item.percentage}%)</span>
</div>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-indigo-600 h-2 rounded-full transition-all duration-300"
style={{ width: `${item.percentage}%` }}
></div>
</div>
</div>
))
) : (
<div className="p-8 text-center text-gray-500">No data available</div>
)}
</div>
</div>
</div>
</div>
</div>
</div>
</ProtectedRoute>
);
}
@@ -0,0 +1,371 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { API_BASE_URL } from '@/lib/config';
interface SessionMetrics {
total: number;
today: number;
last7Days: number;
last30Days: number;
succeeded: number;
failed: number;
inProgress: number;
successRate: number;
}
interface TenantMetrics {
total: number;
active7Days: number;
active30Days: number;
}
interface UserMetrics {
total: number;
dailyLogins: number;
active7Days: number;
active30Days: number;
note: string;
}
interface PerformanceMetrics {
avgDurationMinutes: number;
medianDurationMinutes: number;
p95DurationMinutes: number;
p99DurationMinutes: number;
}
interface HardwareCount {
name: string;
count: number;
percentage: number;
}
interface HardwareMetrics {
topManufacturers: HardwareCount[];
topModels: HardwareCount[];
}
interface PlatformUsageMetrics {
sessions: SessionMetrics;
tenants: TenantMetrics;
users: UserMetrics;
performance: PerformanceMetrics;
hardware: HardwareMetrics;
computedAt: string;
computeDurationMs: number;
fromCache: boolean;
}
export default function PlatformUsageMetricsPage() {
const router = useRouter();
const [metrics, setMetrics] = useState<PlatformUsageMetrics | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [refreshing, setRefreshing] = useState(false);
const fetchMetrics = async (showRefreshing = false) => {
try {
if (showRefreshing) {
setRefreshing(true);
} else {
setLoading(true);
}
setError(null);
// Platform-wide metrics - cross-tenant (Galactic Admin only)
const response = await fetch(`${API_BASE_URL}/api/platform/usage-metrics`);
if (!response.ok) {
throw new Error(`Failed to fetch platform usage metrics: ${response.statusText}`);
}
const data = await response.json();
setMetrics(data);
} catch (err) {
console.error('Error fetching platform usage metrics:', err);
setError(err instanceof Error ? err.message : 'Failed to fetch platform usage metrics');
} finally {
setLoading(false);
setRefreshing(false);
}
};
useEffect(() => {
// Check if galactic admin mode is enabled
const galacticAdminMode = localStorage.getItem('galacticAdminMode') === 'true';
if (!galacticAdminMode) {
router.push('/');
return;
}
fetchMetrics();
}, [router]);
const formatDuration = (minutes: number) => {
if (minutes < 60) {
return `${minutes.toFixed(1)}m`;
}
const hours = Math.floor(minutes / 60);
const mins = Math.round(minutes % 60);
return `${hours}h ${mins}m`;
};
const formatTimestamp = (timestamp: string) => {
const date = new Date(timestamp);
return date.toLocaleString();
};
if (loading) {
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-4 text-gray-600">Loading platform usage metrics...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center p-4">
<div className="bg-white rounded-lg shadow-xl p-8 max-w-md w-full">
<div className="text-center">
<svg className="h-12 w-12 text-red-500 mx-auto" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<h2 className="mt-4 text-xl font-semibold text-gray-900">Error Loading Platform Usage Metrics</h2>
<p className="mt-2 text-gray-600">{error}</p>
<div className="mt-6 space-y-3">
<button
onClick={() => fetchMetrics()}
className="w-full px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
Retry
</button>
<button
onClick={() => router.push('/')}
className="w-full px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors"
>
Back to Home
</button>
</div>
</div>
</div>
</div>
);
}
if (!metrics) {
return null;
}
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 p-6">
<div className="max-w-7xl mx-auto">
{/* Header */}
<div className="mb-6">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<button
onClick={() => router.push('/')}
className="p-2 hover:bg-white rounded-lg transition-colors"
title="Back to Home"
>
<svg className="h-6 w-6 text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
</button>
<div>
<h1 className="text-3xl font-bold text-gray-900">Platform Usage Metrics</h1>
<p className="text-sm text-gray-600 mt-1">
Cross-tenant metrics • Computed at {formatTimestamp(metrics.computedAt)} in {metrics.computeDurationMs}ms
{metrics.fromCache && (
<span className="ml-2 px-2 py-0.5 bg-blue-100 text-blue-700 text-xs rounded">
From Cache
</span>
)}
</p>
</div>
</div>
<button
onClick={() => fetchMetrics(true)}
disabled={refreshing}
className="px-4 py-2 bg-white text-gray-700 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center space-x-2"
>
<svg className={`h-5 w-5 ${refreshing ? 'animate-spin' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
<span>{refreshing ? 'Refreshing...' : 'Refresh'}</span>
</button>
</div>
</div>
{/* Session Statistics */}
<div className="mb-6">
<h2 className="text-xl font-semibold text-gray-900 mb-4">Sessions</h2>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-500 mb-1">Total Sessions</div>
<div className="text-3xl font-bold text-gray-900">{metrics.sessions.total.toLocaleString()}</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-500 mb-1">Today</div>
<div className="text-3xl font-bold text-blue-600">{metrics.sessions.today.toLocaleString()}</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-500 mb-1">Last 7 Days</div>
<div className="text-3xl font-bold text-indigo-600">{metrics.sessions.last7Days.toLocaleString()}</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-500 mb-1">Last 30 Days</div>
<div className="text-3xl font-bold text-purple-600">{metrics.sessions.last30Days.toLocaleString()}</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mt-4">
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-500 mb-1">Succeeded</div>
<div className="text-3xl font-bold text-green-600">{metrics.sessions.succeeded.toLocaleString()}</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-500 mb-1">Failed</div>
<div className="text-3xl font-bold text-red-600">{metrics.sessions.failed.toLocaleString()}</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-500 mb-1">In Progress</div>
<div className="text-3xl font-bold text-yellow-600">{metrics.sessions.inProgress.toLocaleString()}</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-500 mb-1">Success Rate</div>
<div className="text-3xl font-bold text-gray-900">{metrics.sessions.successRate}%</div>
</div>
</div>
</div>
{/* Tenant & User Statistics */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
{/* Tenants */}
<div>
<h2 className="text-xl font-semibold text-gray-900 mb-4">Tenants</h2>
<div className="space-y-4">
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-500 mb-1">Total Tenants</div>
<div className="text-3xl font-bold text-gray-900">{metrics.tenants.total.toLocaleString()}</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-500 mb-1">Active (Last 7 Days)</div>
<div className="text-3xl font-bold text-blue-600">{metrics.tenants.active7Days.toLocaleString()}</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-500 mb-1">Active (Last 30 Days)</div>
<div className="text-3xl font-bold text-indigo-600">{metrics.tenants.active30Days.toLocaleString()}</div>
</div>
</div>
</div>
{/* Users */}
<div>
<h2 className="text-xl font-semibold text-gray-900 mb-4">Users</h2>
<div className="bg-white rounded-lg shadow p-6">
<div className="text-center py-8">
<svg className="h-16 w-16 text-gray-300 mx-auto mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
<p className="text-gray-600 text-sm">{metrics.users.note}</p>
</div>
</div>
</div>
</div>
{/* Performance Statistics */}
<div className="mb-6">
<h2 className="text-xl font-semibold text-gray-900 mb-4">Performance</h2>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-500 mb-1">Average Duration</div>
<div className="text-3xl font-bold text-gray-900">{formatDuration(metrics.performance.avgDurationMinutes)}</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-500 mb-1">Median Duration</div>
<div className="text-3xl font-bold text-blue-600">{formatDuration(metrics.performance.medianDurationMinutes)}</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-500 mb-1">P95 Duration</div>
<div className="text-3xl font-bold text-orange-600">{formatDuration(metrics.performance.p95DurationMinutes)}</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-500 mb-1">P99 Duration</div>
<div className="text-3xl font-bold text-red-600">{formatDuration(metrics.performance.p99DurationMinutes)}</div>
</div>
</div>
</div>
{/* Hardware Statistics */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Top Manufacturers */}
<div>
<h2 className="text-xl font-semibold text-gray-900 mb-4">Top Manufacturers</h2>
<div className="bg-white rounded-lg shadow overflow-hidden">
<div className="divide-y divide-gray-200">
{metrics.hardware.topManufacturers.length > 0 ? (
metrics.hardware.topManufacturers.map((item, index) => (
<div key={index} className="p-4 hover:bg-gray-50 transition-colors">
<div className="flex items-center justify-between mb-2">
<span className="font-medium text-gray-900">{item.name || 'Unknown'}</span>
<div className="text-right">
<span className="text-sm font-semibold text-gray-900">{item.count.toLocaleString()}</span>
<span className="text-xs text-gray-500 ml-2">({item.percentage}%)</span>
</div>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
style={{ width: `${item.percentage}%` }}
></div>
</div>
</div>
))
) : (
<div className="p-8 text-center text-gray-500">No data available</div>
)}
</div>
</div>
</div>
{/* Top Models */}
<div>
<h2 className="text-xl font-semibold text-gray-900 mb-4">Top Models</h2>
<div className="bg-white rounded-lg shadow overflow-hidden">
<div className="divide-y divide-gray-200">
{metrics.hardware.topModels.length > 0 ? (
metrics.hardware.topModels.map((item, index) => (
<div key={index} className="p-4 hover:bg-gray-50 transition-colors">
<div className="flex items-center justify-between mb-2">
<span className="font-medium text-gray-900">{item.name || 'Unknown'}</span>
<div className="text-right">
<span className="text-sm font-semibold text-gray-900">{item.count.toLocaleString()}</span>
<span className="text-xs text-gray-500 ml-2">({item.percentage}%)</span>
</div>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-indigo-600 h-2 rounded-full transition-all duration-300"
style={{ width: `${item.percentage}%` }}
></div>
</div>
</div>
))
) : (
<div className="p-8 text-center text-gray-500">No data available</div>
)}
</div>
</div>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,658 @@
"use client";
import { useEffect, useState, useRef } from "react";
import { useParams, useRouter } from "next/navigation";
import { useSignalR } from "../../../contexts/SignalRContext";
import { useTenant } from "../../../contexts/TenantContext";
import { ProtectedRoute } from '../../../components/ProtectedRoute';
import { API_BASE_URL } from "@/lib/config";
interface EnrollmentEvent {
eventId: string;
sessionId: string;
timestamp: string;
eventType: string;
severity: string;
source: string;
phase: number;
phaseName?: string;
message: string;
sequence: number;
data?: Record<string, any>;
}
interface Session {
sessionId: string;
tenantId: string;
serialNumber: string;
deviceName: string;
manufacturer: string;
model: string;
startedAt: string;
status: string;
currentPhase: number;
eventCount: number;
durationSeconds: number;
}
const phaseNames: Record<number, string> = {
0: "PreFlight",
1: "Network",
2: "Identity",
3: "MDM Enrollment",
4: "ESP Device Setup",
5: "App Installation",
6: "ESP User Setup",
7: "Complete",
99: "Failed"
};
export default function SessionDetailPage() {
const params = useParams();
const router = useRouter();
const sessionId = params?.sessionId as string;
const [events, setEvents] = useState<EnrollmentEvent[]>([]);
const [session, setSession] = useState<Session | null>(null);
const [sessionTenantId, setSessionTenantId] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [expandedPhases, setExpandedPhases] = useState<Set<string>>(new Set());
const [showMarkFailedConfirm, setShowMarkFailedConfirm] = useState(false);
const [adminMode, setAdminMode] = useState(() => {
if (typeof window !== 'undefined') {
return localStorage.getItem('adminMode') === 'true';
}
return false;
});
const [galacticAdminMode, setGalacticAdminMode] = useState(() => {
if (typeof window !== 'undefined') {
return localStorage.getItem('galacticAdminMode') === 'true';
}
return false;
});
// Use ref for debounce timeout to persist across renders
const refreshTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const sessionIdRef = useRef(sessionId);
const hasInitialFetch = useRef(false);
const lastFetchedSessionId = useRef<string | null>(null);
// Track if we've joined groups to prevent duplicate joins
const hasJoinedGroups = useRef(false);
// Use global contexts
const { on, off, isConnected, joinGroup, leaveGroup } = useSignalR();
const { tenantId } = useTenant();
// Initial data fetch
useEffect(() => {
if (!sessionId) return;
// Update sessionIdRef
sessionIdRef.current = sessionId;
// Reset fetch flag only if navigating to a different session
if (lastFetchedSessionId.current !== sessionId) {
hasInitialFetch.current = false;
lastFetchedSessionId.current = sessionId;
setSessionTenantId(null); // Reset session tenant ID for new session
}
// Prevent duplicate fetches in React StrictMode (development double-mounting)
if (hasInitialFetch.current) return;
hasInitialFetch.current = true;
fetchSessionDetails();
// fetchEvents will be called after sessionTenantId is set
}, [sessionId]);
// Fetch events when we have the session's tenant ID
useEffect(() => {
if (sessionTenantId && sessionId) {
fetchEvents();
}
}, [sessionTenantId, sessionId]);
// Join SignalR groups when connected (for multi-tenancy and cost optimization)
useEffect(() => {
// Use the session's tenant ID if available
const effectiveTenantId = sessionTenantId || tenantId;
if (!sessionId || !isConnected || !effectiveTenantId) return;
if (!hasJoinedGroups.current) {
// Join both tenant group (for newEvents) and session group (for eventStream)
const tenantGroupName = `tenant-${effectiveTenantId}`;
const sessionGroupName = `session-${effectiveTenantId}-${sessionId}`;
joinGroup(tenantGroupName);
joinGroup(sessionGroupName);
hasJoinedGroups.current = true;
}
return () => {
// Leave groups when component unmounts or sessionId changes
if (hasJoinedGroups.current && effectiveTenantId) {
const tenantGroupName = `tenant-${effectiveTenantId}`;
const sessionGroupName = `session-${effectiveTenantId}-${sessionId}`;
leaveGroup(tenantGroupName);
leaveGroup(sessionGroupName);
hasJoinedGroups.current = false;
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sessionId, isConnected, sessionTenantId, tenantId]);
// Setup SignalR listener - re-register when connection changes
useEffect(() => {
// Listen for real-time event stream (cost-efficient - no HTTP requests!)
const handleEventStream = (data: { sessionId: string; tenantId: string; events: EnrollmentEvent[]; session: Session }) => {
console.log('Event stream received via SignalR:', data);
if (data.sessionId === sessionIdRef.current && data.events && data.events.length > 0) {
// Add phase names to new events
const eventsWithPhaseNames = data.events.map((e: EnrollmentEvent) => ({
...e,
phaseName: phaseNames[e.phase] || "Unknown"
}));
// Add new events to existing list (deduplication by eventId)
setEvents(prevEvents => {
const existingIds = new Set(prevEvents.map(e => e.eventId));
const newEvents = eventsWithPhaseNames.filter(e => !existingIds.has(e.eventId));
return [...prevEvents, ...newEvents].sort((a, b) => a.sequence - b.sequence);
});
// Update session details directly from SignalR (no HTTP request needed!)
if (data.session) {
setSession(data.session);
}
}
};
// Also listen for summary updates to update session details
const handleNewEvents = (data: { sessionId: string; tenantId: string; eventCount: number; session: Session }) => {
if (data.sessionId === sessionIdRef.current) {
// Update session details directly from SignalR (no HTTP request needed!)
if (data.session) {
setSession(data.session);
}
}
};
on('eventstream', handleEventStream);
on('newevents', handleNewEvents);
return () => {
off('eventstream', handleEventStream);
off('newevents', handleNewEvents);
if (refreshTimeoutRef.current) {
clearTimeout(refreshTimeoutRef.current);
}
};
}, [on, off]); // Re-register when SignalR connection changes
const fetchSessionDetails = async () => {
try {
// In galactic admin mode, fetch from the galactic endpoint (cross-tenant)
// Otherwise, use the current tenant's sessions
const endpoint = galacticAdminMode
? `${API_BASE_URL}/api/galactic/sessions`
: `${API_BASE_URL}/api/sessions?tenantId=${tenantId}`;
const response = await fetch(endpoint);
if (response.ok) {
const data = await response.json();
const foundSession = data.sessions?.find((s: Session) => s.sessionId === sessionId);
if (foundSession) {
setSession(foundSession);
// Store the session's tenant ID for subsequent requests
setSessionTenantId(foundSession.tenantId);
}
}
} catch (error) {
console.error("Failed to fetch session details:", error);
}
};
const fetchEvents = async () => {
// Use the session's tenant ID if available, otherwise fall back to current user's tenant ID
const effectiveTenantId = sessionTenantId || tenantId;
try {
const response = await fetch(`${API_BASE_URL}/api/sessions/${sessionId}/events?tenantId=${effectiveTenantId}`);
if (response.ok) {
const data = await response.json();
const eventsWithPhaseNames = data.events.map((e: EnrollmentEvent) => ({
...e,
phaseName: phaseNames[e.phase] || "Unknown"
}));
setEvents(eventsWithPhaseNames);
}
} catch (error) {
console.error("Failed to fetch events:", error);
} finally {
setLoading(false);
}
};
const markAsFailed = () => {
setShowMarkFailedConfirm(true);
};
const confirmMarkFailed = async () => {
// Use the session's tenant ID if available
const effectiveTenantId = sessionTenantId || tenantId;
try {
const response = await fetch(`${API_BASE_URL}/api/sessions/${sessionId}/mark-failed?tenantId=${effectiveTenantId}`, {
method: 'POST',
});
if (response.ok) {
setShowMarkFailedConfirm(false);
// Session will be updated via SignalR, but we can also update local state immediately
if (session) {
setSession({
...session,
status: 'Failed'
});
}
} else {
console.error('Failed to mark session as failed');
}
} catch (error) {
console.error('Error marking session as failed:', error);
}
};
const cancelMarkFailed = () => {
setShowMarkFailedConfirm(false);
};
// Group events by phase
const eventsByPhase = events.reduce((acc, event) => {
const phaseName = event.phaseName || "Unknown";
if (!acc[phaseName]) {
acc[phaseName] = [];
}
acc[phaseName].push(event);
return acc;
}, {} as Record<string, EnrollmentEvent[]>);
const phaseOrder = ["PreFlight", "Network", "Identity", "MDM Enrollment", "ESP Device Setup", "App Installation", "ESP User Setup", "Complete", "Failed"];
const orderedPhases = phaseOrder.filter(phase => eventsByPhase[phase]);
// Initialize all phases as expanded on first load
useEffect(() => {
if (orderedPhases.length > 0 && expandedPhases.size === 0) {
setExpandedPhases(new Set(orderedPhases));
}
}, [orderedPhases.length]);
const expandAll = () => {
setExpandedPhases(new Set(orderedPhases));
};
const collapseAll = () => {
setExpandedPhases(new Set());
};
const togglePhase = (phaseName: string) => {
const newExpanded = new Set(expandedPhases);
if (newExpanded.has(phaseName)) {
newExpanded.delete(phaseName);
} else {
newExpanded.add(phaseName);
}
setExpandedPhases(newExpanded);
};
if (loading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-gray-600">Loading session details...</div>
</div>
);
}
return (
<ProtectedRoute>
<div className="min-h-screen bg-gray-50">
{/* Header */}
<header className="bg-white shadow">
<div className="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8 flex items-center justify-between">
<div>
<button
onClick={() => router.push('/')}
className="text-sm text-gray-600 hover:text-gray-900 mb-2 flex items-center"
>
← Back to Dashboard
</button>
<h1 className="text-3xl font-bold text-gray-900">
Session Details
</h1>
</div>
{adminMode && session?.status === 'InProgress' && (
<button
onClick={markAsFailed}
className="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 transition-colors flex items-center gap-2"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
Mark as Failed
</button>
)}
</div>
</header>
<main className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
<div className="px-4 py-6 sm:px-0">
{/* Session Info Card */}
{session && (
<div className="bg-white shadow rounded-lg p-6 mb-6">
<h2 className="text-xl font-semibold text-gray-900 mb-4">Device Information</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<InfoItem label="Device" value={session.deviceName || session.serialNumber} />
<InfoItem label="Model" value={`${session.manufacturer} ${session.model}`} />
<InfoItem label="Serial Number" value={session.serialNumber} />
<InfoItem label="Session ID" value={session.sessionId} />
<InfoItem label="Status" value={<StatusBadge status={session.status} />} />
<InfoItem label="Events" value={session.eventCount.toString()} />
<InfoItem label="Duration" value={`${Math.round(session.durationSeconds / 60)} min`} />
</div>
</div>
)}
{/* Phase Timeline */}
{session && (
<div className="bg-white shadow rounded-lg p-6 mb-6">
<h2 className="text-xl font-semibold text-gray-900 mb-6">Enrollment Progress</h2>
<PhaseTimeline
currentPhase={session.currentPhase}
completedPhases={session.status === 'Succeeded' ? [7] : []}
/>
</div>
)}
{/* Timeline */}
<div className="bg-white shadow rounded-lg p-6">
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-semibold text-gray-900">Event Timeline</h2>
{orderedPhases.length > 0 && (
<div className="flex gap-2">
<button
onClick={expandAll}
className="px-3 py-1 text-sm bg-blue-50 text-blue-700 hover:bg-blue-100 rounded transition-colors"
>
Expand All
</button>
<button
onClick={collapseAll}
className="px-3 py-1 text-sm bg-gray-50 text-gray-700 hover:bg-gray-100 rounded transition-colors"
>
Collapse All
</button>
</div>
)}
</div>
{orderedPhases.length === 0 ? (
<div className="text-gray-500 text-center py-8">No events found for this session.</div>
) : (
<div className="space-y-8">
{orderedPhases.map((phaseName) => (
<PhaseSection
key={phaseName}
phaseName={phaseName}
events={eventsByPhase[phaseName]}
isExpanded={expandedPhases.has(phaseName)}
onToggle={() => togglePhase(phaseName)}
/>
))}
</div>
)}
</div>
</div>
{/* Mark as Failed Confirmation Modal */}
{showMarkFailedConfirm && session && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50" onClick={cancelMarkFailed}>
<div className="bg-white rounded-lg shadow-xl max-w-md w-full mx-4" onClick={(e) => e.stopPropagation()}>
<div className="p-6">
<div className="flex items-center mb-4">
<div className="flex-shrink-0 w-12 h-12 bg-red-100 rounded-full flex items-center justify-center">
<svg className="w-6 h-6 text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<h3 className="ml-4 text-lg font-semibold text-gray-900">Mark Session as Failed</h3>
</div>
<div className="mb-6">
<p className="text-sm text-gray-700 mb-2">
You are about to manually mark this session as <span className="font-semibold text-red-600">Failed</span>.
</p>
<p className="text-sm text-gray-700 mb-2">
Session <span className="font-mono text-xs">{session.sessionId}</span> for device <span className="font-semibold">{session.deviceName || session.serialNumber}</span> will be marked as failed with the reason "Manually marked as failed by administrator".
</p>
<p className="text-sm text-gray-600">
This action will update the session status and cannot be undone. Do you want to continue?
</p>
</div>
<div className="flex justify-end gap-3">
<button onClick={cancelMarkFailed} className="px-4 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 transition-colors">
Cancel
</button>
<button onClick={confirmMarkFailed} className="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 transition-colors">
Mark as Failed
</button>
</div>
</div>
</div>
</div>
)}
</main>
</div>
</ProtectedRoute>
);
}
function InfoItem({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div>
<div className="text-sm font-medium text-gray-500">{label}</div>
<div className="mt-1 text-sm text-gray-900">{value}</div>
</div>
);
}
function PhaseSection({
phaseName,
events,
isExpanded,
onToggle
}: {
phaseName: string;
events: EnrollmentEvent[];
isExpanded: boolean;
onToggle: () => void;
}) {
return (
<div className="border-l-4 border-blue-500 pl-4">
<button
onClick={onToggle}
className="flex items-center justify-between w-full text-left mb-3 group"
>
<h3 className="text-lg font-semibold text-gray-900 group-hover:text-blue-600">
{phaseName} ({events.length} events)
</h3>
<span className="text-gray-400">{isExpanded ? '▼' : '▶'}</span>
</button>
{isExpanded && (
<div className="space-y-3">
{events.map((event, index) => (
<EventRow key={event.eventId || `${event.sessionId}-${event.sequence}`} event={event} />
))}
</div>
)}
</div>
);
}
function EventRow({ event }: { event: EnrollmentEvent }) {
const [showDetails, setShowDetails] = useState(false);
return (
<div className="bg-gray-50 rounded-lg p-3 hover:bg-gray-100 transition-colors">
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3">
<span className="text-xs text-gray-500 font-mono">
{new Date(event.timestamp).toLocaleTimeString()}
</span>
<SeverityBadge severity={event.severity} />
<span className="text-sm font-medium text-gray-900">{event.eventType}</span>
</div>
<p className="mt-1 text-sm text-gray-600">{event.message}</p>
<div className="mt-1 flex items-center gap-3 text-xs text-gray-500">
<span>Source: {event.source}</span>
<span>Seq: {event.sequence}</span>
</div>
</div>
{event.data && Object.keys(event.data).length > 0 && (
<button
onClick={() => setShowDetails(!showDetails)}
className="text-xs text-blue-600 hover:text-blue-800 ml-4"
>
{showDetails ? 'Hide' : 'Details'}
</button>
)}
</div>
{showDetails && event.data && (
<div className="mt-3 p-3 bg-gray-900 rounded text-xs text-gray-100 font-mono overflow-x-auto">
<pre>{JSON.stringify(event.data, null, 2)}</pre>
</div>
)}
</div>
);
}
function PhaseTimeline({ currentPhase, completedPhases }: { currentPhase: number; completedPhases: number[] }) {
console.log('[PhaseTimeline] currentPhase:', currentPhase, 'completedPhases:', completedPhases);
const phases = [
{ id: 0, name: "PreFlight", shortName: "Pre-Flight" },
{ id: 1, name: "Network", shortName: "Network" },
{ id: 2, name: "Identity", shortName: "Identity" },
{ id: 3, name: "MDM Enrollment", shortName: "MDM" },
{ id: 4, name: "ESP Device Setup", shortName: "Device" },
{ id: 5, name: "App Installation", shortName: "Apps" },
{ id: 6, name: "ESP User Setup", shortName: "User" },
{ id: 7, name: "Complete", shortName: "Complete" },
];
const getPhaseStatus = (phaseId: number) => {
// If phase is completed, show as completed (green) even if it's the current phase
if (completedPhases.includes(phaseId)) return 'completed';
// If it's the current phase but not completed yet, show as current (blue)
if (phaseId === currentPhase) return 'current';
// If it's before the current phase, show as completed (for phases that were skipped or not explicitly tracked)
if (phaseId < currentPhase) return 'completed';
return 'pending';
};
const getPhaseColor = (status: string) => {
switch (status) {
case 'completed': return 'bg-green-500 text-white border-green-500';
case 'current': return 'bg-blue-500 text-white border-blue-500 ring-4 ring-blue-200';
case 'pending': return 'bg-gray-200 text-gray-500 border-gray-300';
default: return 'bg-gray-200 text-gray-500 border-gray-300';
}
};
const getConnectorColor = (fromPhase: number) => {
const fromStatus = getPhaseStatus(fromPhase);
const toStatus = getPhaseStatus(fromPhase + 1);
return fromStatus === 'completed' ? 'bg-green-500' : 'bg-gray-300';
};
return (
<div className="w-full overflow-x-auto py-4">
<div className="flex items-center w-full">
{phases.map((phase, index) => {
const status = getPhaseStatus(phase.id);
const nextStatus = index < phases.length - 1 ? getPhaseStatus(phases[index + 1].id) : null;
const showArrow = status === 'current';
return (
<div key={phase.id} className="flex items-center" style={{ flex: index < phases.length - 1 ? '1 1 0%' : '0 0 auto' }}>
<div className="flex flex-col items-center flex-shrink-0">
<div className={`w-12 h-12 rounded-full flex items-center justify-center border-2 transition-all font-semibold ${getPhaseColor(status)}`}>
{status === 'completed' ? '✓' : phase.id + 1}
</div>
<div className="mt-3 text-xs font-medium text-center text-gray-700 whitespace-nowrap">
{phase.shortName}
</div>
</div>
{index < phases.length - 1 && (
<div className="flex items-center flex-1 mx-3 mb-8">
<div className={`h-1 flex-1 transition-all ${getConnectorColor(phase.id)}`} />
{showArrow && (
<div className="relative" style={{ marginLeft: '-6px', marginRight: '-6px' }}>
<div
className={`w-0 h-0 border-t-[8px] border-t-transparent border-b-[8px] border-b-transparent border-l-[12px] ${
getConnectorColor(phase.id) === 'bg-green-500' ? 'border-l-green-500' : 'border-l-gray-300'
}`}
style={{
filter: getConnectorColor(phase.id) === 'bg-green-500'
? 'drop-shadow(0 0 3px rgba(34, 197, 94, 0.5))'
: 'drop-shadow(0 0 3px rgba(209, 213, 219, 0.5))'
}}
/>
</div>
)}
{!showArrow && <div className={`h-1 flex-1 transition-all ${getConnectorColor(phase.id)}`} />}
</div>
)}
</div>
);
})}
</div>
</div>
);
}
function SeverityBadge({ severity }: { severity: string }) {
const colors = {
Info: "bg-blue-100 text-blue-800",
Warning: "bg-yellow-100 text-yellow-800",
Error: "bg-red-100 text-red-800",
Critical: "bg-red-200 text-red-900"
};
const color = colors[severity as keyof typeof colors] || colors.Info;
return (
<span className={`px-2 py-0.5 rounded text-xs font-medium ${color}`}>
{severity}
</span>
);
}
function StatusBadge({ status }: { status: string }) {
const statusConfig = {
InProgress: { color: "bg-blue-100 text-blue-800", text: "In Progress" },
Succeeded: { color: "bg-green-100 text-green-800", text: "Succeeded" },
Failed: { color: "bg-red-100 text-red-800", text: "Failed" },
Unknown: { color: "bg-gray-100 text-gray-800", text: "Unknown" },
};
const config = statusConfig[status as keyof typeof statusConfig] || statusConfig.Unknown;
return (
<span className={`px-2 inline-block text-xs leading-5 font-semibold rounded-full ${config.color}`}>
{config.text}
</span>
);
}
@@ -0,0 +1,625 @@
"use client";
import { useEffect, useState, useRef } from "react";
import { useParams, useRouter } from "next/navigation";
import { useSignalR } from "../../../contexts/SignalRContext";
import { useTenant } from "../../../contexts/TenantContext";
import { API_BASE_URL } from "@/lib/config";
interface EnrollmentEvent {
eventId: string;
sessionId: string;
timestamp: string;
eventType: string;
severity: string;
source: string;
phase: number;
phaseName?: string;
message: string;
sequence: number;
data?: Record<string, any>;
}
interface Session {
sessionId: string;
tenantId: string;
serialNumber: string;
deviceName: string;
manufacturer: string;
model: string;
startedAt: string;
status: string;
currentPhase: number;
eventCount: number;
durationSeconds: number;
}
const phaseNames: Record<number, string> = {
0: "PreFlight",
1: "Network",
2: "Identity",
3: "MDM Enrollment",
4: "ESP Device Setup",
5: "App Installation",
6: "ESP User Setup",
7: "Complete",
99: "Failed"
};
export default function SessionDetailPage() {
const params = useParams();
const router = useRouter();
const sessionId = params?.sessionId as string;
const [events, setEvents] = useState<EnrollmentEvent[]>([]);
const [session, setSession] = useState<Session | null>(null);
const [loading, setLoading] = useState(true);
const [expandedPhases, setExpandedPhases] = useState<Set<string>>(new Set());
const [showMarkFailedConfirm, setShowMarkFailedConfirm] = useState(false);
const [adminMode, setAdminMode] = useState(() => {
if (typeof window !== 'undefined') {
return localStorage.getItem('adminMode') === 'true';
}
return false;
});
// Use ref for debounce timeout to persist across renders
const refreshTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const sessionIdRef = useRef(sessionId);
const hasInitialFetch = useRef(false);
const lastFetchedSessionId = useRef<string | null>(null);
// Track if we've joined groups to prevent duplicate joins
const hasJoinedGroups = useRef(false);
// Use global contexts
const { on, off, isConnected, joinGroup, leaveGroup } = useSignalR();
const { tenantId } = useTenant();
// Initial data fetch
useEffect(() => {
if (!sessionId) return;
// Update sessionIdRef
sessionIdRef.current = sessionId;
// Reset fetch flag only if navigating to a different session
if (lastFetchedSessionId.current !== sessionId) {
hasInitialFetch.current = false;
lastFetchedSessionId.current = sessionId;
}
// Prevent duplicate fetches in React StrictMode (development double-mounting)
if (hasInitialFetch.current) return;
hasInitialFetch.current = true;
fetchSessionDetails();
fetchEvents();
}, [sessionId]);
// Join SignalR groups when connected (for multi-tenancy and cost optimization)
useEffect(() => {
if (!sessionId || !isConnected || !tenantId) return;
if (!hasJoinedGroups.current) {
// Join both tenant group (for newEvents) and session group (for eventStream)
const tenantGroupName = `tenant-${tenantId}`;
const sessionGroupName = `session-${tenantId}-${sessionId}`;
joinGroup(tenantGroupName);
joinGroup(sessionGroupName);
hasJoinedGroups.current = true;
}
return () => {
// Leave groups when component unmounts or sessionId changes
if (hasJoinedGroups.current && tenantId) {
const tenantGroupName = `tenant-${tenantId}`;
const sessionGroupName = `session-${tenantId}-${sessionId}`;
leaveGroup(tenantGroupName);
leaveGroup(sessionGroupName);
hasJoinedGroups.current = false;
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sessionId, isConnected, tenantId]);
// Setup SignalR listener - re-register when connection changes
useEffect(() => {
// Listen for real-time event stream (cost-efficient - no HTTP requests!)
const handleEventStream = (data: { sessionId: string; tenantId: string; events: EnrollmentEvent[]; session: Session }) => {
console.log('Event stream received via SignalR:', data);
if (data.sessionId === sessionIdRef.current && data.events && data.events.length > 0) {
// Add phase names to new events
const eventsWithPhaseNames = data.events.map((e: EnrollmentEvent) => ({
...e,
phaseName: phaseNames[e.phase] || "Unknown"
}));
// Add new events to existing list (deduplication by eventId)
setEvents(prevEvents => {
const existingIds = new Set(prevEvents.map(e => e.eventId));
const newEvents = eventsWithPhaseNames.filter(e => !existingIds.has(e.eventId));
return [...prevEvents, ...newEvents].sort((a, b) => a.sequence - b.sequence);
});
// Update session details directly from SignalR (no HTTP request needed!)
if (data.session) {
setSession(data.session);
}
}
};
// Also listen for summary updates to update session details
const handleNewEvents = (data: { sessionId: string; tenantId: string; eventCount: number; session: Session }) => {
if (data.sessionId === sessionIdRef.current) {
// Update session details directly from SignalR (no HTTP request needed!)
if (data.session) {
setSession(data.session);
}
}
};
on('eventstream', handleEventStream);
on('newevents', handleNewEvents);
return () => {
off('eventstream', handleEventStream);
off('newevents', handleNewEvents);
if (refreshTimeoutRef.current) {
clearTimeout(refreshTimeoutRef.current);
}
};
}, [on, off]); // Re-register when SignalR connection changes
const fetchSessionDetails = async () => {
try {
// For now, we'll get session details from the sessions list
const response = await fetch(`${API_BASE_URL}/api/sessions?tenantId=${tenantId}`);
if (response.ok) {
const data = await response.json();
const foundSession = data.sessions?.find((s: Session) => s.sessionId === sessionId);
if (foundSession) {
setSession(foundSession);
}
}
} catch (error) {
console.error("Failed to fetch session details:", error);
}
};
const fetchEvents = async () => {
try {
const response = await fetch(`${API_BASE_URL}/api/sessions/${sessionId}/events?tenantId=${tenantId}`);
if (response.ok) {
const data = await response.json();
const eventsWithPhaseNames = data.events.map((e: EnrollmentEvent) => ({
...e,
phaseName: phaseNames[e.phase] || "Unknown"
}));
setEvents(eventsWithPhaseNames);
}
} catch (error) {
console.error("Failed to fetch events:", error);
} finally {
setLoading(false);
}
};
const markAsFailed = () => {
setShowMarkFailedConfirm(true);
};
const confirmMarkFailed = async () => {
try {
const response = await fetch(`${API_BASE_URL}/api/sessions/${sessionId}/mark-failed?tenantId=${tenantId}`, {
method: 'POST',
});
if (response.ok) {
setShowMarkFailedConfirm(false);
// Session will be updated via SignalR, but we can also update local state immediately
if (session) {
setSession({
...session,
status: 'Failed'
});
}
} else {
console.error('Failed to mark session as failed');
}
} catch (error) {
console.error('Error marking session as failed:', error);
}
};
const cancelMarkFailed = () => {
setShowMarkFailedConfirm(false);
};
// Group events by phase
const eventsByPhase = events.reduce((acc, event) => {
const phaseName = event.phaseName || "Unknown";
if (!acc[phaseName]) {
acc[phaseName] = [];
}
acc[phaseName].push(event);
return acc;
}, {} as Record<string, EnrollmentEvent[]>);
const phaseOrder = ["PreFlight", "Network", "Identity", "MDM Enrollment", "ESP Device Setup", "App Installation", "ESP User Setup", "Complete", "Failed"];
const orderedPhases = phaseOrder.filter(phase => eventsByPhase[phase]);
// Initialize all phases as expanded on first load
useEffect(() => {
if (orderedPhases.length > 0 && expandedPhases.size === 0) {
setExpandedPhases(new Set(orderedPhases));
}
}, [orderedPhases.length]);
const expandAll = () => {
setExpandedPhases(new Set(orderedPhases));
};
const collapseAll = () => {
setExpandedPhases(new Set());
};
const togglePhase = (phaseName: string) => {
const newExpanded = new Set(expandedPhases);
if (newExpanded.has(phaseName)) {
newExpanded.delete(phaseName);
} else {
newExpanded.add(phaseName);
}
setExpandedPhases(newExpanded);
};
if (loading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-gray-600">Loading session details...</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50">
{/* Header */}
<header className="bg-white shadow">
<div className="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8 flex items-center justify-between">
<div>
<button
onClick={() => router.push('/')}
className="text-sm text-gray-600 hover:text-gray-900 mb-2 flex items-center"
>
← Back to Dashboard
</button>
<h1 className="text-3xl font-bold text-gray-900">
Session Details
</h1>
</div>
{adminMode && session?.status === 'InProgress' && (
<button
onClick={markAsFailed}
className="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 transition-colors flex items-center gap-2"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
Mark as Failed
</button>
)}
</div>
</header>
<main className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
<div className="px-4 py-6 sm:px-0">
{/* Session Info Card */}
{session && (
<div className="bg-white shadow rounded-lg p-6 mb-6">
<h2 className="text-xl font-semibold text-gray-900 mb-4">Device Information</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<InfoItem label="Device" value={session.deviceName || session.serialNumber} />
<InfoItem label="Model" value={`${session.manufacturer} ${session.model}`} />
<InfoItem label="Serial Number" value={session.serialNumber} />
<InfoItem label="Session ID" value={session.sessionId} />
<InfoItem label="Status" value={<StatusBadge status={session.status} />} />
<InfoItem label="Events" value={session.eventCount.toString()} />
<InfoItem label="Duration" value={`${Math.round(session.durationSeconds / 60)} min`} />
</div>
</div>
)}
{/* Phase Timeline */}
{session && (
<div className="bg-white shadow rounded-lg p-6 mb-6">
<h2 className="text-xl font-semibold text-gray-900 mb-6">Enrollment Progress</h2>
<PhaseTimeline
currentPhase={session.currentPhase}
completedPhases={session.status === 'Succeeded' ? [7] : []}
/>
</div>
)}
{/* Timeline */}
<div className="bg-white shadow rounded-lg p-6">
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-semibold text-gray-900">Event Timeline</h2>
{orderedPhases.length > 0 && (
<div className="flex gap-2">
<button
onClick={expandAll}
className="px-3 py-1 text-sm bg-blue-50 text-blue-700 hover:bg-blue-100 rounded transition-colors"
>
Expand All
</button>
<button
onClick={collapseAll}
className="px-3 py-1 text-sm bg-gray-50 text-gray-700 hover:bg-gray-100 rounded transition-colors"
>
Collapse All
</button>
</div>
)}
</div>
{orderedPhases.length === 0 ? (
<div className="text-gray-500 text-center py-8">No events found for this session.</div>
) : (
<div className="space-y-8">
{orderedPhases.map((phaseName) => (
<PhaseSection
key={phaseName}
phaseName={phaseName}
events={eventsByPhase[phaseName]}
isExpanded={expandedPhases.has(phaseName)}
onToggle={() => togglePhase(phaseName)}
/>
))}
</div>
)}
</div>
</div>
{/* Mark as Failed Confirmation Modal */}
{showMarkFailedConfirm && session && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50" onClick={cancelMarkFailed}>
<div className="bg-white rounded-lg shadow-xl max-w-md w-full mx-4" onClick={(e) => e.stopPropagation()}>
<div className="p-6">
<div className="flex items-center mb-4">
<div className="flex-shrink-0 w-12 h-12 bg-red-100 rounded-full flex items-center justify-center">
<svg className="w-6 h-6 text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<h3 className="ml-4 text-lg font-semibold text-gray-900">Mark Session as Failed</h3>
</div>
<div className="mb-6">
<p className="text-sm text-gray-700 mb-2">
You are about to manually mark this session as <span className="font-semibold text-red-600">Failed</span>.
</p>
<p className="text-sm text-gray-700 mb-2">
Session <span className="font-mono text-xs">{session.sessionId}</span> for device <span className="font-semibold">{session.deviceName || session.serialNumber}</span> will be marked as failed with the reason "Manually marked as failed by administrator".
</p>
<p className="text-sm text-gray-600">
This action will update the session status and cannot be undone. Do you want to continue?
</p>
</div>
<div className="flex justify-end gap-3">
<button onClick={cancelMarkFailed} className="px-4 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 transition-colors">
Cancel
</button>
<button onClick={confirmMarkFailed} className="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 transition-colors">
Mark as Failed
</button>
</div>
</div>
</div>
</div>
)}
</main>
</div>
);
}
function InfoItem({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div>
<div className="text-sm font-medium text-gray-500">{label}</div>
<div className="mt-1 text-sm text-gray-900">{value}</div>
</div>
);
}
function PhaseSection({
phaseName,
events,
isExpanded,
onToggle
}: {
phaseName: string;
events: EnrollmentEvent[];
isExpanded: boolean;
onToggle: () => void;
}) {
return (
<div className="border-l-4 border-blue-500 pl-4">
<button
onClick={onToggle}
className="flex items-center justify-between w-full text-left mb-3 group"
>
<h3 className="text-lg font-semibold text-gray-900 group-hover:text-blue-600">
{phaseName} ({events.length} events)
</h3>
<span className="text-gray-400">{isExpanded ? '▼' : '▶'}</span>
</button>
{isExpanded && (
<div className="space-y-3">
{events.map((event, index) => (
<EventRow key={event.eventId || `${event.sessionId}-${event.sequence}`} event={event} />
))}
</div>
)}
</div>
);
}
function EventRow({ event }: { event: EnrollmentEvent }) {
const [showDetails, setShowDetails] = useState(false);
return (
<div className="bg-gray-50 rounded-lg p-3 hover:bg-gray-100 transition-colors">
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3">
<span className="text-xs text-gray-500 font-mono">
{new Date(event.timestamp).toLocaleTimeString()}
</span>
<SeverityBadge severity={event.severity} />
<span className="text-sm font-medium text-gray-900">{event.eventType}</span>
</div>
<p className="mt-1 text-sm text-gray-600">{event.message}</p>
<div className="mt-1 flex items-center gap-3 text-xs text-gray-500">
<span>Source: {event.source}</span>
<span>Seq: {event.sequence}</span>
</div>
</div>
{event.data && Object.keys(event.data).length > 0 && (
<button
onClick={() => setShowDetails(!showDetails)}
className="text-xs text-blue-600 hover:text-blue-800 ml-4"
>
{showDetails ? 'Hide' : 'Details'}
</button>
)}
</div>
{showDetails && event.data && (
<div className="mt-3 p-3 bg-gray-900 rounded text-xs text-gray-100 font-mono overflow-x-auto">
<pre>{JSON.stringify(event.data, null, 2)}</pre>
</div>
)}
</div>
);
}
function PhaseTimeline({ currentPhase, completedPhases }: { currentPhase: number; completedPhases: number[] }) {
console.log('[PhaseTimeline] currentPhase:', currentPhase, 'completedPhases:', completedPhases);
const phases = [
{ id: 0, name: "PreFlight", shortName: "Pre-Flight" },
{ id: 1, name: "Network", shortName: "Network" },
{ id: 2, name: "Identity", shortName: "Identity" },
{ id: 3, name: "MDM Enrollment", shortName: "MDM" },
{ id: 4, name: "ESP Device Setup", shortName: "Device" },
{ id: 5, name: "App Installation", shortName: "Apps" },
{ id: 6, name: "ESP User Setup", shortName: "User" },
{ id: 7, name: "Complete", shortName: "Complete" },
];
const getPhaseStatus = (phaseId: number) => {
// If phase is completed, show as completed (green) even if it's the current phase
if (completedPhases.includes(phaseId)) return 'completed';
// If it's the current phase but not completed yet, show as current (blue)
if (phaseId === currentPhase) return 'current';
// If it's before the current phase, show as completed (for phases that were skipped or not explicitly tracked)
if (phaseId < currentPhase) return 'completed';
return 'pending';
};
const getPhaseColor = (status: string) => {
switch (status) {
case 'completed': return 'bg-green-500 text-white border-green-500';
case 'current': return 'bg-blue-500 text-white border-blue-500 ring-4 ring-blue-200';
case 'pending': return 'bg-gray-200 text-gray-500 border-gray-300';
default: return 'bg-gray-200 text-gray-500 border-gray-300';
}
};
const getConnectorColor = (fromPhase: number) => {
const fromStatus = getPhaseStatus(fromPhase);
const toStatus = getPhaseStatus(fromPhase + 1);
return fromStatus === 'completed' ? 'bg-green-500' : 'bg-gray-300';
};
return (
<div className="w-full overflow-x-auto py-4">
<div className="flex items-center w-full">
{phases.map((phase, index) => {
const status = getPhaseStatus(phase.id);
const nextStatus = index < phases.length - 1 ? getPhaseStatus(phases[index + 1].id) : null;
const showArrow = status === 'current';
return (
<div key={phase.id} className="flex items-center" style={{ flex: index < phases.length - 1 ? '1 1 0%' : '0 0 auto' }}>
<div className="flex flex-col items-center flex-shrink-0">
<div className={`w-12 h-12 rounded-full flex items-center justify-center border-2 transition-all font-semibold ${getPhaseColor(status)}`}>
{status === 'completed' ? '✓' : phase.id + 1}
</div>
<div className="mt-3 text-xs font-medium text-center text-gray-700 whitespace-nowrap">
{phase.shortName}
</div>
</div>
{index < phases.length - 1 && (
<div className="flex items-center flex-1 mx-3 mb-8">
<div className={`h-1 flex-1 transition-all ${getConnectorColor(phase.id)}`} />
{showArrow && (
<div className="relative" style={{ marginLeft: '-6px', marginRight: '-6px' }}>
<div
className={`w-0 h-0 border-t-[8px] border-t-transparent border-b-[8px] border-b-transparent border-l-[12px] ${
getConnectorColor(phase.id) === 'bg-green-500' ? 'border-l-green-500' : 'border-l-gray-300'
}`}
style={{
filter: getConnectorColor(phase.id) === 'bg-green-500'
? 'drop-shadow(0 0 3px rgba(34, 197, 94, 0.5))'
: 'drop-shadow(0 0 3px rgba(209, 213, 219, 0.5))'
}}
/>
</div>
)}
{!showArrow && <div className={`h-1 flex-1 transition-all ${getConnectorColor(phase.id)}`} />}
</div>
)}
</div>
);
})}
</div>
</div>
);
}
function SeverityBadge({ severity }: { severity: string }) {
const colors = {
Info: "bg-blue-100 text-blue-800",
Warning: "bg-yellow-100 text-yellow-800",
Error: "bg-red-100 text-red-800",
Critical: "bg-red-200 text-red-900"
};
const color = colors[severity as keyof typeof colors] || colors.Info;
return (
<span className={`px-2 py-0.5 rounded text-xs font-medium ${color}`}>
{severity}
</span>
);
}
function StatusBadge({ status }: { status: string }) {
const statusConfig = {
InProgress: { color: "bg-blue-100 text-blue-800", text: "In Progress" },
Succeeded: { color: "bg-green-100 text-green-800", text: "Succeeded" },
Failed: { color: "bg-red-100 text-red-800", text: "Failed" },
Unknown: { color: "bg-gray-100 text-gray-800", text: "Unknown" },
};
const config = statusConfig[status as keyof typeof statusConfig] || statusConfig.Unknown;
return (
<span className={`px-2 inline-block text-xs leading-5 font-semibold rounded-full ${config.color}`}>
{config.text}
</span>
);
}
@@ -0,0 +1,542 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { ProtectedRoute } from "../../components/ProtectedRoute";
import { useTenant } from "../../contexts/TenantContext";
import { API_BASE_URL } from "@/lib/config";
interface TenantConfiguration {
tenantId: string;
lastUpdated: string;
updatedBy: string;
securityEnabled: boolean;
rateLimitRequestsPerMinute: number;
manufacturerWhitelist: string;
modelWhitelist: string;
validateSerialNumber: boolean;
dataRetentionDays: number;
sessionTimeoutHours: number;
customSettings?: string;
}
export default function SettingsPage() {
const router = useRouter();
const { tenantId } = useTenant();
const [config, setConfig] = useState<TenantConfiguration | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
// Form state
const [securityEnabled, setSecurityEnabled] = useState(true);
const [rateLimitRequestsPerMinute, setRateLimitRequestsPerMinute] = useState(100);
const [manufacturerWhitelist, setManufacturerWhitelist] = useState("Dell*,HP*,Lenovo*,Microsoft Corporation");
const [modelWhitelist, setModelWhitelist] = useState("*");
const [validateSerialNumber, setValidateSerialNumber] = useState(false);
const [dataRetentionDays, setDataRetentionDays] = useState(90);
const [sessionTimeoutHours, setSessionTimeoutHours] = useState(5);
// Galactic Admin state
const [galacticAdminMode, setGalacticAdminMode] = useState(false);
const [triggeringMaintenance, setTriggeringMaintenance] = useState(false);
// Load galactic admin mode from localStorage
useEffect(() => {
const galacticMode = localStorage.getItem("galacticAdminMode") === "true";
setGalacticAdminMode(galacticMode);
}, []);
// Fetch configuration
useEffect(() => {
if (!tenantId) return;
const fetchConfiguration = async () => {
try {
setLoading(true);
setError(null);
const response = await fetch(`${API_BASE_URL}/api/config/${tenantId}`);
if (!response.ok) {
throw new Error(`Failed to load configuration: ${response.statusText}`);
}
const data: TenantConfiguration = await response.json();
setConfig(data);
// Update form state
setSecurityEnabled(data.securityEnabled);
setRateLimitRequestsPerMinute(data.rateLimitRequestsPerMinute);
setManufacturerWhitelist(data.manufacturerWhitelist);
setModelWhitelist(data.modelWhitelist);
setValidateSerialNumber(data.validateSerialNumber);
setDataRetentionDays(data.dataRetentionDays ?? 90);
setSessionTimeoutHours(data.sessionTimeoutHours ?? 5);
} catch (err) {
console.error("Error fetching configuration:", err);
setError(err instanceof Error ? err.message : "Failed to load configuration");
} finally {
setLoading(false);
}
};
fetchConfiguration();
}, [tenantId]);
const handleSave = async () => {
if (!tenantId || !config) return;
try {
setSaving(true);
setError(null);
setSuccessMessage(null);
const updatedConfig: TenantConfiguration = {
...config,
securityEnabled,
rateLimitRequestsPerMinute,
manufacturerWhitelist,
modelWhitelist,
validateSerialNumber,
dataRetentionDays,
sessionTimeoutHours,
};
const response = await fetch(`${API_BASE_URL}/api/config/${tenantId}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(updatedConfig),
});
if (!response.ok) {
throw new Error(`Failed to save configuration: ${response.statusText}`);
}
const result = await response.json();
setConfig(result.config);
setSuccessMessage("Configuration saved successfully!");
// Auto-hide success message after 3 seconds
setTimeout(() => setSuccessMessage(null), 3000);
} catch (err) {
console.error("Error saving configuration:", err);
setError(err instanceof Error ? err.message : "Failed to save configuration");
} finally {
setSaving(false);
}
};
const handleReset = () => {
if (!config) return;
setSecurityEnabled(config.securityEnabled);
setRateLimitRequestsPerMinute(config.rateLimitRequestsPerMinute);
setManufacturerWhitelist(config.manufacturerWhitelist);
setModelWhitelist(config.modelWhitelist);
setValidateSerialNumber(config.validateSerialNumber);
setDataRetentionDays(config.dataRetentionDays ?? 90);
setSessionTimeoutHours(config.sessionTimeoutHours ?? 5);
setSuccessMessage(null);
setError(null);
};
const handleTriggerMaintenance = async () => {
try {
setTriggeringMaintenance(true);
setError(null);
setSuccessMessage(null);
const response = await fetch(`${API_BASE_URL}/api/maintenance/trigger`, {
method: "POST",
});
if (!response.ok) {
throw new Error(`Failed to trigger maintenance: ${response.statusText}`);
}
const result = await response.json();
setSuccessMessage("Daily maintenance job completed successfully!");
// Auto-hide success message after 5 seconds
setTimeout(() => setSuccessMessage(null), 5000);
} catch (err) {
console.error("Error triggering maintenance:", err);
setError(err instanceof Error ? err.message : "Failed to trigger maintenance job");
} finally {
setTriggeringMaintenance(false);
}
};
return (
<ProtectedRoute>
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100">
{/* Header */}
<header className="bg-white shadow-sm border-b border-gray-200">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<button
onClick={() => router.push("/")}
className="flex items-center space-x-2 text-gray-600 hover:text-gray-900 transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
<span>Back to Dashboard</span>
</button>
</div>
<div>
<h1 className="text-2xl font-bold text-gray-900">Tenant Configuration</h1>
<p className="text-sm text-gray-500">Tenant: {tenantId}</p>
</div>
</div>
</div>
</header>
{/* Main Content */}
<main className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{loading ? (
<div className="bg-white rounded-lg shadow p-8 text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600 mx-auto"></div>
<p className="mt-4 text-gray-600">Loading configuration...</p>
</div>
) : (
<div className="space-y-6">
{/* Success Message */}
{successMessage && (
<div className="bg-green-50 border border-green-200 rounded-lg p-4 flex items-center space-x-3">
<svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span className="text-green-800 font-medium">{successMessage}</span>
</div>
)}
{/* Error Message */}
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 flex items-center space-x-3">
<svg className="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span className="text-red-800">{error}</span>
</div>
)}
{/* Security Toggle */}
<div className="bg-white rounded-lg shadow">
<div className="p-6 border-b border-gray-200">
<h2 className="text-xl font-semibold text-gray-900">Security</h2>
<p className="text-sm text-gray-500 mt-1">Enable or disable security validation for this tenant</p>
</div>
<div className="p-6">
<label className="flex items-center justify-between cursor-pointer">
<div>
<p className="font-medium text-gray-900">Enable Security Validation</p>
<p className="text-sm text-gray-500">
When enabled, all API requests will be validated (certificate, rate limit, hardware whitelist)
</p>
</div>
<div className="relative">
<input
type="checkbox"
className="sr-only"
checked={securityEnabled}
onChange={(e) => setSecurityEnabled(e.target.checked)}
/>
<div
onClick={() => setSecurityEnabled(!securityEnabled)}
className={`w-14 h-8 rounded-full transition-colors ${
securityEnabled ? "bg-indigo-600" : "bg-gray-300"
}`}
>
<div
className={`absolute top-1 left-1 w-6 h-6 bg-white rounded-full transition-transform ${
securityEnabled ? "transform translate-x-6" : ""
}`}
></div>
</div>
</div>
</label>
</div>
</div>
{/* Rate Limiting */}
<div className="bg-white rounded-lg shadow">
<div className="p-6 border-b border-gray-200">
<h2 className="text-xl font-semibold text-gray-900">Rate Limiting</h2>
<p className="text-sm text-gray-500 mt-1">Configure DoS protection limits per device</p>
</div>
<div className="p-6">
<label className="block">
<span className="text-gray-700 font-medium">Maximum Requests per Minute (per Device)</span>
<p className="text-sm text-gray-500 mb-2">
Default: 100. Normal enrollment generates ~2-3 requests/min. Lower values provide stricter protection.
</p>
<input
type="number"
min="1"
max="1000"
value={rateLimitRequestsPerMinute}
onChange={(e) => setRateLimitRequestsPerMinute(parseInt(e.target.value) || 100)}
className="mt-1 block w-full px-4 py-2 border border-gray-300 rounded-lg text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
/>
</label>
</div>
</div>
{/* Hardware Whitelist */}
<div className="bg-white rounded-lg shadow">
<div className="p-6 border-b border-gray-200">
<h2 className="text-xl font-semibold text-gray-900">Hardware Whitelist</h2>
<p className="text-sm text-gray-500 mt-1">Restrict which device manufacturers and models can enroll</p>
</div>
<div className="p-6 space-y-6">
{/* Manufacturer Whitelist */}
<div>
<label className="block">
<span className="text-gray-700 font-medium">Allowed Manufacturers</span>
<p className="text-sm text-gray-500 mb-2">
Comma-separated list. Supports wildcards: <code className="bg-gray-100 px-1 rounded">*</code> = all,
<code className="bg-gray-100 px-1 rounded ml-1">Dell*</code> = starts with "Dell"
</p>
<input
type="text"
value={manufacturerWhitelist}
onChange={(e) => setManufacturerWhitelist(e.target.value)}
placeholder="Dell*,HP*,Lenovo*,Microsoft Corporation"
className="mt-1 block w-full px-4 py-2 border border-gray-300 rounded-lg text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
/>
</label>
</div>
{/* Model Whitelist */}
<div>
<label className="block">
<span className="text-gray-700 font-medium">Allowed Models</span>
<p className="text-sm text-gray-500 mb-2">
Comma-separated list. Use <code className="bg-gray-100 px-1 rounded">*</code> to allow all models.
Examples: <code className="bg-gray-100 px-1 rounded">Latitude*</code>, <code className="bg-gray-100 px-1 rounded">EliteBook*</code>
</p>
<input
type="text"
value={modelWhitelist}
onChange={(e) => setModelWhitelist(e.target.value)}
placeholder="*"
className="mt-1 block w-full px-4 py-2 border border-gray-300 rounded-lg text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
/>
</label>
</div>
</div>
</div>
{/* Serial Number Validation (Prepared for future) */}
<div className="bg-white rounded-lg shadow opacity-60">
<div className="p-6 border-b border-gray-200">
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-semibold text-gray-900">Serial Number Validation</h2>
<p className="text-sm text-gray-500 mt-1">Validate devices against Intune Autopilot registration</p>
</div>
<span className="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
Coming Soon
</span>
</div>
</div>
<div className="p-6">
<label className="flex items-center justify-between cursor-not-allowed">
<div>
<p className="font-medium text-gray-500">Enable Serial Number Validation</p>
<p className="text-sm text-gray-400">
Requires Graph API integration with Multi-Tenant support
</p>
</div>
<div className="relative">
<input
type="checkbox"
className="sr-only"
checked={validateSerialNumber}
disabled
/>
<div className="w-14 h-8 bg-gray-200 rounded-full cursor-not-allowed">
<div className="absolute top-1 left-1 w-6 h-6 bg-white rounded-full"></div>
</div>
</div>
</label>
</div>
</div>
{/* Data Management Settings */}
<div className="bg-white rounded-lg shadow">
<div className="p-6 border-b border-gray-200">
<h2 className="text-xl font-semibold text-gray-900">Data Management</h2>
<p className="text-sm text-gray-500 mt-1">Configure data retention and session timeout policies</p>
</div>
<div className="p-6 space-y-6">
{/* Data Retention Days */}
<div>
<label className="block">
<span className="text-gray-700 font-medium">Data Retention Period (Days)</span>
<p className="text-sm text-gray-500 mb-2">
Sessions and events older than this will be automatically deleted by the daily maintenance job. Default: 90 days.
</p>
<input
type="number"
min="7"
max="3650"
value={dataRetentionDays}
onChange={(e) => setDataRetentionDays(parseInt(e.target.value) || 90)}
className="mt-1 block w-full px-4 py-2 border border-gray-300 rounded-lg text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
/>
<p className="text-xs text-gray-400 mt-1">Minimum: 7 days, Maximum: 3650 days (10 years)</p>
</label>
</div>
{/* Session Timeout Hours */}
<div>
<label className="block">
<span className="text-gray-700 font-medium">Session Timeout (Hours)</span>
<p className="text-sm text-gray-500 mb-2">
Sessions in "InProgress" status longer than this will be marked as "Failed - Timed Out".
This prevents stalled sessions from running indefinitely and skewing statistics.
<br />
<strong>Tip:</strong> Use the same value as your ESP (Enrollment Status Page) timeout for consistency.
</p>
<input
type="number"
min="1"
max="48"
value={sessionTimeoutHours}
onChange={(e) => setSessionTimeoutHours(parseInt(e.target.value) || 5)}
className="mt-1 block w-full px-4 py-2 border border-gray-300 rounded-lg text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
/>
<p className="text-xs text-gray-400 mt-1">Default: 5 hours (ESP default). Minimum: 1 hour, Maximum: 48 hours</p>
</label>
</div>
</div>
</div>
{/* Galactic Admin Functions - Only visible in Galactic Mode */}
{galacticAdminMode && (
<div className="bg-gradient-to-br from-purple-50 to-violet-50 border-2 border-purple-300 rounded-lg shadow-lg">
<div className="p-6 border-b border-purple-200 bg-gradient-to-r from-purple-100 to-violet-100">
<div className="flex items-center space-x-2">
<svg className="w-6 h-6 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<div>
<h2 className="text-xl font-semibold text-purple-900">Galactic Admin Functions</h2>
<p className="text-sm text-purple-600 mt-1">Cross-tenant administrative operations</p>
</div>
</div>
</div>
<div className="p-6">
<div className="flex items-start space-x-4">
<div className="flex-1">
<h3 className="text-lg font-medium text-gray-900 mb-2">Manual Maintenance Trigger</h3>
<p className="text-sm text-gray-600 mb-4">
Manually trigger the daily maintenance job which includes:
</p>
<ul className="text-sm text-gray-600 space-y-1 mb-4 ml-4">
<li className="flex items-start">
<span className="text-purple-500 mr-2">•</span>
<span>Mark stalled sessions as timed out</span>
</li>
<li className="flex items-start">
<span className="text-purple-500 mr-2">•</span>
<span>Aggregate yesterday's metrics into historical snapshots</span>
</li>
<li className="flex items-start">
<span className="text-purple-500 mr-2">•</span>
<span>Clean up old data based on retention policies</span>
</li>
</ul>
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3 mb-4">
<div className="flex items-start space-x-2">
<svg className="w-5 h-5 text-yellow-600 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<p className="text-sm text-yellow-800">
<strong>Warning:</strong> This operation runs across all tenants and may take several minutes to complete.
Use this only for testing or when immediate cleanup is needed.
</p>
</div>
</div>
</div>
<div className="flex-shrink-0">
<button
onClick={handleTriggerMaintenance}
disabled={triggeringMaintenance}
className="px-6 py-3 bg-gradient-to-r from-purple-600 to-violet-600 text-white rounded-lg hover:from-purple-700 hover:to-violet-700 disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow-md hover:shadow-lg flex items-center space-x-2"
>
{triggeringMaintenance ? (
<>
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white"></div>
<span>Running...</span>
</>
) : (
<>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>Run Now</span>
</>
)}
</button>
</div>
</div>
</div>
</div>
)}
{/* Configuration Info */}
{config && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div className="flex items-start space-x-3">
<svg className="w-5 h-5 text-blue-600 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div className="text-sm text-blue-800">
<p className="font-medium">Configuration Info</p>
<p className="mt-1">Last updated: {new Date(config.lastUpdated).toLocaleString()}</p>
<p>Updated by: {config.updatedBy}</p>
</div>
</div>
</div>
)}
{/* Action Buttons */}
<div className="flex items-center justify-end space-x-4 pt-4">
<button
onClick={handleReset}
disabled={saving}
className="px-6 py-2 border border-gray-300 rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Reset
</button>
<button
onClick={handleSave}
disabled={saving}
className="px-6 py-2 bg-indigo-600 text-white rounded-md hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center space-x-2"
>
{saving ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
<span>Saving...</span>
</>
) : (
<span>Save Configuration</span>
)}
</button>
</div>
</div>
)}
</main>
</div>
</ProtectedRoute>
);
}
@@ -0,0 +1,352 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useTenant } from '../../contexts/TenantContext';
import { ProtectedRoute } from '../../components/ProtectedRoute';
import { API_BASE_URL } from '@/lib/config';
interface SessionMetrics {
total: number;
today: number;
last7Days: number;
last30Days: number;
succeeded: number;
failed: number;
inProgress: number;
successRate: number;
}
interface TenantMetrics {
total: number;
active7Days: number;
active30Days: number;
}
interface UserMetrics {
total: number;
dailyLogins: number;
active7Days: number;
active30Days: number;
note: string;
}
interface PerformanceMetrics {
avgDurationMinutes: number;
medianDurationMinutes: number;
p95DurationMinutes: number;
p99DurationMinutes: number;
}
interface HardwareCount {
name: string;
count: number;
percentage: number;
}
interface HardwareMetrics {
topManufacturers: HardwareCount[];
topModels: HardwareCount[];
}
interface PlatformUsageMetrics {
sessions: SessionMetrics;
tenants: TenantMetrics;
users: UserMetrics;
performance: PerformanceMetrics;
hardware: HardwareMetrics;
computedAt: string;
computeDurationMs: number;
fromCache: boolean;
}
export default function UsageMetricsPage() {
const router = useRouter();
const { tenantId } = useTenant();
const [metrics, setMetrics] = useState<PlatformUsageMetrics | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [refreshing, setRefreshing] = useState(false);
const fetchMetrics = async (showRefreshing = false) => {
try {
if (showRefreshing) {
setRefreshing(true);
} else {
setLoading(true);
}
setError(null);
// Tenant-specific metrics - using tenantId
const response = await fetch(`${API_BASE_URL}/api/usage-metrics?tenantId=${tenantId}`);
if (!response.ok) {
throw new Error(`Failed to fetch usage metrics: ${response.statusText}`);
}
const data = await response.json();
setMetrics(data);
} catch (err) {
console.error('Error fetching usage metrics:', err);
setError(err instanceof Error ? err.message : 'Failed to fetch usage metrics');
} finally {
setLoading(false);
setRefreshing(false);
}
};
useEffect(() => {
// Tenant metrics are always accessible - no galactic admin check needed
fetchMetrics();
}, [router]);
const formatDuration = (minutes: number) => {
if (minutes < 60) {
return `${minutes.toFixed(1)}m`;
}
const hours = Math.floor(minutes / 60);
const mins = Math.round(minutes % 60);
return `${hours}h ${mins}m`;
};
const formatTimestamp = (timestamp: string) => {
const date = new Date(timestamp);
return date.toLocaleString();
};
if (loading) {
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-4 text-gray-600">Loading usage metrics...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center p-4">
<div className="bg-white rounded-lg shadow-xl p-8 max-w-md w-full">
<div className="text-center">
<svg className="h-12 w-12 text-red-500 mx-auto" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<h2 className="mt-4 text-xl font-semibold text-gray-900">Error Loading Usage Metrics</h2>
<p className="mt-2 text-gray-600">{error}</p>
<div className="mt-6 space-y-3">
<button
onClick={() => fetchMetrics()}
className="w-full px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
Retry
</button>
<button
onClick={() => router.push('/')}
className="w-full px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors"
>
Back to Home
</button>
</div>
</div>
</div>
</div>
);
}
if (!metrics) {
return null;
}
return (
<ProtectedRoute>
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 p-6">
<div className="max-w-7xl mx-auto">
{/* Header */}
<div className="mb-6">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<button
onClick={() => router.push('/')}
className="p-2 hover:bg-white rounded-lg transition-colors"
title="Back to Home"
>
<svg className="h-6 w-6 text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
</button>
<div>
<h1 className="text-3xl font-bold text-gray-900">Usage Metrics</h1>
<p className="text-sm text-gray-600 mt-1">
Computed at {formatTimestamp(metrics.computedAt)} in {metrics.computeDurationMs}ms
{metrics.fromCache && (
<span className="ml-2 px-2 py-0.5 bg-blue-100 text-blue-700 text-xs rounded">
From Cache
</span>
)}
</p>
</div>
</div>
<button
onClick={() => fetchMetrics(true)}
disabled={refreshing}
className="px-4 py-2 bg-white text-gray-700 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center space-x-2"
>
<svg className={`h-5 w-5 ${refreshing ? 'animate-spin' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
<span>{refreshing ? 'Refreshing...' : 'Refresh'}</span>
</button>
</div>
</div>
{/* Session Statistics */}
<div className="mb-6">
<h2 className="text-xl font-semibold text-gray-900 mb-4">Sessions</h2>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-500 mb-1">Total Sessions</div>
<div className="text-3xl font-bold text-gray-900">{metrics.sessions.total.toLocaleString()}</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-500 mb-1">Today</div>
<div className="text-3xl font-bold text-blue-600">{metrics.sessions.today.toLocaleString()}</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-500 mb-1">Last 7 Days</div>
<div className="text-3xl font-bold text-indigo-600">{metrics.sessions.last7Days.toLocaleString()}</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-500 mb-1">Last 30 Days</div>
<div className="text-3xl font-bold text-purple-600">{metrics.sessions.last30Days.toLocaleString()}</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mt-4">
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-500 mb-1">Succeeded</div>
<div className="text-3xl font-bold text-green-600">{metrics.sessions.succeeded.toLocaleString()}</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-500 mb-1">Failed</div>
<div className="text-3xl font-bold text-red-600">{metrics.sessions.failed.toLocaleString()}</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-500 mb-1">In Progress</div>
<div className="text-3xl font-bold text-yellow-600">{metrics.sessions.inProgress.toLocaleString()}</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-500 mb-1">Success Rate</div>
<div className="text-3xl font-bold text-gray-900">{metrics.sessions.successRate}%</div>
</div>
</div>
</div>
{/* User Statistics */}
<div className="mb-6">
{/* Users */}
<div>
<h2 className="text-xl font-semibold text-gray-900 mb-4">Users</h2>
<div className="bg-white rounded-lg shadow p-6">
<div className="text-center py-8">
<svg className="h-16 w-16 text-gray-300 mx-auto mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
<p className="text-gray-600 text-sm">{metrics.users.note}</p>
</div>
</div>
</div>
</div>
{/* Performance Statistics */}
<div className="mb-6">
<h2 className="text-xl font-semibold text-gray-900 mb-4">Performance</h2>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-500 mb-1">Average Duration</div>
<div className="text-3xl font-bold text-gray-900">{formatDuration(metrics.performance.avgDurationMinutes)}</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-500 mb-1">Median Duration</div>
<div className="text-3xl font-bold text-blue-600">{formatDuration(metrics.performance.medianDurationMinutes)}</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-500 mb-1">P95 Duration</div>
<div className="text-3xl font-bold text-orange-600">{formatDuration(metrics.performance.p95DurationMinutes)}</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-500 mb-1">P99 Duration</div>
<div className="text-3xl font-bold text-red-600">{formatDuration(metrics.performance.p99DurationMinutes)}</div>
</div>
</div>
</div>
{/* Hardware Statistics */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Top Manufacturers */}
<div>
<h2 className="text-xl font-semibold text-gray-900 mb-4">Top Manufacturers</h2>
<div className="bg-white rounded-lg shadow overflow-hidden">
<div className="divide-y divide-gray-200">
{metrics.hardware.topManufacturers.length > 0 ? (
metrics.hardware.topManufacturers.map((item, index) => (
<div key={index} className="p-4 hover:bg-gray-50 transition-colors">
<div className="flex items-center justify-between mb-2">
<span className="font-medium text-gray-900">{item.name || 'Unknown'}</span>
<div className="text-right">
<span className="text-sm font-semibold text-gray-900">{item.count.toLocaleString()}</span>
<span className="text-xs text-gray-500 ml-2">({item.percentage}%)</span>
</div>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
style={{ width: `${item.percentage}%` }}
></div>
</div>
</div>
))
) : (
<div className="p-8 text-center text-gray-500">No data available</div>
)}
</div>
</div>
</div>
{/* Top Models */}
<div>
<h2 className="text-xl font-semibold text-gray-900 mb-4">Top Models</h2>
<div className="bg-white rounded-lg shadow overflow-hidden">
<div className="divide-y divide-gray-200">
{metrics.hardware.topModels.length > 0 ? (
metrics.hardware.topModels.map((item, index) => (
<div key={index} className="p-4 hover:bg-gray-50 transition-colors">
<div className="flex items-center justify-between mb-2">
<span className="font-medium text-gray-900">{item.name || 'Unknown'}</span>
<div className="text-right">
<span className="text-sm font-semibold text-gray-900">{item.count.toLocaleString()}</span>
<span className="text-xs text-gray-500 ml-2">({item.percentage}%)</span>
</div>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-indigo-600 h-2 rounded-full transition-all duration-300"
style={{ width: `${item.percentage}%` }}
></div>
</div>
</div>
))
) : (
<div className="p-8 text-center text-gray-500">No data available</div>
)}
</div>
</div>
</div>
</div>
</div>
</div>
</ProtectedRoute>
);
}
@@ -0,0 +1,349 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useTenant } from '../../contexts/TenantContext';
import { API_BASE_URL } from '@/lib/config';
interface SessionMetrics {
total: number;
today: number;
last7Days: number;
last30Days: number;
succeeded: number;
failed: number;
inProgress: number;
successRate: number;
}
interface TenantMetrics {
total: number;
active7Days: number;
active30Days: number;
}
interface UserMetrics {
total: number;
dailyLogins: number;
active7Days: number;
active30Days: number;
note: string;
}
interface PerformanceMetrics {
avgDurationMinutes: number;
medianDurationMinutes: number;
p95DurationMinutes: number;
p99DurationMinutes: number;
}
interface HardwareCount {
name: string;
count: number;
percentage: number;
}
interface HardwareMetrics {
topManufacturers: HardwareCount[];
topModels: HardwareCount[];
}
interface PlatformUsageMetrics {
sessions: SessionMetrics;
tenants: TenantMetrics;
users: UserMetrics;
performance: PerformanceMetrics;
hardware: HardwareMetrics;
computedAt: string;
computeDurationMs: number;
fromCache: boolean;
}
export default function UsageMetricsPage() {
const router = useRouter();
const { tenantId } = useTenant();
const [metrics, setMetrics] = useState<PlatformUsageMetrics | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [refreshing, setRefreshing] = useState(false);
const fetchMetrics = async (showRefreshing = false) => {
try {
if (showRefreshing) {
setRefreshing(true);
} else {
setLoading(true);
}
setError(null);
// Tenant-specific metrics - using tenantId
const response = await fetch(`${API_BASE_URL}/api/usage-metrics?tenantId=${tenantId}`);
if (!response.ok) {
throw new Error(`Failed to fetch usage metrics: ${response.statusText}`);
}
const data = await response.json();
setMetrics(data);
} catch (err) {
console.error('Error fetching usage metrics:', err);
setError(err instanceof Error ? err.message : 'Failed to fetch usage metrics');
} finally {
setLoading(false);
setRefreshing(false);
}
};
useEffect(() => {
// Tenant metrics are always accessible - no galactic admin check needed
fetchMetrics();
}, [router]);
const formatDuration = (minutes: number) => {
if (minutes < 60) {
return `${minutes.toFixed(1)}m`;
}
const hours = Math.floor(minutes / 60);
const mins = Math.round(minutes % 60);
return `${hours}h ${mins}m`;
};
const formatTimestamp = (timestamp: string) => {
const date = new Date(timestamp);
return date.toLocaleString();
};
if (loading) {
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-4 text-gray-600">Loading usage metrics...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center p-4">
<div className="bg-white rounded-lg shadow-xl p-8 max-w-md w-full">
<div className="text-center">
<svg className="h-12 w-12 text-red-500 mx-auto" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<h2 className="mt-4 text-xl font-semibold text-gray-900">Error Loading Usage Metrics</h2>
<p className="mt-2 text-gray-600">{error}</p>
<div className="mt-6 space-y-3">
<button
onClick={() => fetchMetrics()}
className="w-full px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
Retry
</button>
<button
onClick={() => router.push('/')}
className="w-full px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors"
>
Back to Home
</button>
</div>
</div>
</div>
</div>
);
}
if (!metrics) {
return null;
}
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 p-6">
<div className="max-w-7xl mx-auto">
{/* Header */}
<div className="mb-6">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<button
onClick={() => router.push('/')}
className="p-2 hover:bg-white rounded-lg transition-colors"
title="Back to Home"
>
<svg className="h-6 w-6 text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
</button>
<div>
<h1 className="text-3xl font-bold text-gray-900">Usage Metrics</h1>
<p className="text-sm text-gray-600 mt-1">
Computed at {formatTimestamp(metrics.computedAt)} in {metrics.computeDurationMs}ms
{metrics.fromCache && (
<span className="ml-2 px-2 py-0.5 bg-blue-100 text-blue-700 text-xs rounded">
From Cache
</span>
)}
</p>
</div>
</div>
<button
onClick={() => fetchMetrics(true)}
disabled={refreshing}
className="px-4 py-2 bg-white text-gray-700 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center space-x-2"
>
<svg className={`h-5 w-5 ${refreshing ? 'animate-spin' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
<span>{refreshing ? 'Refreshing...' : 'Refresh'}</span>
</button>
</div>
</div>
{/* Session Statistics */}
<div className="mb-6">
<h2 className="text-xl font-semibold text-gray-900 mb-4">Sessions</h2>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-500 mb-1">Total Sessions</div>
<div className="text-3xl font-bold text-gray-900">{metrics.sessions.total.toLocaleString()}</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-500 mb-1">Today</div>
<div className="text-3xl font-bold text-blue-600">{metrics.sessions.today.toLocaleString()}</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-500 mb-1">Last 7 Days</div>
<div className="text-3xl font-bold text-indigo-600">{metrics.sessions.last7Days.toLocaleString()}</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-500 mb-1">Last 30 Days</div>
<div className="text-3xl font-bold text-purple-600">{metrics.sessions.last30Days.toLocaleString()}</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mt-4">
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-500 mb-1">Succeeded</div>
<div className="text-3xl font-bold text-green-600">{metrics.sessions.succeeded.toLocaleString()}</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-500 mb-1">Failed</div>
<div className="text-3xl font-bold text-red-600">{metrics.sessions.failed.toLocaleString()}</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-500 mb-1">In Progress</div>
<div className="text-3xl font-bold text-yellow-600">{metrics.sessions.inProgress.toLocaleString()}</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-500 mb-1">Success Rate</div>
<div className="text-3xl font-bold text-gray-900">{metrics.sessions.successRate}%</div>
</div>
</div>
</div>
{/* User Statistics */}
<div className="mb-6">
{/* Users */}
<div>
<h2 className="text-xl font-semibold text-gray-900 mb-4">Users</h2>
<div className="bg-white rounded-lg shadow p-6">
<div className="text-center py-8">
<svg className="h-16 w-16 text-gray-300 mx-auto mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
<p className="text-gray-600 text-sm">{metrics.users.note}</p>
</div>
</div>
</div>
</div>
{/* Performance Statistics */}
<div className="mb-6">
<h2 className="text-xl font-semibold text-gray-900 mb-4">Performance</h2>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-500 mb-1">Average Duration</div>
<div className="text-3xl font-bold text-gray-900">{formatDuration(metrics.performance.avgDurationMinutes)}</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-500 mb-1">Median Duration</div>
<div className="text-3xl font-bold text-blue-600">{formatDuration(metrics.performance.medianDurationMinutes)}</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-500 mb-1">P95 Duration</div>
<div className="text-3xl font-bold text-orange-600">{formatDuration(metrics.performance.p95DurationMinutes)}</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-500 mb-1">P99 Duration</div>
<div className="text-3xl font-bold text-red-600">{formatDuration(metrics.performance.p99DurationMinutes)}</div>
</div>
</div>
</div>
{/* Hardware Statistics */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Top Manufacturers */}
<div>
<h2 className="text-xl font-semibold text-gray-900 mb-4">Top Manufacturers</h2>
<div className="bg-white rounded-lg shadow overflow-hidden">
<div className="divide-y divide-gray-200">
{metrics.hardware.topManufacturers.length > 0 ? (
metrics.hardware.topManufacturers.map((item, index) => (
<div key={index} className="p-4 hover:bg-gray-50 transition-colors">
<div className="flex items-center justify-between mb-2">
<span className="font-medium text-gray-900">{item.name || 'Unknown'}</span>
<div className="text-right">
<span className="text-sm font-semibold text-gray-900">{item.count.toLocaleString()}</span>
<span className="text-xs text-gray-500 ml-2">({item.percentage}%)</span>
</div>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
style={{ width: `${item.percentage}%` }}
></div>
</div>
</div>
))
) : (
<div className="p-8 text-center text-gray-500">No data available</div>
)}
</div>
</div>
</div>
{/* Top Models */}
<div>
<h2 className="text-xl font-semibold text-gray-900 mb-4">Top Models</h2>
<div className="bg-white rounded-lg shadow overflow-hidden">
<div className="divide-y divide-gray-200">
{metrics.hardware.topModels.length > 0 ? (
metrics.hardware.topModels.map((item, index) => (
<div key={index} className="p-4 hover:bg-gray-50 transition-colors">
<div className="flex items-center justify-between mb-2">
<span className="font-medium text-gray-900">{item.name || 'Unknown'}</span>
<div className="text-right">
<span className="text-sm font-semibold text-gray-900">{item.count.toLocaleString()}</span>
<span className="text-xs text-gray-500 ml-2">({item.percentage}%)</span>
</div>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-indigo-600 h-2 rounded-full transition-all duration-300"
style={{ width: `${item.percentage}%` }}
></div>
</div>
</div>
))
) : (
<div className="p-8 text-center text-gray-500">No data available</div>
)}
</div>
</div>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,340 @@
"use client";
import { useAuth } from '@/contexts/AuthContext';
import { useNotifications } from '@/contexts/NotificationContext';
import { useState, useRef, useEffect } from 'react';
import Link from 'next/link';
import { API_BASE_URL } from '@/lib/config';
export default function Navbar() {
const { isAuthenticated, user, logout } = useAuth();
const { notifications, unreadCount, markAsRead, markAllAsRead, removeNotification, clearAll } = useNotifications();
const [showNotifications, setShowNotifications] = useState(false);
const [showUserMenu, setShowUserMenu] = useState(false);
const [showSettings, setShowSettings] = useState(false);
const [adminMode, setAdminMode] = useState(() => {
if (typeof window !== 'undefined') {
return localStorage.getItem('adminMode') === 'true';
}
return false;
});
const [galacticAdminMode, setGalacticAdminMode] = useState(() => {
if (typeof window !== 'undefined') {
return localStorage.getItem('galacticAdminMode') === 'true';
}
return false;
});
const notificationRef = useRef<HTMLDivElement>(null);
const userMenuRef = useRef<HTMLDivElement>(null);
const settingsRef = useRef<HTMLDivElement>(null);
// Update localStorage when modes change and dispatch custom event
useEffect(() => {
if (typeof window !== 'undefined') {
localStorage.setItem('adminMode', adminMode.toString());
// Dispatch custom event to notify other components
window.dispatchEvent(new Event('localStorageChange'));
}
}, [adminMode]);
useEffect(() => {
if (typeof window !== 'undefined') {
localStorage.setItem('galacticAdminMode', galacticAdminMode.toString());
// Dispatch custom event to notify other components
window.dispatchEvent(new Event('localStorageChange'));
}
}, [galacticAdminMode]);
// Close dropdowns when clicking outside
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (notificationRef.current && !notificationRef.current.contains(event.target as Node)) {
setShowNotifications(false);
}
if (userMenuRef.current && !userMenuRef.current.contains(event.target as Node)) {
setShowUserMenu(false);
}
if (settingsRef.current && !settingsRef.current.contains(event.target as Node)) {
setShowSettings(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const getNotificationIcon = (type: string) => {
switch (type) {
case 'error':
return '🔴';
case 'warning':
return '⚠️';
case 'success':
return '✅';
default:
return 'ℹ️';
}
};
const formatTime = (date: Date) => {
const now = new Date();
const diffMs = now.getTime() - new Date(date).getTime();
const diffMins = Math.floor(diffMs / 60000);
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins}m ago`;
const diffHours = Math.floor(diffMins / 60);
if (diffHours < 24) return `${diffHours}h ago`;
return `${Math.floor(diffHours / 24)}d ago`;
};
if (!isAuthenticated) {
return null;
}
return (
<nav className="bg-white border-b border-gray-200 shadow-sm">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between h-16">
{/* Logo and Title */}
<div className="flex items-center">
<Link href="/" className="flex items-center">
<span className="text-2xl font-bold text-blue-600">Autopilot Monitor</span>
</Link>
</div>
{/* Right side - Notifications, Settings, Help, Feedback, User */}
<div className="flex items-center space-x-2">
{/* Notification Bell */}
<div className="relative" ref={notificationRef}>
<button
onClick={() => setShowNotifications(!showNotifications)}
className="relative p-2 rounded-full hover:bg-gray-100 transition-colors"
>
<svg className="w-6 h-6 text-gray-600" fill="none" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" viewBox="0 0 24 24" stroke="currentColor">
<path d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"></path>
</svg>
{unreadCount > 0 && (
<span className="absolute -top-1 -right-1 inline-flex items-center justify-center px-2 py-1 text-xs font-bold leading-none text-white bg-red-600 rounded-full min-w-[20px] h-5">
{unreadCount > 9 ? '9+' : unreadCount}
</span>
)}
</button>
{/* Notification Dropdown */}
{showNotifications && (
<div className="absolute right-0 mt-2 w-96 bg-white rounded-lg shadow-lg border border-gray-200 z-50 max-h-96 overflow-hidden flex flex-col">
<div className="px-4 py-3 border-b border-gray-200 flex justify-between items-center">
<h3 className="text-lg font-semibold text-gray-900">Notifications</h3>
{notifications.length > 0 && (
<div className="flex space-x-2">
{unreadCount > 0 && (
<button onClick={markAllAsRead} className="text-sm text-blue-600 hover:text-blue-800">
Mark all read
</button>
)}
<button onClick={clearAll} className="text-sm text-gray-600 hover:text-gray-800">
Clear all
</button>
</div>
)}
</div>
<div className="overflow-y-auto flex-1">
{notifications.length === 0 ? (
<div className="p-8 text-center text-gray-500">
<svg className="w-16 h-16 mx-auto mb-4 text-gray-300" fill="none" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" viewBox="0 0 24 24" stroke="currentColor">
<path d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path>
</svg>
<p>No notifications</p>
</div>
) : (
<div className="divide-y divide-gray-200">
{notifications.map((notification) => (
<div key={notification.id} className={`p-4 hover:bg-gray-50 transition-colors cursor-pointer ${!notification.read ? 'bg-blue-50' : ''}`} onClick={() => { if (!notification.read) { markAsRead(notification.id); } }}>
<div className="flex items-start justify-between">
<div className="flex items-start space-x-3 flex-1">
<span className="text-2xl">{getNotificationIcon(notification.type)}</span>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900">{notification.title}</p>
<p className="text-sm text-gray-600 mt-1">{notification.message}</p>
<p className="text-xs text-gray-400 mt-1">{formatTime(notification.timestamp)}</p>
</div>
</div>
<button onClick={(e) => { e.stopPropagation(); removeNotification(notification.id); }} className="ml-2 text-gray-400 hover:text-gray-600">
<svg className="w-4 h-4" fill="none" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" viewBox="0 0 24 24" stroke="currentColor">
<path d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
</div>
))}
</div>
)}
</div>
</div>
)}
</div>
{/* Settings Menu */}
<div className="relative" ref={settingsRef}>
<button onClick={() => setShowSettings(!showSettings)} className="p-2 rounded-full hover:bg-gray-100 transition-colors" title="Settings">
<svg className="w-6 h-6 text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</button>
{/* Settings Dropdown */}
{showSettings && (
<div className="absolute right-0 mt-2 w-72 bg-white rounded-lg shadow-lg border border-gray-200 z-50 max-h-[32rem] overflow-y-auto">
<div className="p-4">
<h3 className="text-sm font-semibold text-gray-900 mb-3">Settings</h3>
{/* Admin Mode Toggle */}
<div className="mb-3">
<div className="flex items-center justify-between p-3 rounded-lg bg-gray-50">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-700">Admin Mode</span>
{adminMode && <span className="text-xs text-amber-600 font-semibold">AKTIV</span>}
</div>
<button onClick={() => setAdminMode(!adminMode)} className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${adminMode ? 'bg-amber-500' : 'bg-gray-300'}`}>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${adminMode ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
</div>
{/* Galactic Admin Toggle */}
{user?.isGalacticAdmin && (
<div className="mb-3">
<div className="flex items-center justify-between p-3 rounded-lg bg-purple-50">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-700">Galactic Admin</span>
{galacticAdminMode && <span className="text-xs text-purple-700 font-semibold">ACTIVE</span>}
</div>
<button onClick={() => setGalacticAdminMode(!galacticAdminMode)} className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${galacticAdminMode ? 'bg-purple-600' : 'bg-gray-300'}`}>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${galacticAdminMode ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
{galacticAdminMode && (
<p className="mt-2 text-xs text-purple-700 px-3">Shows ALL sessions across ALL tenants</p>
)}
</div>
)}
{/* Configuration */}
<div className="border-t border-gray-200 pt-3 mt-3">
<Link href="/settings" className="block w-full p-3 text-left rounded-lg hover:bg-gray-50 transition-colors" onClick={() => setShowSettings(false)}>
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-gray-700">Configuration</span>
<svg className="h-5 w-5 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
</Link>
</div>
{/* Usage Metrics */}
<div className="border-t border-gray-200 pt-3">
<Link href="/usage-metrics" className="block w-full p-3 text-left rounded-lg hover:bg-gray-50 transition-colors" onClick={() => setShowSettings(false)}>
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-gray-700">Usage Metrics</span>
<svg className="h-5 w-5 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
</div>
</Link>
</div>
{/* Platform Usage Metrics - Galactic Admin Only */}
{galacticAdminMode && (
<div className="border-t border-purple-200 pt-3">
<Link href="/platform-usage-metrics" className="block w-full p-3 text-left rounded-lg hover:bg-purple-50 transition-colors" onClick={() => setShowSettings(false)}>
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-purple-700">Platform Usage Metrics</span>
<svg className="h-5 w-5 text-purple-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
</Link>
</div>
)}
{/* System Health Check - Galactic Admin Only */}
{galacticAdminMode && (
<div className="border-t border-purple-200 pt-3">
<Link href="/health-check" className="block w-full p-3 text-left rounded-lg hover:bg-purple-50 transition-colors" onClick={() => setShowSettings(false)}>
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-purple-700">System Health Check</span>
<svg className="h-5 w-5 text-purple-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
</Link>
</div>
)}
</div>
</div>
)}
</div>
{/* Help Icon */}
<Link href="/docs" className="p-2 rounded-full hover:bg-gray-100 transition-colors" title="Documentation">
<svg className="w-6 h-6 text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</Link>
{/* Feedback Icon */}
<a href="https://github.com/yourusername/autopilot-monitor/issues" target="_blank" rel="noopener noreferrer" className="p-2 rounded-full hover:bg-gray-100 transition-colors" title="Feedback">
<svg className="w-6 h-6 text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z" />
</svg>
</a>
{/* User Menu */}
<div className="relative" ref={userMenuRef}>
<button onClick={() => setShowUserMenu(!showUserMenu)} className="flex items-center space-x-2 p-2 rounded-lg hover:bg-gray-100 transition-colors">
<div className="w-8 h-8 rounded-full bg-blue-600 flex items-center justify-center text-white font-semibold text-sm">
{(() => {
if (user?.displayName) {
const names = user.displayName.split(' ');
if (names.length >= 2) {
return `${names[0].charAt(0)}${names[names.length - 1].charAt(0)}`.toUpperCase();
}
return user.displayName.charAt(0).toUpperCase();
}
return user?.upn?.charAt(0).toUpperCase() || 'U';
})()}
</div>
<svg className="w-4 h-4 text-gray-600" fill="none" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" viewBox="0 0 24 24" stroke="currentColor">
<path d="M19 9l-7 7-7-7"></path>
</svg>
</button>
{/* User Dropdown */}
{showUserMenu && (
<div className="absolute right-0 mt-2 w-64 bg-white rounded-lg shadow-lg border border-gray-200 z-50">
<div className="px-4 py-3 border-b border-gray-200">
<p className="text-sm font-medium text-gray-900">{user?.displayName || 'User'}</p>
<p className="text-sm text-gray-600 truncate">{user?.upn}</p>
{user?.isGalacticAdmin && (
<span className="inline-block mt-2 px-2 py-1 text-xs font-semibold text-purple-800 bg-purple-100 rounded-full">
Galactic Admin
</span>
)}
</div>
<div className="py-1">
<button onClick={() => { logout(); setShowUserMenu(false); }} className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
Sign out
</button>
</div>
</div>
)}
</div>
</div>
</div>
</div>
</nav>
);
}
@@ -0,0 +1,69 @@
"use client";
import { useAuth } from "../contexts/AuthContext";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
interface ProtectedRouteProps {
children: React.ReactNode;
requireGalacticAdmin?: boolean;
}
/**
* Protects routes by requiring authentication
* Optionally requires Galactic Admin role
*/
export function ProtectedRoute({ children, requireGalacticAdmin = false }: ProtectedRouteProps) {
const { isAuthenticated, user, isLoading } = useAuth();
const router = useRouter();
useEffect(() => {
if (!isLoading && !isAuthenticated) {
// Not authenticated -> redirect to landing
router.push("/landing");
}
// Note: We don't automatically redirect for galactic admin check
// Instead, we show the "Access Denied" dialog and let user click "Back to Home"
}, [isAuthenticated, isLoading, router]);
// Show loading state
if (isLoading) {
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-4 text-gray-600">Loading...</p>
</div>
</div>
);
}
// Show nothing if not authenticated (will redirect)
if (!isAuthenticated) {
return null;
}
// Show nothing if requires galactic admin but user is not (will redirect)
if (requireGalacticAdmin && user && !user.isGalacticAdmin) {
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center p-4">
<div className="bg-white rounded-lg shadow-xl p-8 max-w-md w-full text-center">
<svg className="h-12 w-12 text-red-500 mx-auto mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<h2 className="text-xl font-semibold text-gray-900 mb-2">Access Denied</h2>
<p className="text-gray-600 mb-6">You need Galactic Admin permissions to access this page.</p>
<button
onClick={() => router.push("/")}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
Back to Home
</button>
</div>
</div>
);
}
// Render children if authenticated (and galactic admin if required)
return <>{children}</>;
}
@@ -0,0 +1,254 @@
"use client";
import React, { createContext, useContext, useEffect, useState, useCallback } from 'react';
import { PublicClientApplication, AccountInfo, InteractionStatus } from '@azure/msal-browser';
import { MsalProvider, useMsal, useIsAuthenticated } from '@azure/msal-react';
import { msalConfig, loginRequest, apiRequest } from '@/lib/msalConfig';
import { API_BASE_URL } from '@/lib/config';
// Initialize MSAL instance
const msalInstance = new PublicClientApplication(msalConfig);
// Initialize MSAL
msalInstance.initialize().then(() => {
// Handle redirect promise
msalInstance.handleRedirectPromise().catch((error) => {
console.error('[Auth] Redirect error:', error);
});
});
interface UserInfo {
displayName: string;
upn: string;
tenantId: string;
objectId: string;
isGalacticAdmin: boolean;
}
interface AuthContextType {
isAuthenticated: boolean;
user: UserInfo | null;
isLoading: boolean;
login: () => Promise<void>;
logout: () => Promise<void>;
getAccessToken: () => Promise<string | null>;
refreshUserInfo: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
/**
* Internal Auth Provider that uses MSAL hooks
* This component must be inside MsalProvider
*/
function AuthProviderInternal({ children }: { children: React.ReactNode }) {
const { instance, accounts, inProgress } = useMsal();
const isAuthenticated = useIsAuthenticated();
const [user, setUser] = useState<UserInfo | null>(null);
const [isLoading, setIsLoading] = useState(true);
// Handle SSR - if we're on the server, MSAL won't initialize
// Set loading to false immediately on mount in browser
useEffect(() => {
if (typeof window === 'undefined') {
setIsLoading(false);
}
}, []);
/**
* Fetches user info from backend API
*/
const fetchUserInfo = useCallback(async (account: AccountInfo): Promise<UserInfo | null> => {
try {
// Get access token for API
const tokenResponse = await instance.acquireTokenSilent({
scopes: apiRequest.scopes,
account: account,
});
// Call backend API to get user info including galactic admin status
const response = await fetch(`${API_BASE_URL}/api/auth/me`, {
headers: {
'Authorization': `Bearer ${tokenResponse.accessToken}`,
},
});
if (!response.ok) {
throw new Error(`Failed to fetch user info: ${response.statusText}`);
}
const data = await response.json();
return {
displayName: data.displayName || account.name || '',
upn: data.upn || account.username || '',
tenantId: data.tenantId || account.tenantId || '',
objectId: data.objectId || account.homeAccountId || '',
isGalacticAdmin: data.isGalacticAdmin || false,
};
} catch (error) {
console.error('[Auth] Failed to fetch user info:', error);
// Fallback to token claims if API call fails
return {
displayName: account.name || '',
upn: account.username || '',
tenantId: account.tenantId || '',
objectId: account.homeAccountId || '',
isGalacticAdmin: false,
};
}
}, [instance]);
/**
* Refreshes user information from backend
*/
const refreshUserInfo = useCallback(async () => {
if (accounts.length > 0) {
const userInfo = await fetchUserInfo(accounts[0]);
setUser(userInfo);
}
}, [accounts, fetchUserInfo]);
/**
* Load user info when authentication state changes
*/
useEffect(() => {
const loadUserInfo = async () => {
if (inProgress === InteractionStatus.None) {
if (accounts.length > 0) {
const userInfo = await fetchUserInfo(accounts[0]);
setUser(userInfo);
} else {
setUser(null);
}
setIsLoading(false);
}
};
loadUserInfo();
// Fallback: if MSAL doesn't initialize within 3 seconds, set loading to false anyway
const timeout = setTimeout(() => {
if (isLoading) {
console.warn('[Auth] MSAL initialization timeout - setting isLoading to false');
setIsLoading(false);
}
}, 3000);
return () => clearTimeout(timeout);
}, [accounts, inProgress, fetchUserInfo, isLoading]);
/**
* Initiates login flow
*/
const login = useCallback(async () => {
// Check if an interaction is already in progress
if (inProgress !== InteractionStatus.None) {
console.log('[Auth] Interaction already in progress, skipping login');
return;
}
try {
await instance.loginRedirect(loginRequest);
} catch (error: any) {
// Ignore interaction_in_progress errors - this can happen if user clicks button multiple times
if (error?.errorCode === 'interaction_in_progress') {
console.log('[Auth] Interaction already in progress, ignoring duplicate login attempt');
return;
}
console.error('[Auth] Login error:', error);
throw error;
}
}, [instance, inProgress]);
/**
* Initiates logout flow
*/
const logout = useCallback(async () => {
try {
await instance.logoutRedirect({
account: accounts[0],
});
} catch (error) {
console.error('[Auth] Logout error:', error);
throw error;
}
}, [instance, accounts]);
/**
* Gets access token for API calls
* Automatically handles token refresh
*/
const getAccessToken = useCallback(async (): Promise<string | null> => {
if (accounts.length === 0) {
return null;
}
try {
const response = await instance.acquireTokenSilent({
scopes: apiRequest.scopes,
account: accounts[0],
});
return response.accessToken;
} catch (error) {
console.error('[Auth] Token acquisition error:', error);
// If silent token acquisition fails, trigger interactive redirect
// Note: acquireTokenRedirect doesn't return a token, it redirects the browser
// The token will be available after the redirect via handleRedirectPromise
try {
await instance.acquireTokenRedirect({
scopes: apiRequest.scopes,
account: accounts[0],
});
// This line won't be reached as the browser will redirect
return null;
} catch (interactiveError) {
console.error('[Auth] Interactive token acquisition error:', interactiveError);
return null;
}
}
}, [instance, accounts]);
const value: AuthContextType = {
isAuthenticated,
user,
isLoading,
login,
logout,
getAccessToken,
refreshUserInfo,
};
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
);
}
/**
* Main Auth Provider that wraps MsalProvider
*/
export function AuthProvider({ children }: { children: React.ReactNode }) {
return (
<MsalProvider instance={msalInstance}>
<AuthProviderInternal>
{children}
</AuthProviderInternal>
</MsalProvider>
);
}
/**
* Hook to use auth context
*/
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}
@@ -0,0 +1,141 @@
"use client";
import React, { createContext, useContext, useState, useCallback } from 'react';
export type NotificationType = 'error' | 'warning' | 'info' | 'success';
export interface Notification {
id: string;
type: NotificationType;
title: string;
message: string;
timestamp: Date;
read: boolean;
key?: string; // Optional unique key for deduplication
}
interface NotificationContextType {
notifications: Notification[];
unreadCount: number;
addNotification: (type: NotificationType, title: string, message: string, key?: string) => void;
markAsRead: (id: string) => void;
markAllAsRead: () => void;
removeNotification: (id: string) => void;
clearAll: () => void;
}
const NotificationContext = createContext<NotificationContextType | undefined>(undefined);
export function NotificationProvider({ children }: { children: React.ReactNode }) {
const [notifications, setNotifications] = useState<Notification[]>([]);
const addNotification = useCallback((type: NotificationType, title: string, message: string, key?: string) => {
// Check if a notification with this key already exists
if (key) {
setNotifications(prev => {
const existingIndex = prev.findIndex(n => n.key === key);
if (existingIndex !== -1) {
// Update existing notification - move to top, update timestamp and message, mark as unread
const existing = prev[existingIndex];
const updated = {
...existing,
message,
timestamp: new Date(),
read: false, // Mark as unread to get user's attention again
};
// Remove from current position and add to top
const newNotifications = [...prev];
newNotifications.splice(existingIndex, 1);
return [updated, ...newNotifications];
} else {
// Create new notification with key
const notification: Notification = {
id: `${Date.now()}-${Math.random()}`,
type,
title,
message,
timestamp: new Date(),
read: false,
key,
};
// Auto-remove success/info notifications after 10 seconds
if (type === 'success' || type === 'info') {
setTimeout(() => {
setNotifications(prev => prev.filter(n => n.id !== notification.id));
}, 10000);
}
return [notification, ...prev];
}
});
} else {
// No key provided - create new notification (original behavior)
const notification: Notification = {
id: `${Date.now()}-${Math.random()}`,
type,
title,
message,
timestamp: new Date(),
read: false,
};
setNotifications(prev => [notification, ...prev]);
// Auto-remove success/info notifications after 10 seconds
if (type === 'success' || type === 'info') {
setTimeout(() => {
setNotifications(prev => prev.filter(n => n.id !== notification.id));
}, 10000);
}
}
}, []);
const markAsRead = useCallback((id: string) => {
setNotifications(prev =>
prev.map(n => n.id === id ? { ...n, read: true } : n)
);
}, []);
const markAllAsRead = useCallback(() => {
setNotifications(prev =>
prev.map(n => ({ ...n, read: true }))
);
}, []);
const removeNotification = useCallback((id: string) => {
setNotifications(prev => prev.filter(n => n.id !== id));
}, []);
const clearAll = useCallback(() => {
setNotifications([]);
}, []);
const unreadCount = notifications.filter(n => !n.read).length;
const value: NotificationContextType = {
notifications,
unreadCount,
addNotification,
markAsRead,
markAllAsRead,
removeNotification,
clearAll,
};
return (
<NotificationContext.Provider value={value}>
{children}
</NotificationContext.Provider>
);
}
export function useNotifications() {
const context = useContext(NotificationContext);
if (context === undefined) {
throw new Error('useNotifications must be used within a NotificationProvider');
}
return context;
}
@@ -0,0 +1,216 @@
"use client";
import React, { createContext, useContext, useEffect, useRef, useState, useCallback } from 'react';
import * as signalR from '@microsoft/signalr';
import { API_BASE_URL } from '@/lib/config';
interface SignalRContextType {
connection: signalR.HubConnection | null;
connectionState: signalR.HubConnectionState;
on: (eventName: string, callback: (...args: any[]) => void) => void;
off: (eventName: string, callback: (...args: any[]) => void) => void;
invoke: (methodName: string, ...args: any[]) => Promise<any>;
joinGroup: (groupName: string) => Promise<void>;
leaveGroup: (groupName: string) => Promise<void>;
isConnected: boolean;
}
const SignalRContext = createContext<SignalRContextType | undefined>(undefined);
export function SignalRProvider({ children }: { children: React.ReactNode }) {
const [connection, setConnection] = useState<signalR.HubConnection | null>(null);
const [connectionState, setConnectionState] = useState<signalR.HubConnectionState>(signalR.HubConnectionState.Disconnected);
const connectionRef = useRef<signalR.HubConnection | null>(null);
const joinedGroupsRef = useRef<Set<string>>(new Set());
const retryCountRef = useRef(0);
const maxRetries = 3;
useEffect(() => {
// Only create connection once
if (connectionRef.current) {
return;
}
const hubUrl = `${API_BASE_URL}/api/realtime`;
const newConnection = new signalR.HubConnectionBuilder()
.withUrl(hubUrl)
.withAutomaticReconnect({
nextRetryDelayInMilliseconds: (retryContext) => {
// Exponential backoff: 0s, 2s, 10s, 30s, then 30s thereafter
if (retryContext.elapsedMilliseconds < 60000) {
return Math.min(1000 * Math.pow(2, retryContext.previousRetryCount), 30000);
}
return 30000;
}
})
.configureLogging(signalR.LogLevel.Information)
.build();
connectionRef.current = newConnection;
// Setup connection state change handlers
newConnection.onclose((error) => {
setConnectionState(signalR.HubConnectionState.Disconnected);
joinedGroupsRef.current.clear(); // Clear joined groups on disconnect
});
newConnection.onreconnecting((error) => {
setConnectionState(signalR.HubConnectionState.Reconnecting);
});
newConnection.onreconnected((connectionId) => {
setConnectionState(signalR.HubConnectionState.Connected);
retryCountRef.current = 0; // Reset retry count on successful reconnect
joinedGroupsRef.current.clear(); // Clear joined groups on reconnect - need to re-join
});
// Start connection
const startConnection = async () => {
try {
await newConnection.start();
setConnectionState(signalR.HubConnectionState.Connected);
setConnection(newConnection);
retryCountRef.current = 0;
} catch (error) {
console.error('[SignalR] Failed to start connection:', error);
setConnectionState(signalR.HubConnectionState.Disconnected);
// Limited retry with exponential backoff
if (retryCountRef.current < maxRetries) {
retryCountRef.current++;
const delay = Math.min(1000 * Math.pow(2, retryCountRef.current), 30000);
setTimeout(startConnection, delay);
} else {
console.error('[SignalR] Max retries reached. Connection failed.');
}
}
};
startConnection();
// Cleanup only when provider unmounts (app closes)
return () => {
if (connectionRef.current) {
connectionRef.current.stop();
connectionRef.current = null;
}
};
}, []);
const on = (eventName: string, callback: (...args: any[]) => void) => {
if (connectionRef.current) {
connectionRef.current.on(eventName, callback);
}
};
const off = (eventName: string, callback: (...args: any[]) => void) => {
if (connectionRef.current) {
connectionRef.current.off(eventName, callback);
}
};
const invoke = async (methodName: string, ...args: any[]) => {
if (connection && connectionState === signalR.HubConnectionState.Connected) {
return await connection.invoke(methodName, ...args);
}
throw new Error('SignalR connection not established');
};
const joinGroup = useCallback(async (groupName: string) => {
if (!connection || connectionState !== signalR.HubConnectionState.Connected) {
return;
}
// Check if already in group to prevent duplicate joins
if (joinedGroupsRef.current.has(groupName)) {
return;
}
// Add to Set immediately to prevent race conditions with multiple simultaneous calls
joinedGroupsRef.current.add(groupName);
try {
const connectionId = connection.connectionId;
if (!connectionId) {
joinedGroupsRef.current.delete(groupName); // Remove if we can't get connection ID
return;
}
const response = await fetch(`${API_BASE_URL}/api/realtime/groups/join`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ connectionId, groupName })
});
if (!response.ok) {
// Remove from Set if API call failed (so we can retry)
joinedGroupsRef.current.delete(groupName);
}
} catch (error) {
console.error(`[SignalR] Error joining group ${groupName}:`, error);
// Remove from Set if API call failed (so we can retry)
joinedGroupsRef.current.delete(groupName);
}
}, [connection, connectionState]);
const leaveGroup = useCallback(async (groupName: string) => {
if (!connection || connectionState !== signalR.HubConnectionState.Connected) {
return;
}
// Check if we're actually in the group before leaving
if (!joinedGroupsRef.current.has(groupName)) {
return;
}
// Remove from Set immediately to prevent race conditions with multiple simultaneous calls
joinedGroupsRef.current.delete(groupName);
try {
const connectionId = connection.connectionId;
if (!connectionId) {
return;
}
const response = await fetch(`${API_BASE_URL}/api/realtime/groups/leave`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ connectionId, groupName })
});
if (!response.ok) {
// Re-add to Set if API call failed (so we can retry)
joinedGroupsRef.current.add(groupName);
}
} catch (error) {
console.error(`[SignalR] Error leaving group ${groupName}:`, error);
// Re-add to Set if API call failed (so we can retry)
joinedGroupsRef.current.add(groupName);
}
}, [connection, connectionState]);
return (
<SignalRContext.Provider
value={{
connection,
connectionState,
on,
off,
invoke,
joinGroup,
leaveGroup,
isConnected: connectionState === signalR.HubConnectionState.Connected,
}}
>
{children}
</SignalRContext.Provider>
);
}
export function useSignalR() {
const context = useContext(SignalRContext);
if (context === undefined) {
throw new Error('useSignalR must be used within a SignalRProvider');
}
return context;
}
@@ -0,0 +1,63 @@
"use client";
import React, { createContext, useContext, useState, useEffect } from 'react';
import { useAuth } from './AuthContext';
interface TenantContextType {
tenantId: string;
setTenantId: (id: string) => void;
}
const TenantContext = createContext<TenantContextType | undefined>(undefined);
const DEFAULT_TENANT_ID = 'deadbeef-dead-beef-dead-beefdeadbeef';
export function TenantProvider({ children }: { children: React.ReactNode }) {
const { user, isAuthenticated, isLoading } = useAuth();
const [tenantId, setTenantId] = useState<string>(() => {
// Initial: aus localStorage oder Default
if (typeof window !== 'undefined') {
return localStorage.getItem('tenantId') || DEFAULT_TENANT_ID;
}
return DEFAULT_TENANT_ID;
});
// Update tenant ID from authenticated user
useEffect(() => {
if (!isLoading && isAuthenticated && user) {
// Use tenant ID from authenticated user
if (user.tenantId && user.tenantId !== tenantId) {
console.log(`[TenantContext] Setting tenant ID from auth: ${user.tenantId}`);
setTenantId(user.tenantId);
}
} else if (!isLoading && !isAuthenticated) {
// Not authenticated - use default
if (tenantId !== DEFAULT_TENANT_ID) {
console.log(`[TenantContext] User not authenticated, using default tenant ID`);
setTenantId(DEFAULT_TENANT_ID);
}
}
}, [user, isAuthenticated, isLoading, tenantId]);
// Save tenant ID to localStorage
useEffect(() => {
if (typeof window !== 'undefined') {
localStorage.setItem('tenantId', tenantId);
}
}, [tenantId]);
return (
<TenantContext.Provider value={{ tenantId, setTenantId }}>
{children}
</TenantContext.Provider>
);
}
export function useTenant() {
const context = useContext(TenantContext);
if (context === undefined) {
throw new Error('useTenant must be used within a TenantProvider');
}
return context;
}
@@ -0,0 +1,48 @@
/**
* Centralized application configuration
* All configuration values are defined here for easy maintenance
*/
/**
* API Base URL
*
* Sources (in priority order):
* 1. NEXT_PUBLIC_API_BASE_URL environment variable
* 2. Default: http://localhost:7071 (local development)
*
* Production: Set NEXT_PUBLIC_API_BASE_URL in your environment
* Example: https://autopilot-monitor-api.azurewebsites.net
*/
export const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:7071";
/**
* Cache durations (in milliseconds)
*/
export const CACHE_DURATION = {
/** Usage metrics cache: 5 minutes */
USAGE_METRICS: 5 * 60 * 1000,
/** Session data refresh: 10 seconds */
SESSION_REFRESH: 10 * 1000,
};
/**
* SignalR Configuration
*/
export const SIGNALR_CONFIG = {
/** Hub name for autopilot monitoring */
HUB_NAME: "autopilotmonitor",
/** Reconnect delay in milliseconds */
RECONNECT_DELAY: 5000,
};
/**
* UI Configuration
*/
export const UI_CONFIG = {
/** Number of sessions per page in pagination */
SESSIONS_PER_PAGE: 10,
/** Auto-hide duration for success messages (in milliseconds) */
SUCCESS_MESSAGE_DURATION: 3000,
/** Maintenance success message duration (in milliseconds) */
MAINTENANCE_SUCCESS_DURATION: 5000,
};
@@ -0,0 +1,88 @@
import { Configuration, LogLevel, RedirectRequest } from "@azure/msal-browser";
/**
* MSAL Configuration for Multi-Tenant Azure AD Authentication
*
* Environment Variables:
* - NEXT_PUBLIC_AZURE_AD_CLIENT_ID: Application (client) ID from App Registration
* - NEXT_PUBLIC_AZURE_AD_REDIRECT_URI: Redirect URI configured in App Registration
* - NEXT_PUBLIC_AZURE_AD_POST_LOGOUT_REDIRECT_URI: Post logout redirect URI
*/
// MSAL Configuration
export const msalConfig: Configuration = {
auth: {
clientId: process.env.NEXT_PUBLIC_AZURE_AD_CLIENT_ID || "YOUR_CLIENT_ID_HERE",
authority: "https://login.microsoftonline.com/organizations", // Multi-tenant
redirectUri: process.env.NEXT_PUBLIC_AZURE_AD_REDIRECT_URI || "http://localhost:3000",
postLogoutRedirectUri: process.env.NEXT_PUBLIC_AZURE_AD_POST_LOGOUT_REDIRECT_URI || "http://localhost:3000/landing",
navigateToLoginRequestUrl: true,
},
cache: {
cacheLocation: "localStorage", // "sessionStorage" or "localStorage"
storeAuthStateInCookie: false, // Set to true for IE11 or Edge legacy
},
system: {
loggerOptions: {
loggerCallback: (level, message, containsPii) => {
if (containsPii) {
return;
}
switch (level) {
case LogLevel.Error:
console.error(`[MSAL] ${message}`);
return;
case LogLevel.Info:
console.info(`[MSAL] ${message}`);
return;
case LogLevel.Verbose:
console.debug(`[MSAL] ${message}`);
return;
case LogLevel.Warning:
console.warn(`[MSAL] ${message}`);
return;
default:
return;
}
},
logLevel: LogLevel.Info,
piiLoggingEnabled: false,
},
allowNativeBroker: false, // Disables WAM Broker
},
};
/**
* Scopes you add here will be prompted for user consent during sign-in.
* By default, MSAL.js will add OIDC scopes (openid, profile, email) to any login request.
* For more information about OIDC scopes, visit:
* https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-permissions-and-consent#openid-connect-scopes
*/
export const loginRequest: RedirectRequest = {
scopes: [
"User.Read", // Microsoft Graph - read user profile
],
prompt: "select_account", // Force account selection on login
};
/**
* Scopes for accessing the backend API
* For now, using User.Read to get basic user info
* TODO: Later expose an API in App Registration and use custom scopes
*/
export const apiRequest = {
scopes: [
"User.Read",
],
};
/**
* Protected resource map for token acquisition
* Maps API endpoints to their required scopes
*/
export const protectedResources = {
api: {
endpoint: process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:7071",
scopes: apiRequest.scopes,
},
};
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
@@ -0,0 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: false,
}
module.exports = nextConfig
@@ -0,0 +1,8 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
reactStrictMode: true,
};
export default nextConfig;
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More