From 5690620058da915d5d3b87d910130e4876dc17c1 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Thu, 5 Feb 2026 06:14:41 +0100 Subject: [PATCH] Initial commit --- .gitignore | 95 + AutopilotMonitor.sln | 93 + CONTRIBUTING.md | 251 + LICENSE | 21 + QUICKSTART.md | 183 + README.md | 162 + build.ps1 | 169 + docs/architecture.md | 389 + docs/getting-started.md | 246 + rules/network/proxy-auth-failure.yaml | 139 + .../Bootstrap/Install-AutopilotMonitor.ps1 | 192 + scripts/Bootstrap/README.md | 107 + .../Bootstrap/Uninstall-AutopilotMonitor.ps1 | 73 + .../Api/BackendApiClient.cs | 239 + .../AutopilotMonitor.Agent.Core.csproj | 21 + .../Configuration/AgentConfiguration.cs | 100 + .../EventCollection/AutopilotSimulator.cs | 290 + .../EventCollection/EventLogWatcher.cs | 185 + .../EventCollection/PhaseDetector.cs | 238 + .../EventCollection/RegistryMonitor.cs | 154 + .../Logging/AgentLogger.cs | 70 + .../Monitoring/MonitoringService.cs | 618 ++ .../Security/CertificateHelper.cs | 109 + .../Security/HardwareInfo.cs | 62 + .../Storage/EventSpool.cs | 185 + .../AutopilotMonitor.Agent.csproj | 24 + .../AutopilotMonitorService.cs | 79 + src/Agent/AutopilotMonitor.Agent/Program.cs | 179 + .../AutopilotMonitor.Functions/.gitignore | 264 + .../AutopilotMonitor.Functions.csproj | 31 + .../Extensions/ClaimsPrincipalExtensions.cs | 112 + .../Functions/AuthFunction.cs | 225 + .../Functions/DailyMaintenanceFunction.cs | 436 ++ .../Functions/DeleteSessionFunction.cs | 92 + .../Functions/GetAllSessionsFunction.cs | 60 + .../Functions/GetAuditLogsFunction.cs | 63 + .../Functions/GetSessionEventsFunction.cs | 72 + .../Functions/GetSessionsFunction.cs | 63 + .../GetTenantConfigurationFunction.cs | 48 + .../Functions/HealthCheckFunction.cs | 96 + .../Functions/IngestEventsFunction.cs | 312 + .../Functions/MarkSessionFailedFunction.cs | 126 + .../Functions/PlatformUsageMetricsFunction.cs | 70 + .../Functions/RegisterSessionFunction.cs | 105 + .../Functions/SignalRAddToGroupFunction.cs | 97 + .../Functions/SignalRNegotiateFunction.cs | 30 + .../SignalRRemoveFromGroupFunction.cs | 97 + .../Functions/TriggerMaintenanceFunction.cs | 118 + .../UpdateTenantConfigurationFunction.cs | 74 + .../Functions/UsageMetricsFunction.cs | 84 + .../Middleware/AuthenticationMiddleware.cs | 188 + .../AutopilotMonitor.Functions/Program.cs | 101 + .../Properties/launchSettings.json | 9 + .../Security/CertificateValidator.cs | 135 + .../Security/HardwareWhitelistValidator.cs | 129 + .../Security/SecurityValidationExtensions.cs | 80 + .../Security/SecurityValidator.cs | 189 + .../Security/SerialNumberValidator.cs | 168 + .../Services/GalacticAdminService.cs | 241 + .../Services/HealthCheckService.cs | 267 + .../Services/RateLimitService.cs | 138 + .../Services/TableStorageService.cs | 674 ++ .../Services/TenantConfigurationService.cs | 181 + .../Services/UsageMetricsService.cs | 309 + .../AutopilotMonitor.Functions/host.json | 37 + .../local.settings.json.template | 9 + .../AutopilotMonitor.Shared.csproj | 12 + .../AutopilotMonitor.Shared/Constants.cs | 111 + .../Models/ApiModels.cs | 144 + .../Models/EnrollmentEvent.cs | 123 + .../Models/EnrollmentPhase.cs | 53 + .../Models/SessionRegistration.cs | 91 + .../Models/TenantConfiguration.cs | 129 + .../Models/UsageMetrics.cs | 119 + .../Models/UsageMetricsSnapshot.cs | 113 + src/Web/autopilot-monitor-web/README.md | 155 + src/Web/autopilot-monitor-web/app/globals.css | 27 + .../app/health-check/page.tsx | 255 + .../app/landing/page.tsx | 198 + src/Web/autopilot-monitor-web/app/layout.tsx | 38 + src/Web/autopilot-monitor-web/app/page.tsx | 1141 +++ .../app/platform-usage-metrics/page.tsx | 369 + .../app/platform-usage-metrics/page.tsx.bak | 371 + .../app/sessions/[sessionId]/page.tsx | 658 ++ .../app/sessions/[sessionId]/page.tsx.bak | 625 ++ .../app/settings/page.tsx | 542 ++ .../app/usage-metrics/page.tsx | 352 + .../app/usage-metrics/page.tsx.bak | 349 + .../components/Navbar.tsx | 340 + .../components/ProtectedRoute.tsx | 69 + .../contexts/AuthContext.tsx | 254 + .../contexts/NotificationContext.tsx | 141 + .../contexts/SignalRContext.tsx | 216 + .../contexts/TenantContext.tsx | 63 + src/Web/autopilot-monitor-web/lib/config.ts | 48 + .../autopilot-monitor-web/lib/msalConfig.ts | 88 + src/Web/autopilot-monitor-web/next-env.d.ts | 6 + src/Web/autopilot-monitor-web/next.config.js | 6 + src/Web/autopilot-monitor-web/next.config.ts | 8 + .../autopilot-monitor-web/package-lock.json | 6330 +++++++++++++++++ src/Web/autopilot-monitor-web/package.json | 30 + .../autopilot-monitor-web/postcss.config.mjs | 9 + .../autopilot-monitor-web/tailwind.config.ts | 19 + src/Web/autopilot-monitor-web/tsconfig.json | 40 + 104 files changed, 23845 insertions(+) create mode 100644 .gitignore create mode 100644 AutopilotMonitor.sln create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 QUICKSTART.md create mode 100644 README.md create mode 100644 build.ps1 create mode 100644 docs/architecture.md create mode 100644 docs/getting-started.md create mode 100644 rules/network/proxy-auth-failure.yaml create mode 100644 scripts/Bootstrap/Install-AutopilotMonitor.ps1 create mode 100644 scripts/Bootstrap/README.md create mode 100644 scripts/Bootstrap/Uninstall-AutopilotMonitor.ps1 create mode 100644 src/Agent/AutopilotMonitor.Agent.Core/Api/BackendApiClient.cs create mode 100644 src/Agent/AutopilotMonitor.Agent.Core/AutopilotMonitor.Agent.Core.csproj create mode 100644 src/Agent/AutopilotMonitor.Agent.Core/Configuration/AgentConfiguration.cs create mode 100644 src/Agent/AutopilotMonitor.Agent.Core/EventCollection/AutopilotSimulator.cs create mode 100644 src/Agent/AutopilotMonitor.Agent.Core/EventCollection/EventLogWatcher.cs create mode 100644 src/Agent/AutopilotMonitor.Agent.Core/EventCollection/PhaseDetector.cs create mode 100644 src/Agent/AutopilotMonitor.Agent.Core/EventCollection/RegistryMonitor.cs create mode 100644 src/Agent/AutopilotMonitor.Agent.Core/Logging/AgentLogger.cs create mode 100644 src/Agent/AutopilotMonitor.Agent.Core/Monitoring/MonitoringService.cs create mode 100644 src/Agent/AutopilotMonitor.Agent.Core/Security/CertificateHelper.cs create mode 100644 src/Agent/AutopilotMonitor.Agent.Core/Security/HardwareInfo.cs create mode 100644 src/Agent/AutopilotMonitor.Agent.Core/Storage/EventSpool.cs create mode 100644 src/Agent/AutopilotMonitor.Agent/AutopilotMonitor.Agent.csproj create mode 100644 src/Agent/AutopilotMonitor.Agent/AutopilotMonitorService.cs create mode 100644 src/Agent/AutopilotMonitor.Agent/Program.cs create mode 100644 src/Backend/AutopilotMonitor.Functions/.gitignore create mode 100644 src/Backend/AutopilotMonitor.Functions/AutopilotMonitor.Functions.csproj create mode 100644 src/Backend/AutopilotMonitor.Functions/Extensions/ClaimsPrincipalExtensions.cs create mode 100644 src/Backend/AutopilotMonitor.Functions/Functions/AuthFunction.cs create mode 100644 src/Backend/AutopilotMonitor.Functions/Functions/DailyMaintenanceFunction.cs create mode 100644 src/Backend/AutopilotMonitor.Functions/Functions/DeleteSessionFunction.cs create mode 100644 src/Backend/AutopilotMonitor.Functions/Functions/GetAllSessionsFunction.cs create mode 100644 src/Backend/AutopilotMonitor.Functions/Functions/GetAuditLogsFunction.cs create mode 100644 src/Backend/AutopilotMonitor.Functions/Functions/GetSessionEventsFunction.cs create mode 100644 src/Backend/AutopilotMonitor.Functions/Functions/GetSessionsFunction.cs create mode 100644 src/Backend/AutopilotMonitor.Functions/Functions/GetTenantConfigurationFunction.cs create mode 100644 src/Backend/AutopilotMonitor.Functions/Functions/HealthCheckFunction.cs create mode 100644 src/Backend/AutopilotMonitor.Functions/Functions/IngestEventsFunction.cs create mode 100644 src/Backend/AutopilotMonitor.Functions/Functions/MarkSessionFailedFunction.cs create mode 100644 src/Backend/AutopilotMonitor.Functions/Functions/PlatformUsageMetricsFunction.cs create mode 100644 src/Backend/AutopilotMonitor.Functions/Functions/RegisterSessionFunction.cs create mode 100644 src/Backend/AutopilotMonitor.Functions/Functions/SignalRAddToGroupFunction.cs create mode 100644 src/Backend/AutopilotMonitor.Functions/Functions/SignalRNegotiateFunction.cs create mode 100644 src/Backend/AutopilotMonitor.Functions/Functions/SignalRRemoveFromGroupFunction.cs create mode 100644 src/Backend/AutopilotMonitor.Functions/Functions/TriggerMaintenanceFunction.cs create mode 100644 src/Backend/AutopilotMonitor.Functions/Functions/UpdateTenantConfigurationFunction.cs create mode 100644 src/Backend/AutopilotMonitor.Functions/Functions/UsageMetricsFunction.cs create mode 100644 src/Backend/AutopilotMonitor.Functions/Middleware/AuthenticationMiddleware.cs create mode 100644 src/Backend/AutopilotMonitor.Functions/Program.cs create mode 100644 src/Backend/AutopilotMonitor.Functions/Properties/launchSettings.json create mode 100644 src/Backend/AutopilotMonitor.Functions/Security/CertificateValidator.cs create mode 100644 src/Backend/AutopilotMonitor.Functions/Security/HardwareWhitelistValidator.cs create mode 100644 src/Backend/AutopilotMonitor.Functions/Security/SecurityValidationExtensions.cs create mode 100644 src/Backend/AutopilotMonitor.Functions/Security/SecurityValidator.cs create mode 100644 src/Backend/AutopilotMonitor.Functions/Security/SerialNumberValidator.cs create mode 100644 src/Backend/AutopilotMonitor.Functions/Services/GalacticAdminService.cs create mode 100644 src/Backend/AutopilotMonitor.Functions/Services/HealthCheckService.cs create mode 100644 src/Backend/AutopilotMonitor.Functions/Services/RateLimitService.cs create mode 100644 src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.cs create mode 100644 src/Backend/AutopilotMonitor.Functions/Services/TenantConfigurationService.cs create mode 100644 src/Backend/AutopilotMonitor.Functions/Services/UsageMetricsService.cs create mode 100644 src/Backend/AutopilotMonitor.Functions/host.json create mode 100644 src/Backend/AutopilotMonitor.Functions/local.settings.json.template create mode 100644 src/Shared/AutopilotMonitor.Shared/AutopilotMonitor.Shared.csproj create mode 100644 src/Shared/AutopilotMonitor.Shared/Constants.cs create mode 100644 src/Shared/AutopilotMonitor.Shared/Models/ApiModels.cs create mode 100644 src/Shared/AutopilotMonitor.Shared/Models/EnrollmentEvent.cs create mode 100644 src/Shared/AutopilotMonitor.Shared/Models/EnrollmentPhase.cs create mode 100644 src/Shared/AutopilotMonitor.Shared/Models/SessionRegistration.cs create mode 100644 src/Shared/AutopilotMonitor.Shared/Models/TenantConfiguration.cs create mode 100644 src/Shared/AutopilotMonitor.Shared/Models/UsageMetrics.cs create mode 100644 src/Shared/AutopilotMonitor.Shared/Models/UsageMetricsSnapshot.cs create mode 100644 src/Web/autopilot-monitor-web/README.md create mode 100644 src/Web/autopilot-monitor-web/app/globals.css create mode 100644 src/Web/autopilot-monitor-web/app/health-check/page.tsx create mode 100644 src/Web/autopilot-monitor-web/app/landing/page.tsx create mode 100644 src/Web/autopilot-monitor-web/app/layout.tsx create mode 100644 src/Web/autopilot-monitor-web/app/page.tsx create mode 100644 src/Web/autopilot-monitor-web/app/platform-usage-metrics/page.tsx create mode 100644 src/Web/autopilot-monitor-web/app/platform-usage-metrics/page.tsx.bak create mode 100644 src/Web/autopilot-monitor-web/app/sessions/[sessionId]/page.tsx create mode 100644 src/Web/autopilot-monitor-web/app/sessions/[sessionId]/page.tsx.bak create mode 100644 src/Web/autopilot-monitor-web/app/settings/page.tsx create mode 100644 src/Web/autopilot-monitor-web/app/usage-metrics/page.tsx create mode 100644 src/Web/autopilot-monitor-web/app/usage-metrics/page.tsx.bak create mode 100644 src/Web/autopilot-monitor-web/components/Navbar.tsx create mode 100644 src/Web/autopilot-monitor-web/components/ProtectedRoute.tsx create mode 100644 src/Web/autopilot-monitor-web/contexts/AuthContext.tsx create mode 100644 src/Web/autopilot-monitor-web/contexts/NotificationContext.tsx create mode 100644 src/Web/autopilot-monitor-web/contexts/SignalRContext.tsx create mode 100644 src/Web/autopilot-monitor-web/contexts/TenantContext.tsx create mode 100644 src/Web/autopilot-monitor-web/lib/config.ts create mode 100644 src/Web/autopilot-monitor-web/lib/msalConfig.ts create mode 100644 src/Web/autopilot-monitor-web/next-env.d.ts create mode 100644 src/Web/autopilot-monitor-web/next.config.js create mode 100644 src/Web/autopilot-monitor-web/next.config.ts create mode 100644 src/Web/autopilot-monitor-web/package-lock.json create mode 100644 src/Web/autopilot-monitor-web/package.json create mode 100644 src/Web/autopilot-monitor-web/postcss.config.mjs create mode 100644 src/Web/autopilot-monitor-web/tailwind.config.ts create mode 100644 src/Web/autopilot-monitor-web/tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..17742924 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/AutopilotMonitor.sln b/AutopilotMonitor.sln new file mode 100644 index 00000000..c1b0c8a7 --- /dev/null +++ b/AutopilotMonitor.sln @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..7de64ecf --- /dev/null +++ b/CONTRIBUTING.md @@ -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! diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..8daf7dd2 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/QUICKSTART.md b/QUICKSTART.md new file mode 100644 index 00000000..68eca2c4 --- /dev/null +++ b/QUICKSTART.md @@ -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: +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. 🚀 diff --git a/README.md b/README.md new file mode 100644 index 00000000..76faddfd --- /dev/null +++ b/README.md @@ -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 + 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 diff --git a/build.ps1 b/build.ps1 new file mode 100644 index 00000000..298d7ac0 --- /dev/null +++ b/build.ps1 @@ -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 "" diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 00000000..f993989c --- /dev/null +++ b/docs/architecture.md @@ -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 diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 00000000..68b77933 --- /dev/null +++ b/docs/getting-started.md @@ -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 +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 diff --git a/rules/network/proxy-auth-failure.yaml b/rules/network/proxy-auth-failure.yaml new file mode 100644 index 00000000..50326c43 --- /dev/null +++ b/rules/network/proxy-auth-failure.yaml @@ -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 diff --git a/scripts/Bootstrap/Install-AutopilotMonitor.ps1 b/scripts/Bootstrap/Install-AutopilotMonitor.ps1 new file mode 100644 index 00000000..e6a8f109 --- /dev/null +++ b/scripts/Bootstrap/Install-AutopilotMonitor.ps1 @@ -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 +} diff --git a/scripts/Bootstrap/README.md b/scripts/Bootstrap/README.md new file mode 100644 index 00000000..5acd5fc7 --- /dev/null +++ b/scripts/Bootstrap/README.md @@ -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 diff --git a/scripts/Bootstrap/Uninstall-AutopilotMonitor.ps1 b/scripts/Bootstrap/Uninstall-AutopilotMonitor.ps1 new file mode 100644 index 00000000..96a5d17b --- /dev/null +++ b/scripts/Bootstrap/Uninstall-AutopilotMonitor.ps1 @@ -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 "" diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Api/BackendApiClient.cs b/src/Agent/AutopilotMonitor.Agent.Core/Api/BackendApiClient.cs new file mode 100644 index 00000000..614006a9 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.Core/Api/BackendApiClient.cs @@ -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 +{ + /// + /// Client for communicating with the backend API + /// + 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) + }; + } + + /// + /// Registers a new enrollment session + /// + public async Task RegisterSessionAsync(SessionRegistration registration) + { + var request = new RegisterSessionRequest { Registration = registration }; + var url = $"{_baseUrl}{Constants.ApiEndpoints.RegisterSession}"; + + var response = await PostAsync(url, request); + return response; + } + + /// + /// Ingests a batch of events using NDJSON + gzip compression for bandwidth optimization + /// + public async Task 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(responseJson); + + return response; + } + + /// + /// Gets a SAS URL for uploading a troubleshooting bundle + /// + public async Task GetBundleUploadUrlAsync(UploadBundleRequest request) + { + var url = $"{_baseUrl}{Constants.ApiEndpoints.UploadBundle}"; + var response = await PostAsync(url, request); + return response; + } + + private async Task PostAsync(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(responseJson); + + return result; + } + + /// + /// Creates NDJSON (newline-delimited JSON) from IngestEventsRequest + /// Format: metadata line + one event per line + /// + private string CreateNdjson(IngestEventsRequest request) + { + var lines = new System.Collections.Generic.List(); + + // 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); + } + + /// + /// Compresses a string using gzip + /// + 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(); + } + } + + /// + /// Adds device identification headers for authentication and authorization + /// Includes: Client certificate, manufacturer, model, serial number + /// + 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(); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/AutopilotMonitor.Agent.Core.csproj b/src/Agent/AutopilotMonitor.Agent.Core/AutopilotMonitor.Agent.Core.csproj new file mode 100644 index 00000000..f56c7e30 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.Core/AutopilotMonitor.Agent.Core.csproj @@ -0,0 +1,21 @@ + + + + net48 + latest + + + + + + + + + + + + + + + + diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Configuration/AgentConfiguration.cs b/src/Agent/AutopilotMonitor.Agent.Core/Configuration/AgentConfiguration.cs new file mode 100644 index 00000000..04e0c728 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.Core/Configuration/AgentConfiguration.cs @@ -0,0 +1,100 @@ +using System; + +namespace AutopilotMonitor.Agent.Core.Configuration +{ + /// + /// Agent configuration settings + /// + public class AgentConfiguration + { + /// + /// Backend API base URL + /// + public string ApiBaseUrl { get; set; } + + /// + /// Session identifier for this enrollment + /// + public string SessionId { get; set; } + + /// + /// Tenant identifier + /// + public string TenantId { get; set; } + + /// + /// Local spool directory path + /// + public string SpoolDirectory { get; set; } + + /// + /// Agent log directory path + /// + public string LogDirectory { get; set; } + + /// + /// Upload interval in seconds + /// + public int UploadIntervalSeconds { get; set; } = 30; + + /// + /// Maximum batch size for events + /// + public int MaxBatchSize { get; set; } = 100; + + /// + /// Whether to use client certificate authentication + /// + public bool UseClientCertAuth { get; set; } = false; + + /// + /// Client certificate thumbprint (if using cert auth) + /// + public string ClientCertThumbprint { get; set; } + + /// + /// Enable debug logging + /// + public bool EnableDebugLogging { get; set; } = false; + + /// + /// Maximum retry attempts for failed uploads + /// + public int MaxRetryAttempts { get; set; } = 5; + + /// + /// Enable Autopilot simulator for testing/demo (generates fake events) + /// + public bool EnableSimulator { get; set; } = false; + + /// + /// Simulate enrollment failure (only used if EnableSimulator is true) + /// + public bool SimulateFailure { get; set; } = false; + + /// + /// Whether to cleanup files on exit (delete C:\ProgramData\AutopilotMonitor) + /// + public bool CleanupOnExit { get; set; } = true; + + /// + /// Whether to self-destruct when enrollment completes (remove Scheduled Task and all files) + /// + public bool SelfDestructOnComplete { get; set; } = true; + + /// + /// Name of the Scheduled Task to remove during self-destruct + /// + public string ScheduledTaskName { get; set; } = "AutopilotMonitor-Agent"; + + /// + /// Validates the configuration + /// + public bool IsValid() + { + return !string.IsNullOrEmpty(ApiBaseUrl) && + !string.IsNullOrEmpty(SessionId) && + !string.IsNullOrEmpty(TenantId); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/EventCollection/AutopilotSimulator.cs b/src/Agent/AutopilotMonitor.Agent.Core/EventCollection/AutopilotSimulator.cs new file mode 100644 index 00000000..91d3ecea --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.Core/EventCollection/AutopilotSimulator.cs @@ -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 +{ + /// + /// Simulates a realistic Autopilot enrollment flow for testing and demo purposes + /// + public class AutopilotSimulator : IDisposable + { + private readonly AgentLogger _logger; + private readonly string _sessionId; + private readonly string _tenantId; + private readonly Action _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 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; + } + + /// + /// Starts the Autopilot simulation + /// + public void Start() + { + _logger.Info("Starting Autopilot Simulator"); + _cancellationTokenSource = new CancellationTokenSource(); + _simulationTask = Task.Run(() => RunSimulation(_cancellationTokenSource.Token)); + } + + /// + /// Stops the simulation + /// + 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 + { + { "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 + { + { "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 + { + { "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 + { + { "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; + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/EventCollection/EventLogWatcher.cs b/src/Agent/AutopilotMonitor.Agent.Core/EventCollection/EventLogWatcher.cs new file mode 100644 index 00000000..2856ac2b --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.Core/EventCollection/EventLogWatcher.cs @@ -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 +{ + /// + /// Watches Windows Event Logs for Autopilot-related events + /// + public class EventLogWatcher : IDisposable + { + private readonly AgentLogger _logger; + private readonly string _sessionId; + private readonly string _tenantId; + private readonly Action _onEventCollected; + private readonly List _queries = new List(); + private readonly List _watchers = new List(); + + // 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 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)); + } + + /// + /// Starts watching event logs + /// + 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."); + } + } + + /// + /// Stops watching event logs + /// + 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 + { + { "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(); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/EventCollection/PhaseDetector.cs b/src/Agent/AutopilotMonitor.Agent.Core/EventCollection/PhaseDetector.cs new file mode 100644 index 00000000..13bbae35 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.Core/EventCollection/PhaseDetector.cs @@ -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 +{ + /// + /// Detects and tracks Autopilot enrollment phases + /// + public class PhaseDetector : IDisposable + { + private readonly AgentLogger _logger; + private readonly string _sessionId; + private readonly string _tenantId; + private readonly Action _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 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)); + } + + /// + /// Starts phase detection + /// + 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) + ); + } + + /// + /// Stops phase detection + /// + 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 + { + { "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(); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/EventCollection/RegistryMonitor.cs b/src/Agent/AutopilotMonitor.Agent.Core/EventCollection/RegistryMonitor.cs new file mode 100644 index 00000000..e36662ea --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.Core/EventCollection/RegistryMonitor.cs @@ -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 +{ + /// + /// Monitors Autopilot registry keys for status changes + /// + public class RegistryMonitor : IDisposable + { + private readonly AgentLogger _logger; + private readonly string _sessionId; + private readonly string _tenantId; + private readonly Action _onEventCollected; + private Timer _pollTimer; + private readonly Dictionary _lastValues = new Dictionary(); + + // 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 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)); + } + + /// + /// Starts monitoring registry keys + /// + 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) + ); + } + + /// + /// Stops monitoring registry keys + /// + 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 + { + { "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(); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Logging/AgentLogger.cs b/src/Agent/AutopilotMonitor.Agent.Core/Logging/AgentLogger.cs new file mode 100644 index 00000000..12460446 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.Core/Logging/AgentLogger.cs @@ -0,0 +1,70 @@ +using System; +using System.IO; + +namespace AutopilotMonitor.Agent.Core.Logging +{ + /// + /// Simple file-based logger for the agent + /// + 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 + } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/MonitoringService.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/MonitoringService.cs new file mode 100644 index 00000000..f990231d --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/MonitoringService.cs @@ -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 +{ + /// + /// Main monitoring service that collects and uploads telemetry + /// + 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"); + } + + /// + /// Starts the monitoring service + /// + 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"); + } + + /// + /// Starts all event collection components + /// + 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); + } + } + + /// + /// Registers the session with the backend + /// + 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"; + } + + /// + /// Stops the monitoring service + /// + 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"); + } + + /// + /// Stops all event collection components + /// + 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); + } + } + + /// + /// Emits an event (adds to spool) + /// + 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()); + } + } + + /// + /// Called when new events are available in the spool (FileSystemWatcher detected new files) + /// Uses debouncing to batch events before uploading + /// + 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 + ); + } + } + + /// + /// Called when debounce timer expires - uploads batched events + /// + private void DebounceTimerCallback(object state) + { + _logger.Debug("Debounce timer expired, uploading batched events"); + Task.Run(() => UploadEventsAsync()); + } + + /// + /// Fallback timer callback in case FileSystemWatcher misses events + /// + 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); + } + } + + /// + /// Handles enrollment completion and triggers self-destruct sequence + /// + 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); + } + } + + /// + /// Executes full self-destruct: removes scheduled task and deletes all files + /// + 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); + } + } + + /// + /// Executes cleanup only (files) without removing scheduled task + /// + 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(); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Security/CertificateHelper.cs b/src/Agent/AutopilotMonitor.Agent.Core/Security/CertificateHelper.cs new file mode 100644 index 00000000..5702429d --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.Core/Security/CertificateHelper.cs @@ -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 +{ + /// + /// Helper for finding and validating MDM client certificates + /// + public static class CertificateHelper + { + /// + /// Finds the MDM device certificate for client authentication + /// + /// Optional specific thumbprint to search for + /// Logger for diagnostic output + /// The MDM certificate, or null if not found + 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() + .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() + .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() + .Any(ext => ext.EnhancedKeyUsages + .OfType() + .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; + } + } + + /// + /// Validates that a certificate is still valid + /// + public static bool IsCertificateValid(X509Certificate2 certificate) + { + if (certificate == null) + return false; + + var now = DateTime.UtcNow; + return now >= certificate.NotBefore && now <= certificate.NotAfter; + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Security/HardwareInfo.cs b/src/Agent/AutopilotMonitor.Agent.Core/Security/HardwareInfo.cs new file mode 100644 index 00000000..c4d21e27 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.Core/Security/HardwareInfo.cs @@ -0,0 +1,62 @@ +using System; +using System.Management; +using AutopilotMonitor.Agent.Core.Logging; + +namespace AutopilotMonitor.Agent.Core.Security +{ + /// + /// Helper for retrieving hardware information + /// + public static class HardwareInfo + { + /// + /// Gets the device manufacturer and model from WMI + /// + /// Logger for diagnostic output + /// Tuple of (Manufacturer, Model, SerialNumber) + 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"); + } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Storage/EventSpool.cs b/src/Agent/AutopilotMonitor.Agent.Core/Storage/EventSpool.cs new file mode 100644 index 00000000..87d8eadc --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.Core/Storage/EventSpool.cs @@ -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 +{ + /// + /// Manages offline event storage (spool) for resilient uploads + /// + public class EventSpool : IDisposable + { + private readonly string _spoolDirectory; + private readonly object _lockObject = new object(); + private readonly FileSystemWatcher _fileWatcher; + + /// + /// Event raised when new events are added to the spool + /// + 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; + } + + /// + /// Starts watching for new events + /// + public void StartWatching() + { + _fileWatcher.EnableRaisingEvents = true; + } + + /// + /// Stops watching for new events + /// + public void StopWatching() + { + _fileWatcher.EnableRaisingEvents = false; + } + + private void OnFileCreated(object sender, FileSystemEventArgs e) + { + // Raise event to trigger immediate upload + EventsAvailable?.Invoke(this, EventArgs.Empty); + } + + /// + /// Adds an event to the spool + /// + 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); + } + } + + /// + /// Gets a batch of events from the spool (oldest first) + /// + public List GetBatch(int maxBatchSize) + { + lock (_lockObject) + { + var events = new List(); + 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(json); + if (evt != null) + { + events.Add(evt); + } + } + catch + { + // Skip corrupted files + } + } + + return events; + } + } + + /// + /// Removes events from the spool after successful upload + /// + public void RemoveEvents(List 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 + } + } + } + } + } + + /// + /// Gets the count of events in the spool + /// + public int GetCount() + { + lock (_lockObject) + { + return Directory.GetFiles(_spoolDirectory, "event_*.json").Length; + } + } + + /// + /// Clears all events from the spool + /// + 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(); + } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent/AutopilotMonitor.Agent.csproj b/src/Agent/AutopilotMonitor.Agent/AutopilotMonitor.Agent.csproj new file mode 100644 index 00000000..965c87a8 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent/AutopilotMonitor.Agent.csproj @@ -0,0 +1,24 @@ + + + + Exe + net48 + latest + AnyCPU + + + + + + + + + + + + + + + + + diff --git a/src/Agent/AutopilotMonitor.Agent/AutopilotMonitorService.cs b/src/Agent/AutopilotMonitor.Agent/AutopilotMonitorService.cs new file mode 100644 index 00000000..7a9aca97 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent/AutopilotMonitorService.cs @@ -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 +{ + /// + /// Windows Service wrapper for the monitoring agent + /// + 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 + }; + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent/Program.cs b/src/Agent/AutopilotMonitor.Agent/Program.cs new file mode 100644 index 00000000..dc7b8d7b --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent/Program.cs @@ -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>(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 + }; + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/.gitignore b/src/Backend/AutopilotMonitor.Functions/.gitignore new file mode 100644 index 00000000..ff5b00c5 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/.gitignore @@ -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 \ No newline at end of file diff --git a/src/Backend/AutopilotMonitor.Functions/AutopilotMonitor.Functions.csproj b/src/Backend/AutopilotMonitor.Functions/AutopilotMonitor.Functions.csproj new file mode 100644 index 00000000..5b47be84 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/AutopilotMonitor.Functions.csproj @@ -0,0 +1,31 @@ + + + + net8.0 + V4 + Exe + enable + enable + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Backend/AutopilotMonitor.Functions/Extensions/ClaimsPrincipalExtensions.cs b/src/Backend/AutopilotMonitor.Functions/Extensions/ClaimsPrincipalExtensions.cs new file mode 100644 index 00000000..434f5f3b --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Extensions/ClaimsPrincipalExtensions.cs @@ -0,0 +1,112 @@ +using System.Security.Claims; +using Microsoft.Azure.Functions.Worker; + +namespace AutopilotMonitor.Functions.Extensions; + +/// +/// Extension methods for extracting user information from JWT token claims +/// +public static class ClaimsPrincipalExtensions +{ + /// + /// Extracts the ClaimsPrincipal from the FunctionContext + /// + public static ClaimsPrincipal? GetUser(this FunctionContext context) + { + // Try to get ClaimsPrincipal from HTTP context + var httpContext = context.GetHttpContext(); + return httpContext?.User; + } + + /// + /// 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 + /// + public static string? GetTenantId(this ClaimsPrincipal principal) + { + return principal.FindFirst("tid")?.Value + ?? principal.FindFirst("http://schemas.microsoft.com/identity/claims/tenantid")?.Value; + } + + /// + /// 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 + /// + 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; + } + + /// + /// Gets the user's display name from token claims + /// Supports both v1.0 and v2.0 token formats + /// + 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; + } + + /// + /// Gets the user's email address from token claims + /// Supports both v1.0 and v2.0 token formats + /// + 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; + } + + /// + /// 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 + /// + public static string? GetObjectId(this ClaimsPrincipal principal) + { + return principal.FindFirst("oid")?.Value + ?? principal.FindFirst("http://schemas.microsoft.com/identity/claims/objectidentifier")?.Value; + } + + /// + /// Gets the application (client) ID that the token was issued for + /// + public static string? GetAudience(this ClaimsPrincipal principal) + { + return principal.FindFirst("aud")?.Value; + } + + /// + /// Gets the issuer of the token + /// + public static string? GetIssuer(this ClaimsPrincipal principal) + { + return principal.FindFirst("iss")?.Value; + } + + /// + /// Checks if the principal has a valid tenant ID claim + /// + public static bool HasTenantId(this ClaimsPrincipal principal) + { + var tenantId = principal.GetTenantId(); + return !string.IsNullOrEmpty(tenantId); + } + + /// + /// Gets all claims as a dictionary for debugging purposes + /// + public static Dictionary GetAllClaims(this ClaimsPrincipal principal) + { + return principal.Claims + .GroupBy(c => c.Type) + .ToDictionary( + g => g.Key, + g => string.Join(", ", g.Select(c => c.Value)) + ); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/AuthFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/AuthFunction.cs new file mode 100644 index 00000000..298de521 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Functions/AuthFunction.cs @@ -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; + +/// +/// Authentication and authorization endpoints +/// +public class AuthFunction +{ + private readonly ILogger _logger; + private readonly GalacticAdminService _galacticAdminService; + + public AuthFunction( + ILogger logger, + GalacticAdminService galacticAdminService) + { + _logger = logger; + _galacticAdminService = galacticAdminService; + } + + /// + /// GET /api/auth/me + /// Returns information about the currently authenticated user + /// + [Function("GetCurrentUser")] + [Authorize] + public async Task 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; + } + + /// + /// GET /api/auth/is-galactic-admin + /// Checks if the current user is a Galactic Admin + /// + [Function("IsGalacticAdmin")] + [Authorize] + public async Task 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; + } + + /// + /// GET /api/auth/galactic-admins + /// Lists all Galactic Admins (only accessible by Galactic Admins) + /// + [Function("GetGalacticAdmins")] + [Authorize] + public async Task 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; + } + + /// + /// POST /api/auth/galactic-admins + /// Adds a new Galactic Admin (only accessible by existing Galactic Admins) + /// + [Function("AddGalacticAdmin")] + [Authorize] + public async Task 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(); + 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; + } + + /// + /// DELETE /api/auth/galactic-admins/{upn} + /// Removes a Galactic Admin (only accessible by existing Galactic Admins) + /// + [Function("RemoveGalacticAdmin")] + [Authorize] + public async Task 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; +} diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/DailyMaintenanceFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/DailyMaintenanceFunction.cs new file mode 100644 index 00000000..dad8a82a --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Functions/DailyMaintenanceFunction.cs @@ -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 +{ + /// + /// 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 + /// + public class DailyMaintenanceFunction + { + private readonly TableStorageService _storageService; + private readonly TenantConfigurationService _tenantConfigService; + private readonly UsageMetricsService _usageMetricsService; + private readonly ILogger _logger; + + public DailyMaintenanceFunction( + TableStorageService storageService, + TenantConfigurationService tenantConfigService, + UsageMetricsService usageMetricsService, + ILogger logger) + { + _storageService = storageService; + _tenantConfigService = tenantConfigService; + _usageMetricsService = usageMetricsService; + _logger = logger; + } + + /// + /// Timer trigger: Runs daily at 2:00 AM UTC + /// NCRONTAB format: {second} {minute} {hour} {day} {month} {day-of-week} + /// + [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"); + } + } + + /// + /// Manually triggered maintenance with flexible date selection + /// + public async Task 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; + } + } + + /// + /// 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 + /// + 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 + { + { "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"); + } + } + + /// + /// Aggregates yesterday's metrics and saves them as historical snapshots + /// + private async Task AggregateYesterdayMetricsAsync() + { + var yesterday = DateTime.UtcNow.AddDays(-1).Date; + await AggregateMetricsForDateAsync(yesterday); + } + + /// + /// Aggregates metrics for a specific date and saves them as historical snapshots + /// + 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 + } + } + + /// + /// Computes historical metrics for a specific date and tenant + /// + private UsageMetricsSnapshot ComputeUsageMetricsSnapshot(string date, string tenantId, List 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) + }; + } + + /// + /// Cleans up old sessions and events based on tenant retention policies + /// + 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 + { + { "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"); + } + } + + /// + /// Calculates percentile value from sorted list + /// + private double CalculatePercentile(List 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); + } + } + + /// + /// Result of a maintenance operation + /// + 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; } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/DeleteSessionFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/DeleteSessionFunction.cs new file mode 100644 index 00000000..a1946534 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Functions/DeleteSessionFunction.cs @@ -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 _logger; + private readonly TableStorageService _storageService; + + public DeleteSessionFunction(ILogger logger, TableStorageService storageService) + { + _logger = logger; + _storageService = storageService; + } + + [Function("DeleteSession")] + public async Task 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 + { + { "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; + } + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/GetAllSessionsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/GetAllSessionsFunction.cs new file mode 100644 index 00000000..6136928f --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Functions/GetAllSessionsFunction.cs @@ -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 _logger; + private readonly TableStorageService _storageService; + + public GetAllSessionsFunction(ILogger logger, TableStorageService storageService) + { + _logger = logger; + _storageService = storageService; + } + + [Function("GetAllSessions")] + public async Task 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() + }); + + return errorResponse; + } + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/GetAuditLogsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/GetAuditLogsFunction.cs new file mode 100644 index 00000000..49906def --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Functions/GetAuditLogsFunction.cs @@ -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 _logger; + private readonly TableStorageService _storageService; + + public GetAuditLogsFunction(ILogger logger, TableStorageService storageService) + { + _logger = logger; + _storageService = storageService; + } + + [Function("GetAuditLogs")] + public async Task 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() + }); + + return errorResponse; + } + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/GetSessionEventsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/GetSessionEventsFunction.cs new file mode 100644 index 00000000..6cc4053b --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Functions/GetSessionEventsFunction.cs @@ -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 _logger; + private readonly TableStorageService _storageService; + + public GetSessionEventsFunction(ILogger logger, TableStorageService storageService) + { + _logger = logger; + _storageService = storageService; + } + + [Function("GetSessionEvents")] + public async Task 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() + }); + + return errorResponse; + } + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/GetSessionsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/GetSessionsFunction.cs new file mode 100644 index 00000000..3a3cbb00 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Functions/GetSessionsFunction.cs @@ -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 _logger; + private readonly TableStorageService _storageService; + + public GetSessionsFunction(ILogger logger, TableStorageService storageService) + { + _logger = logger; + _storageService = storageService; + } + + [Function("GetSessions")] + public async Task 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() + }); + + return errorResponse; + } + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/GetTenantConfigurationFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/GetTenantConfigurationFunction.cs new file mode 100644 index 00000000..49f634d5 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Functions/GetTenantConfigurationFunction.cs @@ -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 _logger; + private readonly TenantConfigurationService _configService; + + public GetTenantConfigurationFunction( + ILogger logger, + TenantConfigurationService configService) + { + _logger = logger; + _configService = configService; + } + + [Function("GetTenantConfiguration")] + public async Task 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; + } + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/HealthCheckFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/HealthCheckFunction.cs new file mode 100644 index 00000000..4b10dfde --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Functions/HealthCheckFunction.cs @@ -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 _logger; + private readonly HealthCheckService _healthCheckService; + private readonly GalacticAdminService _galacticAdminService; + + public HealthCheckFunction( + ILogger logger, + HealthCheckService healthCheckService, + GalacticAdminService galacticAdminService) + { + _logger = logger; + _healthCheckService = healthCheckService; + _galacticAdminService = galacticAdminService; + } + + /// + /// GET /api/health + /// Basic health check endpoint (anonymous access) + /// + [Function("HealthCheck")] + public async Task 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; + } + + /// + /// GET /api/health/detailed + /// Detailed health check with comprehensive system checks (Galactic Admin only) + /// + [Function("DetailedHealthCheck")] + [Authorize] + public async Task 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; + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/IngestEventsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/IngestEventsFunction.cs new file mode 100644 index 00000000..3db8883b --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Functions/IngestEventsFunction.cs @@ -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 _logger; + private readonly TableStorageService _storageService; + private readonly TenantConfigurationService _configService; + private readonly RateLimitService _rateLimitService; + + public IngestEventsFunction( + ILogger logger, + TableStorageService storageService, + TenantConfigurationService configService, + RateLimitService rateLimitService) + { + _logger = logger; + _storageService = storageService; + _configService = configService; + _rateLimitService = rateLimitService; + } + + [Function("IngestEvents")] + public async Task 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(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() + }; + } + + 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? 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"); + } + } + + /// + /// Parses NDJSON + gzip compressed request + /// Format: First line is metadata (sessionId, tenantId), subsequent lines are events + /// + private async Task 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(lines[0]); + if (metadata == null) + { + throw new InvalidOperationException("Failed to parse NDJSON metadata"); + } + + // Subsequent lines: events + var events = new List(); + for (int i = 1; i < lines.Length; i++) + { + var evt = JsonConvert.DeserializeObject(lines[i]); + if (evt != null) + { + events.Add(evt); + } + } + + return new IngestEventsRequest + { + SessionId = metadata.SessionId, + TenantId = metadata.TenantId, + Events = events + }; + } + + private async Task 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() }; + } + } + + public class IngestEventsOutput + { + [HttpResult] + public HttpResponseData? HttpResponse { get; set; } + + [SignalROutput(HubName = "autopilotmonitor")] + public SignalRMessageAction[]? SignalRMessages { get; set; } + } + + /// + /// NDJSON metadata (first line of NDJSON payload) + /// + internal class NdjsonMetadata + { + public string SessionId { get; set; } = string.Empty; + public string TenantId { get; set; } = string.Empty; + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/MarkSessionFailedFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/MarkSessionFailedFunction.cs new file mode 100644 index 00000000..7588ef42 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Functions/MarkSessionFailedFunction.cs @@ -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 _logger; + private readonly TableStorageService _storageService; + + public MarkSessionFailedFunction(ILogger logger, TableStorageService storageService) + { + _logger = logger; + _storageService = storageService; + } + + [Function("MarkSessionFailed")] + public async Task 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 + { + { "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; } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/PlatformUsageMetricsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/PlatformUsageMetricsFunction.cs new file mode 100644 index 00000000..7dd9f088 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Functions/PlatformUsageMetricsFunction.cs @@ -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 +{ + /// + /// Function for retrieving platform usage metrics (Galactic Admin only) + /// + public class PlatformUsageMetricsFunction + { + private readonly ILogger _logger; + private readonly UsageMetricsService _usageMetricsService; + + public PlatformUsageMetricsFunction( + ILogger logger, + UsageMetricsService usageMetricsService) + { + _logger = logger; + _usageMetricsService = usageMetricsService; + } + + /// + /// GET /api/platform/usage-metrics - Compute and return platform usage metrics + /// On-demand computation with 5-minute cache (Galactic Admin only) + /// + [Function("GetPlatformUsageMetrics")] + public async Task 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; + } + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/RegisterSessionFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/RegisterSessionFunction.cs new file mode 100644 index 00000000..35f61e60 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Functions/RegisterSessionFunction.cs @@ -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 _logger; + private readonly TableStorageService _storageService; + private readonly TenantConfigurationService _configService; + private readonly RateLimitService _rateLimitService; + + public RegisterSessionFunction( + ILogger logger, + TableStorageService storageService, + TenantConfigurationService configService, + RateLimitService rateLimitService) + { + _logger = logger; + _storageService = storageService; + _configService = configService; + _rateLimitService = rateLimitService; + } + + [Function("RegisterSession")] + public async Task 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(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 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; + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/SignalRAddToGroupFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/SignalRAddToGroupFunction.cs new file mode 100644 index 00000000..5871d922 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Functions/SignalRAddToGroupFunction.cs @@ -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 _logger; + + public SignalRAddToGroupFunction(ILogger logger) + { + _logger = logger; + } + + [Function("AddToGroup")] + public async Task 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(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; } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/SignalRNegotiateFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/SignalRNegotiateFunction.cs new file mode 100644 index 00000000..606371fe --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Functions/SignalRNegotiateFunction.cs @@ -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 _logger; + + public SignalRNegotiateFunction(ILogger 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; + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/SignalRRemoveFromGroupFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/SignalRRemoveFromGroupFunction.cs new file mode 100644 index 00000000..6526a103 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Functions/SignalRRemoveFromGroupFunction.cs @@ -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 _logger; + + public SignalRRemoveFromGroupFunction(ILogger logger) + { + _logger = logger; + } + + [Function("RemoveFromGroup")] + public async Task 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(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; } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/TriggerMaintenanceFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/TriggerMaintenanceFunction.cs new file mode 100644 index 00000000..6ba3e972 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Functions/TriggerMaintenanceFunction.cs @@ -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 +{ + /// + /// Allows Galactic Admins to manually trigger maintenance tasks + /// + public class TriggerMaintenanceFunction + { + private readonly ILogger _logger; + private readonly DailyMaintenanceFunction _maintenanceFunction; + private readonly GalacticAdminService _galacticAdminService; + + public TriggerMaintenanceFunction( + ILogger logger, + DailyMaintenanceFunction maintenanceFunction, + GalacticAdminService galacticAdminService) + { + _logger = logger; + _maintenanceFunction = maintenanceFunction; + _galacticAdminService = galacticAdminService; + } + + /// + /// 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) + /// + [Function("TriggerMaintenance")] + [Authorize] + public async Task 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; + } + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/UpdateTenantConfigurationFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/UpdateTenantConfigurationFunction.cs new file mode 100644 index 00000000..2982fbfd --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Functions/UpdateTenantConfigurationFunction.cs @@ -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 _logger; + private readonly TenantConfigurationService _configService; + + public UpdateTenantConfigurationFunction( + ILogger logger, + TenantConfigurationService configService) + { + _logger = logger; + _configService = configService; + } + + [Function("UpdateTenantConfiguration")] + public async Task 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(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; + } + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/UsageMetricsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/UsageMetricsFunction.cs new file mode 100644 index 00000000..e4da7a8d --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Functions/UsageMetricsFunction.cs @@ -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 +{ + /// + /// Function for retrieving tenant-specific usage metrics (Tenant Admin) + /// + public class UsageMetricsFunction + { + private readonly ILogger _logger; + private readonly UsageMetricsService _usageMetricsService; + + public UsageMetricsFunction( + ILogger logger, + UsageMetricsService usageMetricsService) + { + _logger = logger; + _usageMetricsService = usageMetricsService; + } + + /// + /// GET /api/usage-metrics?tenantId={tenantId} - Compute and return tenant-specific usage metrics + /// On-demand computation with 5-minute cache (Tenant Admin) + /// + [Function("GetTenantUsageMetrics")] + public async Task 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; + } + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Middleware/AuthenticationMiddleware.cs b/src/Backend/AutopilotMonitor.Functions/Middleware/AuthenticationMiddleware.cs new file mode 100644 index 00000000..80937e5b --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Middleware/AuthenticationMiddleware.cs @@ -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; + +/// +/// Middleware to manually validate JWT tokens and populate ClaimsPrincipal +/// Required for Azure Functions .NET 8 Isolated Worker (Microsoft.Identity.Web doesn't work automatically) +/// +public class AuthenticationMiddleware : IFunctionsWorkerMiddleware +{ + private readonly ILogger _logger; + private readonly IConfiguration _configuration; + private readonly JsonWebTokenHandler _tokenHandler; + private readonly IConfigurationManager _configurationManager; + + public AuthenticationMiddleware( + ILogger 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( + 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( + 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().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); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Program.cs b/src/Backend/AutopilotMonitor.Functions/Program.cs new file mode 100644 index 00000000..1e1dd982 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Program.cs @@ -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(); + +// Configure JSON serialization to use camelCase +builder.Services.Configure(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(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +builder.Build().Run(); diff --git a/src/Backend/AutopilotMonitor.Functions/Properties/launchSettings.json b/src/Backend/AutopilotMonitor.Functions/Properties/launchSettings.json new file mode 100644 index 00000000..54e76b42 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Properties/launchSettings.json @@ -0,0 +1,9 @@ +{ + "profiles": { + "AutopilotMonitor.Functions": { + "commandName": "Project", + "commandLineArgs": "--port 7228", + "launchBrowser": false + } + } +} \ No newline at end of file diff --git a/src/Backend/AutopilotMonitor.Functions/Security/CertificateValidator.cs b/src/Backend/AutopilotMonitor.Functions/Security/CertificateValidator.cs new file mode 100644 index 00000000..ce147a3d --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Security/CertificateValidator.cs @@ -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 +{ + /// + /// Validates client certificates for device authentication + /// + public static class CertificateValidator + { + /// + /// Validates a client certificate from HTTP request header + /// + /// Base64-encoded certificate from X-Client-Certificate header + /// Logger for diagnostic output + /// Validation result with certificate details + 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() + .Any(ext => ext.EnhancedKeyUsages + .OfType() + .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}" + }; + } + } + } + + /// + /// Result of certificate validation + /// + public class CertificateValidationResult + { + /// + /// Whether the certificate is valid + /// + public bool IsValid { get; set; } + + /// + /// Certificate thumbprint (for logging and rate limiting) + /// + public string? Thumbprint { get; set; } + + /// + /// Certificate subject + /// + public string? Subject { get; set; } + + /// + /// Certificate issuer + /// + public string? Issuer { get; set; } + + /// + /// Error message if validation failed + /// + public string? ErrorMessage { get; set; } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Security/HardwareWhitelistValidator.cs b/src/Backend/AutopilotMonitor.Functions/Security/HardwareWhitelistValidator.cs new file mode 100644 index 00000000..73f60c03 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Security/HardwareWhitelistValidator.cs @@ -0,0 +1,129 @@ +using System; +using System.Linq; +using System.Text.RegularExpressions; +using Microsoft.Extensions.Logging; + +namespace AutopilotMonitor.Functions.Security +{ + /// + /// Validates hardware manufacturer and model against whitelist + /// Supports wildcard patterns (e.g., "Dell*", "HP EliteBook*") + /// + public static class HardwareWhitelistValidator + { + /// + /// Validates hardware against whitelist + /// + /// Device manufacturer + /// Device model + /// Allowed manufacturers (supports wildcards) + /// Allowed models (supports wildcards) + /// Logger for diagnostic output + /// Validation result + 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 + }; + } + + /// + /// Matches a string against a wildcard pattern (supports * and ?) + /// + 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); + } + } + + /// + /// Result of hardware validation + /// + public class HardwareValidationResult + { + /// + /// Whether the hardware is authorized + /// + public bool IsValid { get; set; } + + /// + /// Device manufacturer + /// + public string? Manufacturer { get; set; } + + /// + /// Device model + /// + public string? Model { get; set; } + + /// + /// Error message if validation failed + /// + public string? ErrorMessage { get; set; } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Security/SecurityValidationExtensions.cs b/src/Backend/AutopilotMonitor.Functions/Security/SecurityValidationExtensions.cs new file mode 100644 index 00000000..fcbf1760 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Security/SecurityValidationExtensions.cs @@ -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 +{ + /// + /// Extension methods for security validation on HTTP requests + /// + public static class SecurityValidationExtensions + { + /// + /// Validates request security and creates error response if validation fails + /// + /// HTTP request + /// Tenant ID + /// Tenant configuration service + /// Rate limit service + /// Logger + /// Validation result with optional error response + 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); + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Security/SecurityValidator.cs b/src/Backend/AutopilotMonitor.Functions/Security/SecurityValidator.cs new file mode 100644 index 00000000..5670dea7 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Security/SecurityValidator.cs @@ -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 +{ + /// + /// Centralized security validation for all API requests + /// Performs certificate validation, rate limiting, and hardware whitelisting + /// + 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; + } + + /// + /// Validates request security (certificate, rate limit, hardware whitelist) + /// + /// HTTP request + /// Tenant ID for configuration lookup + /// Security validation result + public async Task 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 + }; + } + } + + /// + /// Result of security validation + /// + public class SecurityValidationResult + { + /// + /// Whether the request passed all security checks + /// + public bool IsValid { get; set; } + + /// + /// HTTP status code if validation failed + /// + public HttpStatusCode StatusCode { get; set; } + + /// + /// Error message if validation failed + /// + public string? ErrorMessage { get; set; } + + /// + /// Detailed error information + /// + public string? Details { get; set; } + + /// + /// Certificate thumbprint (if validation succeeded) + /// + public string? CertificateThumbprint { get; set; } + + /// + /// Device manufacturer (if validation succeeded) + /// + public string? Manufacturer { get; set; } + + /// + /// Device model (if validation succeeded) + /// + public string? Model { get; set; } + + /// + /// Rate limit result (if validation succeeded) + /// + public RateLimitResult? RateLimitResult { get; set; } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Security/SerialNumberValidator.cs b/src/Backend/AutopilotMonitor.Functions/Security/SerialNumberValidator.cs new file mode 100644 index 00000000..5cd8298d --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Security/SerialNumberValidator.cs @@ -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 +{ + /// + /// Validates device serial numbers against Intune Autopilot registration + /// Requires Graph API integration with DeviceManagementServiceConfig.Read.All permission + /// + 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(); + } + + /// + /// Validates that a serial number is registered in Autopilot for the given tenant + /// + /// Tenant ID + /// Device serial number + /// Validation result + public async Task 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(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 + }; + } + } + } + + /// + /// Result of serial number validation + /// + public class SerialNumberValidationResult + { + /// + /// Whether the serial number is registered in Autopilot + /// + public bool IsValid { get; set; } + + /// + /// Device serial number + /// + public string SerialNumber { get; set; } + + /// + /// Autopilot device ID (if found) + /// + public string AutopilotDeviceId { get; set; } + + /// + /// Error message if validation failed + /// + 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})"); +*/ diff --git a/src/Backend/AutopilotMonitor.Functions/Services/GalacticAdminService.cs b/src/Backend/AutopilotMonitor.Functions/Services/GalacticAdminService.cs new file mode 100644 index 00000000..d5877cc6 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Services/GalacticAdminService.cs @@ -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; + +/// +/// Service for managing Galactic Admin permissions +/// Galactic Admins can access cross-tenant data and perform platform-wide operations +/// +public class GalacticAdminService +{ + private readonly TableServiceClient _tableServiceClient; + private readonly IMemoryCache _cache; + private readonly ILogger _logger; + private readonly string _tableName = "GalacticAdmins"; + private readonly TimeSpan _cacheDuration = TimeSpan.FromMinutes(5); + + public GalacticAdminService( + IConfiguration configuration, + IMemoryCache cache, + ILogger 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}"); + } + } + + /// + /// Checks if a UPN is a Galactic Admin + /// Uses caching for performance + /// + /// User Principal Name (e.g., oliver@contoso.com) + /// True if the user is a Galactic Admin + public async Task 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(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; + } + + /// + /// Gets a Galactic Admin entity by UPN + /// + private async Task 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( + "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; + } + } + + /// + /// Adds a user as a Galactic Admin + /// + /// User Principal Name + /// UPN of the admin who is adding this user + public async Task 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; + } + + /// + /// Removes a user from Galactic Admins + /// + 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}"); + } + + /// + /// Disables (but does not delete) a Galactic Admin + /// + 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}"); + } + } + + /// + /// Gets all Galactic Admins + /// + public async Task> GetAllGalacticAdminsAsync() + { + var tableClient = _tableServiceClient.GetTableClient(_tableName); + + var admins = new List(); + await foreach (var entity in tableClient.QueryAsync( + filter: $"PartitionKey eq 'GalacticAdmins'")) + { + admins.Add(entity); + } + + return admins; + } + + /// + /// Clears the cache for all Galactic Admins + /// Useful after bulk updates + /// + 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 + } +} + +/// +/// Entity representing a Galactic Admin in Table Storage +/// +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; } + + /// + /// User Principal Name (lowercase) + /// + public string Upn { get; set; } = string.Empty; + + /// + /// Whether this admin is currently enabled + /// + public bool IsEnabled { get; set; } = true; + + /// + /// When this admin was added + /// + public DateTime AddedDate { get; set; } + + /// + /// UPN of the admin who added this user + /// + public string AddedBy { get; set; } = string.Empty; +} diff --git a/src/Backend/AutopilotMonitor.Functions/Services/HealthCheckService.cs b/src/Backend/AutopilotMonitor.Functions/Services/HealthCheckService.cs new file mode 100644 index 00000000..7ecb0997 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Services/HealthCheckService.cs @@ -0,0 +1,267 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; + +namespace AutopilotMonitor.Functions.Services; + +/// +/// Service for performing comprehensive health checks +/// +public class HealthCheckService +{ + private readonly ILogger _logger; + private readonly TableStorageService _tableStorageService; + private readonly IConfiguration _configuration; + + public HealthCheckService( + ILogger logger, + TableStorageService tableStorageService, + IConfiguration configuration) + { + _logger = logger; + _tableStorageService = tableStorageService; + _configuration = configuration; + } + + /// + /// Performs all health checks and returns the results + /// + public async Task PerformAllChecksAsync() + { + var result = new HealthCheckResult + { + Timestamp = DateTime.UtcNow, + Checks = new List() + }; + + // Perform all checks in parallel + var checks = new List> + { + 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; + } + + /// + /// Checks Azure Table Storage connectivity + /// + private async Task 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 + { + { "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; + } + + /// + /// Checks that all required configuration settings are present + /// + private Task 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(); + var presentSettings = new List(); + + 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 + { + { "RequiredSettings", requiredSettings.Length }, + { "PresentSettings", presentSettings } + }; + } + else + { + check.Status = "unhealthy"; + check.Message = $"{missingSettings.Count} required settings missing"; + check.Details = new Dictionary + { + { "MissingSettings", missingSettings }, + { "PresentSettings", presentSettings } + }; + } + + return Task.FromResult(check); + } + + /// + /// Checks authentication configuration + /// + private Task 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 + { + { "ClientIdPresent", !string.IsNullOrEmpty(clientId) }, + { "TenantIdPresent", !string.IsNullOrEmpty(tenantId) } + }; + } + else + { + check.Status = "healthy"; + check.Message = "Azure AD configuration present"; + check.Details = new Dictionary + { + { "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); + } + + /// + /// Checks SignalR service configuration + /// + private Task 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 + { + { "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); + } +} + +/// +/// Result of a health check operation +/// +public class HealthCheckResult +{ + public DateTime Timestamp { get; set; } + public string OverallStatus { get; set; } = "unknown"; + public List Checks { get; set; } = new(); +} + +/// +/// Individual health check result +/// +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? Details { get; set; } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Services/RateLimitService.cs b/src/Backend/AutopilotMonitor.Functions/Services/RateLimitService.cs new file mode 100644 index 00000000..f15d1e96 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Services/RateLimitService.cs @@ -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 +{ + /// + /// Rate limiting service to prevent DoS attacks + /// Uses sliding window algorithm for fair rate limiting per device + /// + public class RateLimitService + { + private readonly IMemoryCache _cache; + private readonly ILogger _logger; + private readonly TimeSpan _windowDuration; + + public RateLimitService(IMemoryCache cache, ILogger logger) + { + _cache = cache; + _logger = logger; + _windowDuration = TimeSpan.FromMinutes(1); + } + + /// + /// Checks if a device has exceeded its rate limit + /// + /// Certificate thumbprint identifying the device + /// Maximum requests per minute (from tenant configuration) + /// Rate limit result with details + 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(); + }); + + 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 + }; + } + } + } + + /// + /// Result of rate limit check + /// + public class RateLimitResult + { + /// + /// Whether the request is allowed + /// + public bool IsAllowed { get; set; } + + /// + /// Error message if rate limit exceeded + /// + public string? Message { get; set; } + + /// + /// Number of requests in current window + /// + public int RequestsInWindow { get; set; } + + /// + /// Maximum allowed requests + /// + public int MaxRequests { get; set; } + + /// + /// Time to wait before retrying (if rate limited) + /// + public TimeSpan? RetryAfter { get; set; } + + /// + /// Window duration for rate limiting + /// + public TimeSpan WindowDuration { get; set; } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.cs b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.cs new file mode 100644 index 00000000..5049148c --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.cs @@ -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 +{ + /// + /// Service for managing Azure Table Storage operations + /// + public class TableStorageService + { + private readonly TableServiceClient _tableServiceClient; + private readonly ILogger _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 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"); + } + } + + /// + /// Stores a session registration + /// + public async Task 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; + } + } + + /// + /// Stores an event + /// + public async Task 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; + } + } + + + /// + /// Gets all sessions for a tenant + /// + public async Task> GetSessionsAsync(string tenantId, int maxResults = 100) + { + try + { + var tableClient = _tableServiceClient.GetTableClient(SessionsTableName); + var sessions = new List(); + + // Query sessions by tenant (PartitionKey) + var query = tableClient.QueryAsync( + 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(); + } + } + + /// + /// Gets all sessions across all tenants (for galactic admin mode) + /// + public async Task> GetAllSessionsAsync(int maxResults = 100) + { + try + { + var tableClient = _tableServiceClient.GetTableClient(SessionsTableName); + var sessions = new List(); + + // Query all sessions without tenant filter + var query = tableClient.QueryAsync(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(); + } + } + + /// + /// Gets a specific session + /// + public async Task GetSessionAsync(string tenantId, string sessionId) + { + try + { + var tableClient = _tableServiceClient.GetTableClient(SessionsTableName); + var entity = await tableClient.GetEntityAsync(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; + } + } + + /// + /// Updates the session status and current phase + /// + public async Task 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(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(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; + } + + /// + /// Gets all events for a specific session + /// + public async Task> GetSessionEventsAsync(string tenantId, string sessionId, int maxResults = 1000) + { + try + { + var tableClient = _tableServiceClient.GetTableClient(EventsTableName); + var events = new List(); + + // Events are stored with PartitionKey = "{TenantId}_{SessionId}" + var partitionKey = $"{tenantId}_{sessionId}"; + + var query = tableClient.QueryAsync( + 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(); + } + } + + /// + /// Maps a TableEntity to EnrollmentEvent + /// + 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>(entity.GetString("DataJson")) + : new Dictionary() + }; + } + + /// + /// Maps a TableEntity to SessionSummary + /// + 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(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 + }; + } + + /// + /// Deletes a session from storage + /// + public async Task 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; + } + } + + /// + /// Deletes all events for a session from storage + /// + public async Task 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(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; + } + } + + /// + /// Logs an audit entry + /// + public async Task LogAuditEntryAsync(string tenantId, string action, string entityType, string entityId, string performedBy, Dictionary? 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; + } + } + + /// + /// Gets audit log entries for a tenant + /// + public async Task> GetAuditLogsAsync(string tenantId, int maxResults = 100) + { + try + { + var tableClient = _tableServiceClient.GetTableClient(AuditLogsTableName); + var query = tableClient.QueryAsync(filter: $"PartitionKey eq '{tenantId}'"); + + var logs = new List(); + 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(); + } + } + + // ===== HISTORICAL METRICS METHODS ===== + + /// + /// Saves a historical metrics snapshot + /// + public async Task 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; + } + } + + /// + /// Gets historical metrics for a date range + /// + public async Task> 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(); + + 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(filter: filter); + + var results = new List(); + 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(); + } + } + + // ===== DATA RETENTION METHODS ===== + + /// + /// Gets sessions older than a specific date for a tenant + /// + public async Task> 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(filter: filter); + + var sessions = new List(); + 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(); + } + } + + /// + /// Gets all unique tenant IDs from sessions table + /// + public async Task> GetAllTenantIdsAsync() + { + try + { + var tableClient = _tableServiceClient.GetTableClient(SessionsTableName); + var query = tableClient.QueryAsync(select: new[] { "PartitionKey" }); + + var tenantIds = new HashSet(); + 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(); + } + } + } + + 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; + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigurationService.cs b/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigurationService.cs new file mode 100644 index 00000000..60043dc5 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigurationService.cs @@ -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 +{ + /// + /// Service for managing tenant-specific configuration in Azure Table Storage + /// + public class TenantConfigurationService + { + private readonly TableClient _tableClient; + private readonly ILogger _logger; + + // In-memory cache for configuration (to avoid table lookups on every request) + private readonly System.Collections.Concurrent.ConcurrentDictionary _configCache; + + public TenantConfigurationService(IConfiguration configuration, ILogger logger) + { + _logger = logger; + _configCache = new System.Collections.Concurrent.ConcurrentDictionary(); + + var connectionString = configuration["AzureWebJobsStorage"]; + var serviceClient = new TableServiceClient(connectionString); + _tableClient = serviceClient.GetTableClient("TenantConfiguration"); + _tableClient.CreateIfNotExists(); + } + + /// + /// Gets configuration for a tenant (uses cache with 5-minute TTL) + /// + public async Task 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(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); + } + } + + /// + /// Saves configuration for a tenant + /// + 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; + } + } + + /// + /// Invalidates cache for a tenant (forces reload on next request) + /// + 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; } + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Services/UsageMetricsService.cs b/src/Backend/AutopilotMonitor.Functions/Services/UsageMetricsService.cs new file mode 100644 index 00000000..1d72f449 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Services/UsageMetricsService.cs @@ -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 +{ + /// + /// Service for computing platform usage metrics + /// + public class UsageMetricsService + { + private readonly TableStorageService _storageService; + private readonly ILogger _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 logger) + { + _storageService = storageService; + _logger = logger; + } + + /// + /// Computes platform usage metrics (with 5-minute cache) + /// + public async Task 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; + } + + /// + /// Computes tenant-specific usage metrics (no caching for tenant-specific metrics) + /// + public async Task 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 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 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 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 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); + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/host.json b/src/Backend/AutopilotMonitor.Functions/host.json new file mode 100644 index 00000000..54e7ef93 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/host.json @@ -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 + } +} \ No newline at end of file diff --git a/src/Backend/AutopilotMonitor.Functions/local.settings.json.template b/src/Backend/AutopilotMonitor.Functions/local.settings.json.template new file mode 100644 index 00000000..b90bcfc5 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/local.settings.json.template @@ -0,0 +1,9 @@ +{ + "IsEncrypted": false, + "Values": { + "AzureWebJobsStorage": "UseDevelopmentStorage=true", + "FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated", + "AzureTableStorageConnectionString": "UseDevelopmentStorage=true", + "AzureBlobStorageConnectionString": "UseDevelopmentStorage=true" + } +} diff --git a/src/Shared/AutopilotMonitor.Shared/AutopilotMonitor.Shared.csproj b/src/Shared/AutopilotMonitor.Shared/AutopilotMonitor.Shared.csproj new file mode 100644 index 00000000..594d0e6d --- /dev/null +++ b/src/Shared/AutopilotMonitor.Shared/AutopilotMonitor.Shared.csproj @@ -0,0 +1,12 @@ + + + + netstandard2.0 + 9.0 + + + + + + + diff --git a/src/Shared/AutopilotMonitor.Shared/Constants.cs b/src/Shared/AutopilotMonitor.Shared/Constants.cs new file mode 100644 index 00000000..b6bccde8 --- /dev/null +++ b/src/Shared/AutopilotMonitor.Shared/Constants.cs @@ -0,0 +1,111 @@ +namespace AutopilotMonitor.Shared +{ + /// + /// Shared constants across all projects + /// + public static class Constants + { + /// + /// Agent version + /// + public const string AgentVersion = "1.0.0-phase1"; + + /// + /// Registry path for agent configuration + /// + public const string RegistryPath = @"SOFTWARE\AutopilotMonitor"; + + /// + /// Local spool directory for offline queueing + /// + public const string SpoolDirectory = @"%ProgramData%\AutopilotMonitor\Spool"; + + /// + /// Agent log directory + /// + public const string LogDirectory = @"%ProgramData%\AutopilotMonitor\Logs"; + + /// + /// Maximum batch size for events + /// + public const int MaxBatchSize = 100; + + /// + /// Maximum batch age in seconds + /// + public const int MaxBatchAgeSeconds = 30; + + /// + /// Maximum retry attempts for failed uploads + /// + public const int MaxRetryAttempts = 5; + + /// + /// API endpoint paths + /// + 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"; + } + + /// + /// Event types + /// + 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"; + } + + /// + /// Event sources + /// + 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"; + } + + /// + /// Log file paths to monitor + /// + 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"; + } + + /// + /// Azure Table Storage table names + /// + public static class TableNames + { + public const string Sessions = "sessions"; + public const string Events = "events"; + public const string RuleResults = "ruleresults"; + } + + /// + /// Azure Blob Storage container names + /// + public static class ContainerNames + { + public const string Bundles = "bundles"; + public const string Screenshots = "screenshots"; + public const string Logs = "logs"; + } + } +} diff --git a/src/Shared/AutopilotMonitor.Shared/Models/ApiModels.cs b/src/Shared/AutopilotMonitor.Shared/Models/ApiModels.cs new file mode 100644 index 00000000..c7c6d615 --- /dev/null +++ b/src/Shared/AutopilotMonitor.Shared/Models/ApiModels.cs @@ -0,0 +1,144 @@ +using System; +using System.Collections.Generic; + +namespace AutopilotMonitor.Shared.Models +{ + /// + /// Request to register a new session + /// + public class RegisterSessionRequest + { + public SessionRegistration Registration { get; set; } + } + + /// + /// Response from session registration + /// + public class RegisterSessionResponse + { + public string SessionId { get; set; } + public bool Success { get; set; } + public string Message { get; set; } + public DateTime RegisteredAt { get; set; } + } + + /// + /// Request to ingest events (batched) + /// + public class IngestEventsRequest + { + public string SessionId { get; set; } + public string TenantId { get; set; } + public List Events { get; set; } + public bool IsCompressed { get; set; } + + public IngestEventsRequest() + { + Events = new List(); + } + } + + /// + /// Response from event ingestion + /// + 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; } + + /// + /// Whether the request was rejected due to rate limiting + /// + public bool RateLimitExceeded { get; set; } + + /// + /// Rate limit details (only populated if RateLimitExceeded is true) + /// + public RateLimitInfo RateLimitInfo { get; set; } + } + + /// + /// Rate limit information for UI display + /// + public class RateLimitInfo + { + /// + /// Number of requests in current window + /// + public int RequestsInWindow { get; set; } + + /// + /// Maximum allowed requests + /// + public int MaxRequests { get; set; } + + /// + /// Window duration in seconds + /// + public int WindowDurationSeconds { get; set; } + + /// + /// Seconds to wait before retrying + /// + public int RetryAfterSeconds { get; set; } + } + + /// + /// Request to upload a troubleshooting bundle + /// + public class UploadBundleRequest + { + public string SessionId { get; set; } + public string TenantId { get; set; } + public string BundleFileName { get; set; } + } + + /// + /// Response with SAS URL for bundle upload + /// + 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; } + } + + /// + /// Session summary for UI display + /// + 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; } + } + + /// + /// Status of an enrollment session + /// + public enum SessionStatus + { + InProgress, + Succeeded, + Failed, + Unknown + } +} diff --git a/src/Shared/AutopilotMonitor.Shared/Models/EnrollmentEvent.cs b/src/Shared/AutopilotMonitor.Shared/Models/EnrollmentEvent.cs new file mode 100644 index 00000000..4728ea62 --- /dev/null +++ b/src/Shared/AutopilotMonitor.Shared/Models/EnrollmentEvent.cs @@ -0,0 +1,123 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace AutopilotMonitor.Shared.Models +{ + /// + /// Represents a single event during enrollment + /// + public class EnrollmentEvent + { + /// + /// Unique identifier for this event + /// + public string EventId { get; set; } + + /// + /// Session identifier this event belongs to + /// + public string SessionId { get; set; } + + /// + /// Tenant identifier + /// + public string TenantId { get; set; } + + /// + /// Timestamp when the event occurred (UTC) + /// + public DateTime Timestamp { get; set; } + + /// + /// Type of event (e.g., "phase_transition", "app_install_start", "error") + /// + public string EventType { get; set; } + + /// + /// Severity level of the event (internal property, not serialized) + /// + [JsonIgnore] + public EventSeverity Severity { get; set; } + + /// + /// Severity as string for JSON serialization + /// + [JsonPropertyName("severity")] + public string SeverityString => Severity.ToString(); + + /// + /// Source of the event (e.g., "IME", "EventLog", "Agent") + /// + public string Source { get; set; } + + /// + /// Phase during which this event occurred (internal property, not serialized) + /// + [JsonIgnore] + public EnrollmentPhase Phase { get; set; } + + /// + /// Phase as number for JSON serialization (frontend expects number) + /// + [JsonPropertyName("phase")] + public int PhaseNumber => (int)Phase; + + /// + /// Phase name as string for JSON serialization + /// + [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" + }; + } + + /// + /// Human-readable message + /// + public string Message { get; set; } + + /// + /// Additional structured data + /// + public Dictionary Data { get; set; } + + /// + /// Sequence number for ordering events with same timestamp + /// + public long Sequence { get; set; } + + public EnrollmentEvent() + { + EventId = Guid.NewGuid().ToString(); + Timestamp = DateTime.UtcNow; + Data = new Dictionary(); + } + } + + /// + /// Severity levels for events + /// + public enum EventSeverity + { + Debug = 0, + Info = 1, + Warning = 2, + Error = 3, + Critical = 4 + } +} diff --git a/src/Shared/AutopilotMonitor.Shared/Models/EnrollmentPhase.cs b/src/Shared/AutopilotMonitor.Shared/Models/EnrollmentPhase.cs new file mode 100644 index 00000000..f3436ce6 --- /dev/null +++ b/src/Shared/AutopilotMonitor.Shared/Models/EnrollmentPhase.cs @@ -0,0 +1,53 @@ +namespace AutopilotMonitor.Shared.Models +{ + /// + /// Represents the phases of Autopilot enrollment + /// + public enum EnrollmentPhase + { + /// + /// Pre-flight: Agent deployed, session registered + /// + PreFlight = 0, + + /// + /// OOBE/Network: Network connection established + /// + Network = 1, + + /// + /// Identity: Azure AD/Entra joined, device certificate issued + /// + Identity = 2, + + /// + /// MDM Enrollment: Intune enrolled, policies starting + /// + MdmEnrollment = 3, + + /// + /// ESP Device Setup: Device policies applied + /// + EspDeviceSetup = 4, + + /// + /// App Installation: Required apps installing + /// + AppInstallation = 5, + + /// + /// ESP User Setup: User policies and apps + /// + EspUserSetup = 6, + + /// + /// Complete: Desktop reached + /// + Complete = 7, + + /// + /// Failed: Enrollment failed + /// + Failed = 99 + } +} diff --git a/src/Shared/AutopilotMonitor.Shared/Models/SessionRegistration.cs b/src/Shared/AutopilotMonitor.Shared/Models/SessionRegistration.cs new file mode 100644 index 00000000..84309ab6 --- /dev/null +++ b/src/Shared/AutopilotMonitor.Shared/Models/SessionRegistration.cs @@ -0,0 +1,91 @@ +using System; + +namespace AutopilotMonitor.Shared.Models +{ + /// + /// Registration payload for a new enrollment session + /// + public class SessionRegistration + { + /// + /// Unique session identifier (GUID) + /// + public string SessionId { get; set; } + + /// + /// Tenant identifier + /// + public string TenantId { get; set; } + + /// + /// Device serial number + /// + public string SerialNumber { get; set; } + + /// + /// Device manufacturer + /// + public string Manufacturer { get; set; } + + /// + /// Device model + /// + public string Model { get; set; } + + /// + /// Device name + /// + public string DeviceName { get; set; } + + /// + /// OS build number + /// + public string OsBuild { get; set; } + + /// + /// OS edition (e.g., "Pro", "Enterprise") + /// + public string OsEdition { get; set; } + + /// + /// OS language + /// + public string OsLanguage { get; set; } + + /// + /// Autopilot profile name (if detectable) + /// + public string AutopilotProfileName { get; set; } + + /// + /// Autopilot profile ID (if detectable) + /// + public string AutopilotProfileId { get; set; } + + /// + /// Whether this is user-driven enrollment + /// + public bool IsUserDriven { get; set; } + + /// + /// Whether this is pre-provisioned + /// + public bool IsPreProvisioned { get; set; } + + /// + /// Timestamp when enrollment started (UTC) + /// + public DateTime StartedAt { get; set; } + + /// + /// Agent version + /// + public string AgentVersion { get; set; } + + public SessionRegistration() + { + SessionId = Guid.NewGuid().ToString(); + StartedAt = DateTime.UtcNow; + } + } +} diff --git a/src/Shared/AutopilotMonitor.Shared/Models/TenantConfiguration.cs b/src/Shared/AutopilotMonitor.Shared/Models/TenantConfiguration.cs new file mode 100644 index 00000000..82aee77c --- /dev/null +++ b/src/Shared/AutopilotMonitor.Shared/Models/TenantConfiguration.cs @@ -0,0 +1,129 @@ +using System; +using System.Collections.Generic; + +namespace AutopilotMonitor.Shared.Models +{ + /// + /// Tenant-specific configuration stored in Azure Table Storage + /// PartitionKey = TenantId + /// RowKey = "config" + /// + public class TenantConfiguration + { + /// + /// Tenant ID (PartitionKey in Table Storage) + /// + public string TenantId { get; set; } + + /// + /// When the configuration was last updated + /// + public DateTime LastUpdated { get; set; } + + /// + /// Updated by (user email or system) + /// + public string UpdatedBy { get; set; } + + // ===== SECURITY SETTINGS ===== + + /// + /// Whether request security validation is enabled + /// + public bool SecurityEnabled { get; set; } = true; + + /// + /// Rate limit: Maximum requests per minute per device + /// Default: 100 + /// + public int RateLimitRequestsPerMinute { get; set; } = 100; + + /// + /// Hardware whitelist: Allowed manufacturers (supports wildcards like "Dell*") + /// Comma-separated list + /// + public string ManufacturerWhitelist { get; set; } = "Dell*,HP*,Lenovo*,Microsoft Corporation"; + + /// + /// Hardware whitelist: Allowed models (supports wildcards like "Latitude*") + /// Comma-separated list + /// Default: "*" (all models allowed) + /// + public string ModelWhitelist { get; set; } = "*"; + + /// + /// Whether to validate serial numbers against Intune Autopilot + /// Requires Graph API integration + /// + public bool ValidateSerialNumber { get; set; } = false; + + // ===== DATA MANAGEMENT SETTINGS ===== + + /// + /// Data retention period in days + /// Sessions and events older than this will be deleted by the daily maintenance job + /// Default: 90 days + /// + public int DataRetentionDays { get; set; } = 90; + + /// + /// 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 + /// + public int SessionTimeoutHours { get; set; } = 5; + + // ===== FUTURE SETTINGS (Extensible) ===== + + /// + /// Custom settings as JSON (for future extensibility) + /// + public string CustomSettings { get; set; } + + // ===== HELPER METHODS ===== + + /// + /// Gets manufacturer whitelist as array + /// + public string[] GetManufacturerWhitelist() + { + if (string.IsNullOrEmpty(ManufacturerWhitelist)) + return new[] { "*" }; + + return ManufacturerWhitelist.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); + } + + /// + /// Gets model whitelist as array + /// + public string[] GetModelWhitelist() + { + if (string.IsNullOrEmpty(ModelWhitelist)) + return new[] { "*" }; + + return ModelWhitelist.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); + } + + /// + /// Creates default configuration for a tenant + /// + 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 + }; + } + } +} diff --git a/src/Shared/AutopilotMonitor.Shared/Models/UsageMetrics.cs b/src/Shared/AutopilotMonitor.Shared/Models/UsageMetrics.cs new file mode 100644 index 00000000..cdaf742c --- /dev/null +++ b/src/Shared/AutopilotMonitor.Shared/Models/UsageMetrics.cs @@ -0,0 +1,119 @@ +using System; +using System.Collections.Generic; + +namespace AutopilotMonitor.Shared.Models +{ + /// + /// Platform usage metrics response + /// + public class PlatformUsageMetrics + { + /// + /// Session metrics + /// + public SessionMetrics Sessions { get; set; } = new(); + + /// + /// Tenant metrics + /// + public TenantMetrics Tenants { get; set; } = new(); + + /// + /// User metrics (requires Entra ID authentication) + /// + public UserMetrics Users { get; set; } = new(); + + /// + /// Performance metrics + /// + public PerformanceMetrics Performance { get; set; } = new(); + + /// + /// Hardware metrics + /// + public HardwareMetrics Hardware { get; set; } = new(); + + /// + /// When these metrics were computed + /// + public DateTime ComputedAt { get; set; } + + /// + /// How long it took to compute (milliseconds) + /// + public int ComputeDurationMs { get; set; } + + /// + /// Whether result is from cache + /// + 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 + { + /// + /// Total unique users (available after Entra ID integration) + /// + public int Total { get; set; } + + /// + /// Daily logins across all users + /// + public int DailyLogins { get; set; } + + /// + /// Active users in last 7 days + /// + public int Active7Days { get; set; } + + /// + /// Active users in last 30 days + /// + public int Active30Days { get; set; } + + /// + /// Note about availability + /// + 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 TopManufacturers { get; set; } = new(); + public List TopModels { get; set; } = new(); + } + + public class HardwareCount + { + public string Name { get; set; } = string.Empty; + public int Count { get; set; } + public double Percentage { get; set; } + } +} diff --git a/src/Shared/AutopilotMonitor.Shared/Models/UsageMetricsSnapshot.cs b/src/Shared/AutopilotMonitor.Shared/Models/UsageMetricsSnapshot.cs new file mode 100644 index 00000000..0ba76820 --- /dev/null +++ b/src/Shared/AutopilotMonitor.Shared/Models/UsageMetricsSnapshot.cs @@ -0,0 +1,113 @@ +using System; + +namespace AutopilotMonitor.Shared.Models +{ + /// + /// 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) + /// + public class UsageMetricsSnapshot + { + /// + /// Date of the snapshot (YYYY-MM-DD) - PartitionKey + /// + public string Date { get; set; } = string.Empty; + + /// + /// Tenant ID or "global" for cross-tenant - RowKey + /// + public string TenantId { get; set; } = string.Empty; + + /// + /// When this snapshot was computed + /// + public DateTime ComputedAt { get; set; } + + /// + /// How long it took to compute (milliseconds) + /// + public int ComputeDurationMs { get; set; } + + // ===== SESSION METRICS ===== + + /// + /// Total sessions for this day + /// + public int SessionsTotal { get; set; } + + /// + /// Sessions that succeeded + /// + public int SessionsSucceeded { get; set; } + + /// + /// Sessions that failed + /// + public int SessionsFailed { get; set; } + + /// + /// Sessions still in progress (at end of day) + /// + public int SessionsInProgress { get; set; } + + /// + /// Success rate percentage + /// + public double SessionsSuccessRate { get; set; } + + // ===== PERFORMANCE METRICS ===== + + /// + /// Average session duration in minutes + /// + public double AvgDurationMinutes { get; set; } + + /// + /// Median session duration in minutes + /// + public double MedianDurationMinutes { get; set; } + + /// + /// P95 session duration in minutes + /// + public double P95DurationMinutes { get; set; } + + /// + /// P99 session duration in minutes + /// + public double P99DurationMinutes { get; set; } + + // ===== TENANT METRICS (only for "global" row) ===== + + /// + /// Number of unique tenants active on this day (only for global) + /// + public int UniqueTenants { get; set; } + + // ===== USER METRICS (future - requires Entra ID) ===== + + /// + /// Number of unique users active on this day + /// + public int UniqueUsers { get; set; } + + /// + /// Total login events on this day + /// + public int LoginCount { get; set; } + + // ===== HARDWARE METRICS (Top 5 only for storage efficiency) ===== + + /// + /// Top 5 manufacturers as JSON: [{"name":"Dell","count":100,"percentage":50.0},...] + /// + public string TopManufacturers { get; set; } = "[]"; + + /// + /// Top 5 models as JSON: [{"name":"Latitude 7420","count":50,"percentage":25.0},...] + /// + public string TopModels { get; set; } = "[]"; + } +} diff --git a/src/Web/autopilot-monitor-web/README.md b/src/Web/autopilot-monitor-web/README.md new file mode 100644 index 00000000..bfa8b6e8 --- /dev/null +++ b/src/Web/autopilot-monitor-web/README.md @@ -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 diff --git a/src/Web/autopilot-monitor-web/app/globals.css b/src/Web/autopilot-monitor-web/app/globals.css new file mode 100644 index 00000000..13d40b89 --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/globals.css @@ -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; + } +} diff --git a/src/Web/autopilot-monitor-web/app/health-check/page.tsx b/src/Web/autopilot-monitor-web/app/health-check/page.tsx new file mode 100644 index 00000000..a9cf2183 --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/health-check/page.tsx @@ -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; +} + +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(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 ( +
+
+
🔒
+

Access Denied

+

+ Only Galactic Admins can access the health check dashboard. +

+
+
+ ); + } + + return ( +
+
+ {/* Header */} +
+
+
+

System Health Check

+

+ Comprehensive health monitoring for Autopilot Monitor infrastructure +

+
+ +
+
+ + {/* Health Check Results */} + {healthResult && ( + <> + {/* Overall Status */} +
+
+
+
{getStatusIcon(healthResult.overallStatus)}
+
+

Overall Status: {healthResult.overallStatus.toUpperCase()}

+

+ {healthResult.service} v{healthResult.version} +

+

+ Last checked: {new Date(healthResult.timestamp).toLocaleString()} +

+
+
+
+
+ {healthResult.checks.filter(c => c.status === 'healthy').length} / {healthResult.checks.length} Healthy +
+
+
+
+ + {/* Individual Checks */} +
+ {healthResult.checks.map((check, index) => ( +
+
+
+ {getStatusIcon(check.status)} +
+

{check.name}

+

{check.description}

+
+
+
+ +
+

+ {check.message} +

+
+ + {check.details && Object.keys(check.details).length > 0 && ( +
+

Details

+
+ {Object.entries(check.details).map(([key, value]) => ( +
+
{key}:
+
+ {Array.isArray(value) ? ( +
+ {value.map((item, i) => ( +
{item}
+ ))} +
+ ) : typeof value === 'object' ? ( + JSON.stringify(value) + ) : ( + String(value) + )} +
+
+ ))} +
+
+ )} +
+ ))} +
+ + )} + + {/* Initial State - No Results */} + {!healthResult && !loading && ( +
+
🏥
+

No Health Check Results

+

+ Click "Run Health Check" to perform a comprehensive system health check +

+
+

What will be checked:

+
    +
  • + • + Table Storage: Azure Table Storage connectivity and response time +
  • +
  • + • + Configuration: Presence of all required configuration settings +
  • +
  • + • + Authentication: Azure AD / MSAL configuration validity +
  • +
  • + • + SignalR: Azure SignalR Service configuration and endpoint +
  • +
+
+
+ )} +
+
+ ); +} diff --git a/src/Web/autopilot-monitor-web/app/landing/page.tsx b/src/Web/autopilot-monitor-web/app/landing/page.tsx new file mode 100644 index 00000000..2791c206 --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/landing/page.tsx @@ -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 ( +
+
+
+

Loading...

+
+
+ ); + } + + return ( +
+ {/* Navigation */} + + + {/* Hero Section */} +
+
+
+

+ Advanced Monitoring for + Windows Autopilot +

+

+ Real-time insights, intelligent troubleshooting, and comprehensive analytics for your Autopilot deployments. + Monitor every phase, track performance, and resolve issues faster than ever before. +

+
+ +
+

+ Free to try • Enterprise-ready • Multi-tenant support +

+
+
+
+ + {/* Features Grid */} +
+
+

+ Everything you need to monitor Autopilot +

+
+ {/* Feature 1 */} +
+
+ + + +
+

Real-Time Monitoring

+

+ Watch Autopilot deployments in real-time with live event streaming. Track every phase from device registration to user login. +

+
+ + {/* Feature 2 */} +
+
+ + + +
+

Usage Analytics

+

+ Comprehensive metrics on deployment success rates, performance trends, and hardware insights across your tenant. +

+
+ + {/* Feature 3 */} +
+
+ + + +
+

Multi-Tenant Ready

+

+ Built for MSPs and enterprises. Manage multiple tenants from a single dashboard with tenant isolation and role-based access. +

+
+ + {/* Feature 4 */} +
+
+ + + +
+

Intelligent Alerts

+

+ Get notified instantly when deployments fail or encounter issues. Smart alerts help you catch problems before users do. +

+
+ + {/* Feature 5 */} +
+
+ + + +
+

Event Timeline

+

+ Detailed event timeline for every deployment session. Drill down into logs, errors, and warnings to troubleshoot efficiently. +

+
+ + {/* Feature 6 */} +
+
+ + + +
+

Audit Logging

+

+ Complete audit trail of all actions and changes. Meet compliance requirements with detailed logging and data retention policies. +

+
+
+
+
+ + {/* CTA Section */} +
+
+

+ Ready to transform your Autopilot monitoring? +

+

+ Join organizations using Autopilot Monitor to deploy devices faster and more reliably. +

+ +
+
+ + {/* Footer */} +
+
+

© 2026 Autopilot Monitor. Powered by Azure and Microsoft Identity.

+
+
+
+ ); +} diff --git a/src/Web/autopilot-monitor-web/app/layout.tsx b/src/Web/autopilot-monitor-web/app/layout.tsx new file mode 100644 index 00000000..1b0c59bf --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/layout.tsx @@ -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 ( + + + + + + + + {children} + + + + + + + ); +} diff --git a/src/Web/autopilot-monitor-web/app/page.tsx b/src/Web/autopilot-monitor-web/app/page.tsx new file mode 100644 index 00000000..0067cbb8 --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/page.tsx @@ -0,0 +1,1141 @@ +"use client"; + +import { useEffect, useState, useRef } from "react"; +import { useRouter } from "next/navigation"; +import { ProtectedRoute } from "../components/ProtectedRoute"; +import { useSignalR } from "../contexts/SignalRContext"; +import { useTenant } from "../contexts/TenantContext"; +import { useAuth } from "../contexts/AuthContext"; +import { useNotifications } from "../contexts/NotificationContext"; +import { API_BASE_URL } from "@/lib/config"; + +interface Session { + sessionId: string; + tenantId: string; + serialNumber: string; + deviceName: string; + manufacturer: string; + model: string; + startedAt: string; + status: string; + currentPhase: number; + eventCount: number; + durationSeconds: number; + failureReason?: string; +} + +export default function Home() { + const router = useRouter(); + const { user, logout } = useAuth(); + const { addNotification } = useNotifications(); + const [apiStatus, setApiStatus] = useState<"unchecked" | "checking" | "healthy" | "error">("unchecked"); + const [sessions, setSessions] = useState([]); + const [loading, setLoading] = useState(true); + const [searchQuery, setSearchQuery] = useState(""); + const [sortColumn, setSortColumn] = useState(null); + const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc"); + const [currentPage, setCurrentPage] = useState(1); + const sessionsPerPage = 10; + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + const [sessionToDelete, setSessionToDelete] = useState<{ sessionId: string; tenantId: string; deviceName?: string } | null>(null); + 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 global contexts + const { on, off, isConnected, joinGroup, leaveGroup } = useSignalR(); + const { tenantId } = useTenant(); + + // Track if initial fetch has been done to prevent duplicate calls in React StrictMode + const hasInitialFetch = useRef(false); + + // Track if galactic admin mode has been initialized (to skip initial mount in useEffect) + const hasGalacticModeInitialized = useRef(false); + + // Track if we've joined the tenant group to prevent duplicate joins + const hasJoinedGroup = useRef(false); + + // Manual health check function + const checkApiHealth = async () => { + setApiStatus("checking"); + try { + const res = await fetch(`${API_BASE_URL}/api/health`); + setApiStatus(res.ok ? "healthy" : "error"); + } catch { + setApiStatus("error"); + } + }; + + const fetchSessions = async () => { + try { + // Use different endpoint based on galactic admin mode + 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 allSessions = data.sessions || []; + setSessions(allSessions); + } else { + addNotification('error', 'Backend Error', `Failed to fetch sessions: ${response.statusText}`, 'backend-error'); + } + } catch (error) { + console.error("Failed to fetch sessions:", error); + addNotification('error', 'Backend Not Reachable', 'Unable to connect to the backend API. Please ensure the backend server is running.', 'backend-unreachable'); + } finally { + setLoading(false); + } + }; + + // Initial data fetch + useEffect(() => { + // Prevent duplicate fetches in React StrictMode (development double-mounting) + if (hasInitialFetch.current) { + return; + } + hasInitialFetch.current = true; + + // Only fetch sessions on load, no automatic health check + fetchSessions(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); // Empty deps - only run once on mount + + // Join tenant group when SignalR is connected (for multi-tenancy) + useEffect(() => { + if (isConnected && !hasJoinedGroup.current) { + const groupName = `tenant-${tenantId}`; + joinGroup(groupName); + hasJoinedGroup.current = true; + } + + return () => { + // Leave group when component unmounts + if (hasJoinedGroup.current) { + const groupName = `tenant-${tenantId}`; + leaveGroup(groupName); + hasJoinedGroup.current = false; + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isConnected, tenantId]); + + // Setup SignalR listener - re-register when connection changes + useEffect(() => { + // Listen for real-time updates via SignalR (no more polling or HTTP requests!) + const handleNewEvents = (data: { sessionId: string; tenantId: string; eventCount: number; session: Session }) => { + console.log('New events notification received on home page', data); + + // Update the session in the list directly from SignalR data (no HTTP request needed!) + if (data.session) { + setSessions(prevSessions => { + const sessionIndex = prevSessions.findIndex(s => s.sessionId === data.session.sessionId); + if (sessionIndex >= 0) { + // Update existing session + const updated = [...prevSessions]; + updated[sessionIndex] = data.session; + return updated; + } else { + // Add new session at the beginning (most recent first) + return [data.session, ...prevSessions]; + } + }); + } + }; + + on('newevents', handleNewEvents); + + return () => { + off('newevents', handleNewEvents); + }; + }, [on, off]); // Re-register when SignalR connection changes + + // Save admin mode to localStorage when it changes + useEffect(() => { + localStorage.setItem('adminMode', adminMode.toString()); + }, [adminMode]); + + // Save galactic admin mode to localStorage when it changes + useEffect(() => { + localStorage.setItem('galacticAdminMode', galacticAdminMode.toString()); + }, [galacticAdminMode]); + + // Disable galactic admin mode if user is not a galactic admin + useEffect(() => { + if (user && !user.isGalacticAdmin && galacticAdminMode) { + console.log('[Home] User is not a galactic admin, disabling galactic admin mode'); + setGalacticAdminMode(false); + } + }, [user, galacticAdminMode]); + + // Listen for localStorage changes from other components (e.g., Navbar) + useEffect(() => { + const handleStorageChange = (e: StorageEvent) => { + if (e.key === 'adminMode' && e.newValue !== null) { + setAdminMode(e.newValue === 'true'); + } + if (e.key === 'galacticAdminMode' && e.newValue !== null) { + setGalacticAdminMode(e.newValue === 'true'); + } + }; + + // Also listen for custom storage events (for same-window changes) + const handleCustomStorageChange = () => { + const newAdminMode = localStorage.getItem('adminMode') === 'true'; + const newGalacticMode = localStorage.getItem('galacticAdminMode') === 'true'; + + if (newAdminMode !== adminMode) { + setAdminMode(newAdminMode); + } + if (newGalacticMode !== galacticAdminMode) { + setGalacticAdminMode(newGalacticMode); + } + }; + + window.addEventListener('storage', handleStorageChange); + window.addEventListener('localStorageChange', handleCustomStorageChange); + + return () => { + window.removeEventListener('storage', handleStorageChange); + window.removeEventListener('localStorageChange', handleCustomStorageChange); + }; + }, [adminMode, galacticAdminMode]); + + // Refetch sessions when galactic admin mode changes + useEffect(() => { + // Skip on initial mount - only refetch when user actually toggles the setting + if (!hasGalacticModeInitialized.current) { + hasGalacticModeInitialized.current = true; + return; + } + + setLoading(true); + fetchSessions(); + }, [galacticAdminMode]); + + // Reset to page 1 when search query or sort changes + useEffect(() => { + setCurrentPage(1); + }, [searchQuery, sortColumn, sortDirection]); + + const deleteSession = (sessionId: string, sessionTenantId: string, deviceName?: string) => { + setSessionToDelete({ sessionId, tenantId: sessionTenantId, deviceName }); + setShowDeleteConfirm(true); + }; + + const confirmDelete = async () => { + if (!sessionToDelete) return; + + try { + const response = await fetch(`${API_BASE_URL}/api/sessions/${sessionToDelete.sessionId}?tenantId=${sessionToDelete.tenantId}`, { + method: 'DELETE', + }); + + if (response.ok) { + // Remove session from local state + setSessions(prevSessions => prevSessions.filter(s => s.sessionId !== sessionToDelete.sessionId)); + console.log(`Session ${sessionToDelete.sessionId} deleted successfully`); + setShowDeleteConfirm(false); + setSessionToDelete(null); + } else { + const data = await response.json(); + alert(`Fehler beim Löschen: ${data.message || 'Unbekannter Fehler'}`); + } + } catch (error) { + console.error('Failed to delete session:', error); + alert('Fehler beim Löschen der Session'); + } + }; + + const cancelDelete = () => { + setShowDeleteConfirm(false); + setSessionToDelete(null); + }; + + const activeSessions = sessions.filter(s => s.status === "InProgress"); + const successRate = sessions.length > 0 + ? Math.round((sessions.filter(s => s.status === "Succeeded").length / sessions.length) * 100) + : 0; + const avgDuration = sessions.length > 0 + ? Math.round(sessions.reduce((sum, s) => sum + s.durationSeconds, 0) / sessions.length / 60) + : 0; + const failedToday = sessions.filter(s => + s.status === "Failed" && + new Date(s.startedAt).toDateString() === new Date().toDateString() + ).length; + + // Filter sessions based on search query + const filteredSessions = sessions.filter(session => { + if (!searchQuery.trim()) return true; + + const query = searchQuery.toLowerCase().trim(); + + // Check for duration comparison (e.g., ">30" or "<10") + const durationMatch = query.match(/^([><]=?)\s*(\d+)$/); + if (durationMatch) { + const operator = durationMatch[1]; + const value = parseInt(durationMatch[2]); + const durationMinutes = Math.round(session.durationSeconds / 60); + + if (operator === ">") return durationMinutes > value; + if (operator === ">=") return durationMinutes >= value; + if (operator === "<") return durationMinutes < value; + if (operator === "<=") return durationMinutes <= value; + } + + // Search in multiple fields + const searchableText = [ + session.deviceName, + session.serialNumber, + session.manufacturer, + session.model, + session.status, + session.sessionId, + new Date(session.startedAt).toLocaleString(), + `${Math.round(session.durationSeconds / 60)} min` + ].join(" ").toLowerCase(); + + return searchableText.includes(query); + }); + + // Sort sessions + const sortedSessions = [...filteredSessions].sort((a, b) => { + if (!sortColumn) return 0; + + let aValue: any = a[sortColumn]; + let bValue: any = b[sortColumn]; + + // Handle special cases + if (sortColumn === "startedAt") { + aValue = new Date(aValue).getTime(); + bValue = new Date(bValue).getTime(); + } + + if (aValue < bValue) return sortDirection === "asc" ? -1 : 1; + if (aValue > bValue) return sortDirection === "asc" ? 1 : -1; + return 0; + }); + + // Paginated sessions - show only current page + const totalPages = Math.ceil(sortedSessions.length / sessionsPerPage); + const startIndex = (currentPage - 1) * sessionsPerPage; + const endIndex = startIndex + sessionsPerPage; + const paginatedSessions = sortedSessions.slice(startIndex, endIndex); + + const handleSort = (column: keyof Session) => { + if (sortColumn === column) { + setSortDirection(sortDirection === "asc" ? "desc" : "asc"); + } else { + setSortColumn(column); + setSortDirection("asc"); + } + }; + + const handlePreviousPage = () => { + setCurrentPage(prev => Math.max(1, prev - 1)); + }; + + const handleNextPage = () => { + setCurrentPage(prev => Math.min(totalPages, prev + 1)); + }; + + return ( + +
+ {/* Main content */} +
+
+ {/* Stats cards */} +
+ + + + +
+ + {/* Welcome message - only show when no sessions */} + {sessions.length === 0 && ( +
+

+ Welcome to Autopilot Monitor +

+

+ Real-time monitoring and troubleshooting for Windows Autopilot +

+

+ No enrollment sessions yet. Sessions will appear below once devices start enrolling. +

+
+

Quick Links

+
+ + +
+
+
+ )} + + {/* Sessions List */} + {sessions.length > 0 && ( +
+
+

+ Sessions ({sessions.length}) + {filteredSessions.length !== sessions.length && ( + + ({filteredSessions.length} filtered) + + )} +

+
+ + {/* Search Input */} +
+ setSearchQuery(e.target.value)} + className="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" + /> + {searchQuery && ( + + )} +
+ +
+ + + + + Device + + {galacticAdminMode && ( + + )} + + Model + + + Status + + + Events + + + Duration + + + Started + + {adminMode && ( + + )} + + + + {paginatedSessions.length === 0 ? ( + + + + ) : ( + paginatedSessions.map((session) => ( + router.push(`/sessions/${session.sessionId}`)} + className="hover:bg-gray-50 cursor-pointer transition-colors" + > + + {galacticAdminMode && ( + + )} + + + + + + {adminMode && ( + + )} + + )) + )} + +
+ Tenant ID + + Actions +
+ No sessions found matching your search. +
+
+ {session.deviceName || session.serialNumber} +
+
+ {session.serialNumber} +
+
+
+ {session.tenantId} +
+
+
+ {session.manufacturer} {session.model} +
+
+ + + {session.eventCount} + + {Math.round(session.durationSeconds / 60)} min + + {new Date(session.startedAt).toLocaleString()} + + +
+
+ + {/* Pagination Controls */} + {totalPages > 1 && ( +
+
+ Page {currentPage} of {totalPages} ({sortedSessions.length} total sessions) +
+
+ + +
+
+ )} +
+ )} +
+
+ + {/* Delete Confirmation Modal */} + {showDeleteConfirm && sessionToDelete && ( +
+
e.stopPropagation()}> +
+
+
+ + + +
+

Delete Session

+
+ +
+

+ This action is irreversible! +

+

+ The session {sessionToDelete.sessionId} for device {sessionToDelete.deviceName || 'Unknown'} and all associated events will be permanently deleted. +

+

+ Do you want to continue? +

+
+ +
+ + +
+
+
+
+ )} +
+
+ ); +} + +function StatsCard({ + title, + value, + description, + color, +}: { + title: string; + value: string; + description: string; + color: "blue" | "green" | "purple" | "red"; +}) { + const colorClasses = { + blue: "bg-blue-500", + green: "bg-green-500", + purple: "bg-purple-500", + red: "bg-red-500", + }; + + return ( +
+
+
+
+
+ + + +
+
+
+
+
{title}
+
+
{value}
+
+
+
+
+
+
{description}
+
+
+
+ ); +} + +function QuickLink({ + title, + description, + href, +}: { + title: string; + description: string; + href: string; +}) { + return ( + +

{title}

+

{description}

+
+ ); +} + +function SortableHeader({ + column, + currentSort, + direction, + onSort, + children, +}: { + column: keyof Session; + currentSort: keyof Session | null; + direction: "asc" | "desc"; + onSort: (column: keyof Session) => void; + children: React.ReactNode; +}) { + const isActive = currentSort === column; + + return ( + onSort(column)} + className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100 transition-colors select-none" + > +
+ {children} + + {isActive ? (direction === "asc" ? "↑" : "↓") : "↕"} + +
+ + ); +} + +function StatusBadge({ status, failureReason }: { status: string; failureReason?: 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; + + // Check if this is a timeout failure + const isTimeout = status === "Failed" && failureReason && failureReason.toLowerCase().includes("timed out"); + + return ( + + {config.text} + {isTimeout && ( + + ⏱️ + + )} + + ); +} + +function SettingsMenu({ + apiStatus, + onCheckHealth, + adminMode, + onAdminModeChange, + galacticAdminMode, + onGalacticAdminModeChange, + user, +}: { + apiStatus: "unchecked" | "checking" | "healthy" | "error"; + onCheckHealth: () => void; + adminMode: boolean; + onAdminModeChange: (enabled: boolean) => void; + galacticAdminMode: boolean; + onGalacticAdminModeChange: (enabled: boolean) => void; + user: { displayName: string; email: string; isGalacticAdmin?: boolean } | null; +}) { + const [isOpen, setIsOpen] = useState(false); + const [showAuditLog, setShowAuditLog] = useState(false); + const [auditLogs, setAuditLogs] = useState([]); + const [loadingLogs, setLoadingLogs] = useState(false); + const menuRef = useRef(null); + const { tenantId } = useTenant(); + + const fetchAuditLogs = async () => { + setLoadingLogs(true); + try { + const response = await fetch(`${API_BASE_URL}/api/audit/logs?tenantId=${tenantId}`); + if (response.ok) { + const data = await response.json(); + setAuditLogs(data.logs || []); + } + } catch (error) { + console.error("Failed to fetch audit logs:", error); + } finally { + setLoadingLogs(false); + } + }; + + const statusColors = { + unchecked: "text-gray-500", + checking: "text-yellow-600", + healthy: "text-green-600", + error: "text-red-600", + }; + + const statusIcons = { + unchecked: "🔘", + checking: "🔄", + healthy: "✅", + error: "❌", + }; + + const statusText = { + unchecked: "Not checked", + checking: "Checking...", + healthy: "Connected", + error: "Not connected", + }; + + // Close dropdown when clicking outside + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (menuRef.current && !menuRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + } + + if (isOpen) { + document.addEventListener("mousedown", handleClickOutside); + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + } + }, [isOpen]); + + return ( +
+ {/* Settings Button */} + + + {/* Dropdown Menu */} + {isOpen && ( +
+
+

Settings

+ + {/* Admin Mode Toggle */} +
+
+
+ Admin Mode + {adminMode && AKTIV} +
+ +
+ {adminMode && ( +

+ ⚠️ Allows deleting sessions +

+ )} +
+ + {/* Galactic Admin Toggle - Only visible to actual galactic admins */} + {user?.isGalacticAdmin && ( +
+
+
+ Galactic Admin + {galacticAdminMode && ACTIVE} +
+ +
+ {galacticAdminMode && ( +

+ Shows ALL sessions across ALL tenants +

+ )} +
+ )} + + {/* API Status Section - Clickable */} +
+ +
+ + {/* Configuration Section */} + + + {/* Usage Metrics Section - Always visible for tenant */} + + + {/* Platform Usage Metrics Section - Galactic Admin Only */} + {galacticAdminMode && ( + + )} + + {/* Audit Log Section */} +
+ +
+
+
+ )} + + {/* Audit Log Modal */} + {showAuditLog && ( +
setShowAuditLog(false)}> +
e.stopPropagation()}> +
+
+

Audit Log

+ +
+
+
+ {loadingLogs ? ( +
Lade Audit Logs...
+ ) : auditLogs.length === 0 ? ( +
Keine Audit Logs vorhanden
+ ) : ( +
+ {auditLogs.map((log) => ( +
+
+
+
+ + {log.action} + + {log.entityType} + {log.entityId} +
+
+ von {log.performedBy} +
+ {log.details && log.details !== '{}' && ( +
+ {log.details} +
+ )} +
+
+ {new Date(log.timestamp).toLocaleString('de-DE')} +
+
+
+ ))} +
+ )} +
+
+
+ )} +
+ ); +} + +function UserMenu({ + username, + email, + onLogout, +}: { + username: string; + email: string; + onLogout: () => void; +}) { + const [isOpen, setIsOpen] = useState(false); + const menuRef = useRef(null); + + // Get initials from username + const getInitials = (name: string) => { + return name + .split(" ") + .map(n => n[0]) + .join("") + .toUpperCase() + .slice(0, 2); + }; + + // Close dropdown when clicking outside + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (menuRef.current && !menuRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + } + + if (isOpen) { + document.addEventListener("mousedown", handleClickOutside); + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + } + }, [isOpen]); + + return ( +
+ {/* User Avatar Button */} + + + {/* Dropdown Menu */} + {isOpen && ( +
+
+ {/* User Info */} +
+
+ {getInitials(username)} +
+
+
+ {username} +
+
+ {email} +
+ +
+
+ + {/* Sign out */} +
+ +
+
+
+ )} +
+ ); +} diff --git a/src/Web/autopilot-monitor-web/app/platform-usage-metrics/page.tsx b/src/Web/autopilot-monitor-web/app/platform-usage-metrics/page.tsx new file mode 100644 index 00000000..d222f455 --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/platform-usage-metrics/page.tsx @@ -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(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( +
+
+
+

Loading platform usage metrics...

+
+
+ ); + } + + if (error) { + return ( +
+
+
+ + + +

Error Loading Platform Usage Metrics

+

{error}

+
+ + +
+
+
+
+ ); + } + + if (!metrics) { + return null; + } + + return ( + +
+
+ {/* Header */} +
+
+
+ +
+

Platform Usage Metrics

+

+ Cross-tenant metrics • Computed at {formatTimestamp(metrics.computedAt)} in {metrics.computeDurationMs}ms + {metrics.fromCache && ( + + From Cache + + )} +

+
+
+ +
+
+ + {/* Session Statistics */} +
+

Sessions

+
+
+
Total Sessions
+
{metrics.sessions.total.toLocaleString()}
+
+
+
Today
+
{metrics.sessions.today.toLocaleString()}
+
+
+
Last 7 Days
+
{metrics.sessions.last7Days.toLocaleString()}
+
+
+
Last 30 Days
+
{metrics.sessions.last30Days.toLocaleString()}
+
+
+ +
+
+
Succeeded
+
{metrics.sessions.succeeded.toLocaleString()}
+
+
+
Failed
+
{metrics.sessions.failed.toLocaleString()}
+
+
+
In Progress
+
{metrics.sessions.inProgress.toLocaleString()}
+
+
+
Success Rate
+
{metrics.sessions.successRate}%
+
+
+
+ + {/* Tenant & User Statistics */} +
+ {/* Tenants */} +
+

Tenants

+
+
+
Total Tenants
+
{metrics.tenants.total.toLocaleString()}
+
+
+
Active (Last 7 Days)
+
{metrics.tenants.active7Days.toLocaleString()}
+
+
+
Active (Last 30 Days)
+
{metrics.tenants.active30Days.toLocaleString()}
+
+
+
+ + {/* Users */} +
+

Users

+
+
+ + + +

{metrics.users.note}

+
+
+
+
+ + {/* Performance Statistics */} +
+

Performance

+
+
+
Average Duration
+
{formatDuration(metrics.performance.avgDurationMinutes)}
+
+
+
Median Duration
+
{formatDuration(metrics.performance.medianDurationMinutes)}
+
+
+
P95 Duration
+
{formatDuration(metrics.performance.p95DurationMinutes)}
+
+
+
P99 Duration
+
{formatDuration(metrics.performance.p99DurationMinutes)}
+
+
+
+ + {/* Hardware Statistics */} +
+ {/* Top Manufacturers */} +
+

Top Manufacturers

+
+
+ {metrics.hardware.topManufacturers.length > 0 ? ( + metrics.hardware.topManufacturers.map((item, index) => ( +
+
+ {item.name || 'Unknown'} +
+ {item.count.toLocaleString()} + ({item.percentage}%) +
+
+
+
+
+
+ )) + ) : ( +
No data available
+ )} +
+
+
+ + {/* Top Models */} +
+

Top Models

+
+
+ {metrics.hardware.topModels.length > 0 ? ( + metrics.hardware.topModels.map((item, index) => ( +
+
+ {item.name || 'Unknown'} +
+ {item.count.toLocaleString()} + ({item.percentage}%) +
+
+
+
+
+
+ )) + ) : ( +
No data available
+ )} +
+
+
+
+
+
+
+ ); +} diff --git a/src/Web/autopilot-monitor-web/app/platform-usage-metrics/page.tsx.bak b/src/Web/autopilot-monitor-web/app/platform-usage-metrics/page.tsx.bak new file mode 100644 index 00000000..d1bc44a7 --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/platform-usage-metrics/page.tsx.bak @@ -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(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( +
+
+
+

Loading platform usage metrics...

+
+
+ ); + } + + if (error) { + return ( +
+
+
+ + + +

Error Loading Platform Usage Metrics

+

{error}

+
+ + +
+
+
+
+ ); + } + + if (!metrics) { + return null; + } + + return ( +
+
+ {/* Header */} +
+
+
+ +
+

Platform Usage Metrics

+

+ Cross-tenant metrics • Computed at {formatTimestamp(metrics.computedAt)} in {metrics.computeDurationMs}ms + {metrics.fromCache && ( + + From Cache + + )} +

+
+
+ +
+
+ + {/* Session Statistics */} +
+

Sessions

+
+
+
Total Sessions
+
{metrics.sessions.total.toLocaleString()}
+
+
+
Today
+
{metrics.sessions.today.toLocaleString()}
+
+
+
Last 7 Days
+
{metrics.sessions.last7Days.toLocaleString()}
+
+
+
Last 30 Days
+
{metrics.sessions.last30Days.toLocaleString()}
+
+
+ +
+
+
Succeeded
+
{metrics.sessions.succeeded.toLocaleString()}
+
+
+
Failed
+
{metrics.sessions.failed.toLocaleString()}
+
+
+
In Progress
+
{metrics.sessions.inProgress.toLocaleString()}
+
+
+
Success Rate
+
{metrics.sessions.successRate}%
+
+
+
+ + {/* Tenant & User Statistics */} +
+ {/* Tenants */} +
+

Tenants

+
+
+
Total Tenants
+
{metrics.tenants.total.toLocaleString()}
+
+
+
Active (Last 7 Days)
+
{metrics.tenants.active7Days.toLocaleString()}
+
+
+
Active (Last 30 Days)
+
{metrics.tenants.active30Days.toLocaleString()}
+
+
+
+ + {/* Users */} +
+

Users

+
+
+ + + +

{metrics.users.note}

+
+
+
+
+ + {/* Performance Statistics */} +
+

Performance

+
+
+
Average Duration
+
{formatDuration(metrics.performance.avgDurationMinutes)}
+
+
+
Median Duration
+
{formatDuration(metrics.performance.medianDurationMinutes)}
+
+
+
P95 Duration
+
{formatDuration(metrics.performance.p95DurationMinutes)}
+
+
+
P99 Duration
+
{formatDuration(metrics.performance.p99DurationMinutes)}
+
+
+
+ + {/* Hardware Statistics */} +
+ {/* Top Manufacturers */} +
+

Top Manufacturers

+
+
+ {metrics.hardware.topManufacturers.length > 0 ? ( + metrics.hardware.topManufacturers.map((item, index) => ( +
+
+ {item.name || 'Unknown'} +
+ {item.count.toLocaleString()} + ({item.percentage}%) +
+
+
+
+
+
+ )) + ) : ( +
No data available
+ )} +
+
+
+ + {/* Top Models */} +
+

Top Models

+
+
+ {metrics.hardware.topModels.length > 0 ? ( + metrics.hardware.topModels.map((item, index) => ( +
+
+ {item.name || 'Unknown'} +
+ {item.count.toLocaleString()} + ({item.percentage}%) +
+
+
+
+
+
+ )) + ) : ( +
No data available
+ )} +
+
+
+
+
+
+ ); +} diff --git a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/page.tsx b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/page.tsx new file mode 100644 index 00000000..3915bbd7 --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/page.tsx @@ -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; +} + +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 = { + 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([]); + const [session, setSession] = useState(null); + const [sessionTenantId, setSessionTenantId] = useState(null); + const [loading, setLoading] = useState(true); + const [expandedPhases, setExpandedPhases] = useState>(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(null); + const sessionIdRef = useRef(sessionId); + const hasInitialFetch = useRef(false); + const lastFetchedSessionId = useRef(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); + + 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 ( +
+
Loading session details...
+
+ ); + } + + return ( + +
+ {/* Header */} +
+
+
+ +

+ Session Details +

+
+ {adminMode && session?.status === 'InProgress' && ( + + )} +
+
+ +
+
+ {/* Session Info Card */} + {session && ( +
+

Device Information

+
+ + + + + } /> + + +
+
+ )} + + {/* Phase Timeline */} + {session && ( +
+

Enrollment Progress

+ +
+ )} + + {/* Timeline */} +
+
+

Event Timeline

+ {orderedPhases.length > 0 && ( +
+ + +
+ )} +
+ + {orderedPhases.length === 0 ? ( +
No events found for this session.
+ ) : ( +
+ {orderedPhases.map((phaseName) => ( + togglePhase(phaseName)} + /> + ))} +
+ )} +
+
+ + {/* Mark as Failed Confirmation Modal */} + {showMarkFailedConfirm && session && ( +
+
e.stopPropagation()}> +
+
+
+ + + +
+

Mark Session as Failed

+
+
+

+ You are about to manually mark this session as Failed. +

+

+ Session {session.sessionId} for device {session.deviceName || session.serialNumber} will be marked as failed with the reason "Manually marked as failed by administrator". +

+

+ This action will update the session status and cannot be undone. Do you want to continue? +

+
+
+ + +
+
+
+
+ )} +
+
+
+ ); +} + +function InfoItem({ label, value }: { label: string; value: React.ReactNode }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +function PhaseSection({ + phaseName, + events, + isExpanded, + onToggle +}: { + phaseName: string; + events: EnrollmentEvent[]; + isExpanded: boolean; + onToggle: () => void; +}) { + return ( +
+ + + {isExpanded && ( +
+ {events.map((event, index) => ( + + ))} +
+ )} +
+ ); +} + +function EventRow({ event }: { event: EnrollmentEvent }) { + const [showDetails, setShowDetails] = useState(false); + + return ( +
+
+
+
+ + {new Date(event.timestamp).toLocaleTimeString()} + + + {event.eventType} +
+

{event.message}

+
+ Source: {event.source} + Seq: {event.sequence} +
+
+ {event.data && Object.keys(event.data).length > 0 && ( + + )} +
+ + {showDetails && event.data && ( +
+
{JSON.stringify(event.data, null, 2)}
+
+ )} +
+ ); +} + +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 ( +
+
+ {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 ( +
+
+
+ {status === 'completed' ? '✓' : phase.id + 1} +
+
+ {phase.shortName} +
+
+ {index < phases.length - 1 && ( +
+
+ {showArrow && ( +
+
+
+ )} + {!showArrow &&
} +
+ )} +
+ ); + })} +
+
+ ); +} + +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 ( + + {severity} + + ); +} + +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 ( + + {config.text} + + ); +} diff --git a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/page.tsx.bak b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/page.tsx.bak new file mode 100644 index 00000000..e0309571 --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/page.tsx.bak @@ -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; +} + +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 = { + 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([]); + const [session, setSession] = useState(null); + const [loading, setLoading] = useState(true); + const [expandedPhases, setExpandedPhases] = useState>(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(null); + const sessionIdRef = useRef(sessionId); + const hasInitialFetch = useRef(false); + const lastFetchedSessionId = useRef(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); + + 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 ( +
+
Loading session details...
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+
+ +

+ Session Details +

+
+ {adminMode && session?.status === 'InProgress' && ( + + )} +
+
+ +
+
+ {/* Session Info Card */} + {session && ( +
+

Device Information

+
+ + + + + } /> + + +
+
+ )} + + {/* Phase Timeline */} + {session && ( +
+

Enrollment Progress

+ +
+ )} + + {/* Timeline */} +
+
+

Event Timeline

+ {orderedPhases.length > 0 && ( +
+ + +
+ )} +
+ + {orderedPhases.length === 0 ? ( +
No events found for this session.
+ ) : ( +
+ {orderedPhases.map((phaseName) => ( + togglePhase(phaseName)} + /> + ))} +
+ )} +
+
+ + {/* Mark as Failed Confirmation Modal */} + {showMarkFailedConfirm && session && ( +
+
e.stopPropagation()}> +
+
+
+ + + +
+

Mark Session as Failed

+
+
+

+ You are about to manually mark this session as Failed. +

+

+ Session {session.sessionId} for device {session.deviceName || session.serialNumber} will be marked as failed with the reason "Manually marked as failed by administrator". +

+

+ This action will update the session status and cannot be undone. Do you want to continue? +

+
+
+ + +
+
+
+
+ )} +
+
+ ); +} + +function InfoItem({ label, value }: { label: string; value: React.ReactNode }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +function PhaseSection({ + phaseName, + events, + isExpanded, + onToggle +}: { + phaseName: string; + events: EnrollmentEvent[]; + isExpanded: boolean; + onToggle: () => void; +}) { + return ( +
+ + + {isExpanded && ( +
+ {events.map((event, index) => ( + + ))} +
+ )} +
+ ); +} + +function EventRow({ event }: { event: EnrollmentEvent }) { + const [showDetails, setShowDetails] = useState(false); + + return ( +
+
+
+
+ + {new Date(event.timestamp).toLocaleTimeString()} + + + {event.eventType} +
+

{event.message}

+
+ Source: {event.source} + Seq: {event.sequence} +
+
+ {event.data && Object.keys(event.data).length > 0 && ( + + )} +
+ + {showDetails && event.data && ( +
+
{JSON.stringify(event.data, null, 2)}
+
+ )} +
+ ); +} + +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 ( +
+
+ {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 ( +
+
+
+ {status === 'completed' ? '✓' : phase.id + 1} +
+
+ {phase.shortName} +
+
+ {index < phases.length - 1 && ( +
+
+ {showArrow && ( +
+
+
+ )} + {!showArrow &&
} +
+ )} +
+ ); + })} +
+
+ ); +} + +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 ( + + {severity} + + ); +} + +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 ( + + {config.text} + + ); +} diff --git a/src/Web/autopilot-monitor-web/app/settings/page.tsx b/src/Web/autopilot-monitor-web/app/settings/page.tsx new file mode 100644 index 00000000..3d44a86d --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/settings/page.tsx @@ -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(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [successMessage, setSuccessMessage] = useState(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 ( + +
+ {/* Header */} +
+
+
+
+ +
+
+

Tenant Configuration

+

Tenant: {tenantId}

+
+
+
+
+ + {/* Main Content */} +
+ {loading ? ( +
+
+

Loading configuration...

+
+ ) : ( +
+ {/* Success Message */} + {successMessage && ( +
+ + + + {successMessage} +
+ )} + + {/* Error Message */} + {error && ( +
+ + + + {error} +
+ )} + + {/* Security Toggle */} +
+
+

Security

+

Enable or disable security validation for this tenant

+
+
+ +
+
+ + {/* Rate Limiting */} +
+
+

Rate Limiting

+

Configure DoS protection limits per device

+
+
+ +
+
+ + {/* Hardware Whitelist */} +
+
+

Hardware Whitelist

+

Restrict which device manufacturers and models can enroll

+
+
+ {/* Manufacturer Whitelist */} +
+ +
+ + {/* Model Whitelist */} +
+ +
+
+
+ + {/* Serial Number Validation (Prepared for future) */} +
+
+
+
+

Serial Number Validation

+

Validate devices against Intune Autopilot registration

+
+ + Coming Soon + +
+
+
+ +
+
+ + {/* Data Management Settings */} +
+
+

Data Management

+

Configure data retention and session timeout policies

+
+
+ {/* Data Retention Days */} +
+ +
+ + {/* Session Timeout Hours */} +
+ +
+
+
+ + {/* Galactic Admin Functions - Only visible in Galactic Mode */} + {galacticAdminMode && ( +
+
+
+ + + +
+

Galactic Admin Functions

+

Cross-tenant administrative operations

+
+
+
+
+
+
+

Manual Maintenance Trigger

+

+ Manually trigger the daily maintenance job which includes: +

+
    +
  • + • + Mark stalled sessions as timed out +
  • +
  • + • + Aggregate yesterday's metrics into historical snapshots +
  • +
  • + • + Clean up old data based on retention policies +
  • +
+
+
+ + + +

+ Warning: This operation runs across all tenants and may take several minutes to complete. + Use this only for testing or when immediate cleanup is needed. +

+
+
+
+
+ +
+
+
+
+ )} + + {/* Configuration Info */} + {config && ( +
+
+ + + +
+

Configuration Info

+

Last updated: {new Date(config.lastUpdated).toLocaleString()}

+

Updated by: {config.updatedBy}

+
+
+
+ )} + + {/* Action Buttons */} +
+ + +
+
+ )} +
+
+
+ ); +} diff --git a/src/Web/autopilot-monitor-web/app/usage-metrics/page.tsx b/src/Web/autopilot-monitor-web/app/usage-metrics/page.tsx new file mode 100644 index 00000000..ec055f3f --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/usage-metrics/page.tsx @@ -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(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( +
+
+
+

Loading usage metrics...

+
+
+ ); + } + + if (error) { + return ( +
+
+
+ + + +

Error Loading Usage Metrics

+

{error}

+
+ + +
+
+
+
+ ); + } + + if (!metrics) { + return null; + } + + return ( + +
+
+ {/* Header */} +
+
+
+ +
+

Usage Metrics

+

+ Computed at {formatTimestamp(metrics.computedAt)} in {metrics.computeDurationMs}ms + {metrics.fromCache && ( + + From Cache + + )} +

+
+
+ +
+
+ + {/* Session Statistics */} +
+

Sessions

+
+
+
Total Sessions
+
{metrics.sessions.total.toLocaleString()}
+
+
+
Today
+
{metrics.sessions.today.toLocaleString()}
+
+
+
Last 7 Days
+
{metrics.sessions.last7Days.toLocaleString()}
+
+
+
Last 30 Days
+
{metrics.sessions.last30Days.toLocaleString()}
+
+
+ +
+
+
Succeeded
+
{metrics.sessions.succeeded.toLocaleString()}
+
+
+
Failed
+
{metrics.sessions.failed.toLocaleString()}
+
+
+
In Progress
+
{metrics.sessions.inProgress.toLocaleString()}
+
+
+
Success Rate
+
{metrics.sessions.successRate}%
+
+
+
+ + {/* User Statistics */} +
+ {/* Users */} +
+

Users

+
+
+ + + +

{metrics.users.note}

+
+
+
+
+ + {/* Performance Statistics */} +
+

Performance

+
+
+
Average Duration
+
{formatDuration(metrics.performance.avgDurationMinutes)}
+
+
+
Median Duration
+
{formatDuration(metrics.performance.medianDurationMinutes)}
+
+
+
P95 Duration
+
{formatDuration(metrics.performance.p95DurationMinutes)}
+
+
+
P99 Duration
+
{formatDuration(metrics.performance.p99DurationMinutes)}
+
+
+
+ + {/* Hardware Statistics */} +
+ {/* Top Manufacturers */} +
+

Top Manufacturers

+
+
+ {metrics.hardware.topManufacturers.length > 0 ? ( + metrics.hardware.topManufacturers.map((item, index) => ( +
+
+ {item.name || 'Unknown'} +
+ {item.count.toLocaleString()} + ({item.percentage}%) +
+
+
+
+
+
+ )) + ) : ( +
No data available
+ )} +
+
+
+ + {/* Top Models */} +
+

Top Models

+
+
+ {metrics.hardware.topModels.length > 0 ? ( + metrics.hardware.topModels.map((item, index) => ( +
+
+ {item.name || 'Unknown'} +
+ {item.count.toLocaleString()} + ({item.percentage}%) +
+
+
+
+
+
+ )) + ) : ( +
No data available
+ )} +
+
+
+
+
+
+
+ ); +} diff --git a/src/Web/autopilot-monitor-web/app/usage-metrics/page.tsx.bak b/src/Web/autopilot-monitor-web/app/usage-metrics/page.tsx.bak new file mode 100644 index 00000000..7a99e7bd --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/usage-metrics/page.tsx.bak @@ -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(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( +
+
+
+

Loading usage metrics...

+
+
+ ); + } + + if (error) { + return ( +
+
+
+ + + +

Error Loading Usage Metrics

+

{error}

+
+ + +
+
+
+
+ ); + } + + if (!metrics) { + return null; + } + + return ( +
+
+ {/* Header */} +
+
+
+ +
+

Usage Metrics

+

+ Computed at {formatTimestamp(metrics.computedAt)} in {metrics.computeDurationMs}ms + {metrics.fromCache && ( + + From Cache + + )} +

+
+
+ +
+
+ + {/* Session Statistics */} +
+

Sessions

+
+
+
Total Sessions
+
{metrics.sessions.total.toLocaleString()}
+
+
+
Today
+
{metrics.sessions.today.toLocaleString()}
+
+
+
Last 7 Days
+
{metrics.sessions.last7Days.toLocaleString()}
+
+
+
Last 30 Days
+
{metrics.sessions.last30Days.toLocaleString()}
+
+
+ +
+
+
Succeeded
+
{metrics.sessions.succeeded.toLocaleString()}
+
+
+
Failed
+
{metrics.sessions.failed.toLocaleString()}
+
+
+
In Progress
+
{metrics.sessions.inProgress.toLocaleString()}
+
+
+
Success Rate
+
{metrics.sessions.successRate}%
+
+
+
+ + {/* User Statistics */} +
+ {/* Users */} +
+

Users

+
+
+ + + +

{metrics.users.note}

+
+
+
+
+ + {/* Performance Statistics */} +
+

Performance

+
+
+
Average Duration
+
{formatDuration(metrics.performance.avgDurationMinutes)}
+
+
+
Median Duration
+
{formatDuration(metrics.performance.medianDurationMinutes)}
+
+
+
P95 Duration
+
{formatDuration(metrics.performance.p95DurationMinutes)}
+
+
+
P99 Duration
+
{formatDuration(metrics.performance.p99DurationMinutes)}
+
+
+
+ + {/* Hardware Statistics */} +
+ {/* Top Manufacturers */} +
+

Top Manufacturers

+
+
+ {metrics.hardware.topManufacturers.length > 0 ? ( + metrics.hardware.topManufacturers.map((item, index) => ( +
+
+ {item.name || 'Unknown'} +
+ {item.count.toLocaleString()} + ({item.percentage}%) +
+
+
+
+
+
+ )) + ) : ( +
No data available
+ )} +
+
+
+ + {/* Top Models */} +
+

Top Models

+
+
+ {metrics.hardware.topModels.length > 0 ? ( + metrics.hardware.topModels.map((item, index) => ( +
+
+ {item.name || 'Unknown'} +
+ {item.count.toLocaleString()} + ({item.percentage}%) +
+
+
+
+
+
+ )) + ) : ( +
No data available
+ )} +
+
+
+
+
+
+ ); +} diff --git a/src/Web/autopilot-monitor-web/components/Navbar.tsx b/src/Web/autopilot-monitor-web/components/Navbar.tsx new file mode 100644 index 00000000..ceccd386 --- /dev/null +++ b/src/Web/autopilot-monitor-web/components/Navbar.tsx @@ -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(null); + const userMenuRef = useRef(null); + const settingsRef = useRef(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 ( + + ); +} diff --git a/src/Web/autopilot-monitor-web/components/ProtectedRoute.tsx b/src/Web/autopilot-monitor-web/components/ProtectedRoute.tsx new file mode 100644 index 00000000..e841aa20 --- /dev/null +++ b/src/Web/autopilot-monitor-web/components/ProtectedRoute.tsx @@ -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 ( +
+
+
+

Loading...

+
+
+ ); + } + + // 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 ( +
+
+ + + +

Access Denied

+

You need Galactic Admin permissions to access this page.

+ +
+
+ ); + } + + // Render children if authenticated (and galactic admin if required) + return <>{children}; +} diff --git a/src/Web/autopilot-monitor-web/contexts/AuthContext.tsx b/src/Web/autopilot-monitor-web/contexts/AuthContext.tsx new file mode 100644 index 00000000..3c6fe547 --- /dev/null +++ b/src/Web/autopilot-monitor-web/contexts/AuthContext.tsx @@ -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; + logout: () => Promise; + getAccessToken: () => Promise; + refreshUserInfo: () => Promise; +} + +const AuthContext = createContext(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(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 => { + 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 => { + 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 ( + + {children} + + ); +} + +/** + * Main Auth Provider that wraps MsalProvider + */ +export function AuthProvider({ children }: { children: React.ReactNode }) { + return ( + + + {children} + + + ); +} + +/** + * 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; +} diff --git a/src/Web/autopilot-monitor-web/contexts/NotificationContext.tsx b/src/Web/autopilot-monitor-web/contexts/NotificationContext.tsx new file mode 100644 index 00000000..8a0bf1bb --- /dev/null +++ b/src/Web/autopilot-monitor-web/contexts/NotificationContext.tsx @@ -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(undefined); + +export function NotificationProvider({ children }: { children: React.ReactNode }) { + const [notifications, setNotifications] = useState([]); + + 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 ( + + {children} + + ); +} + +export function useNotifications() { + const context = useContext(NotificationContext); + if (context === undefined) { + throw new Error('useNotifications must be used within a NotificationProvider'); + } + return context; +} diff --git a/src/Web/autopilot-monitor-web/contexts/SignalRContext.tsx b/src/Web/autopilot-monitor-web/contexts/SignalRContext.tsx new file mode 100644 index 00000000..438da25f --- /dev/null +++ b/src/Web/autopilot-monitor-web/contexts/SignalRContext.tsx @@ -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; + joinGroup: (groupName: string) => Promise; + leaveGroup: (groupName: string) => Promise; + isConnected: boolean; +} + +const SignalRContext = createContext(undefined); + +export function SignalRProvider({ children }: { children: React.ReactNode }) { + const [connection, setConnection] = useState(null); + const [connectionState, setConnectionState] = useState(signalR.HubConnectionState.Disconnected); + const connectionRef = useRef(null); + const joinedGroupsRef = useRef>(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 ( + + {children} + + ); +} + +export function useSignalR() { + const context = useContext(SignalRContext); + if (context === undefined) { + throw new Error('useSignalR must be used within a SignalRProvider'); + } + return context; +} diff --git a/src/Web/autopilot-monitor-web/contexts/TenantContext.tsx b/src/Web/autopilot-monitor-web/contexts/TenantContext.tsx new file mode 100644 index 00000000..acdba9b4 --- /dev/null +++ b/src/Web/autopilot-monitor-web/contexts/TenantContext.tsx @@ -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(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(() => { + // 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 ( + + {children} + + ); +} + +export function useTenant() { + const context = useContext(TenantContext); + if (context === undefined) { + throw new Error('useTenant must be used within a TenantProvider'); + } + return context; +} diff --git a/src/Web/autopilot-monitor-web/lib/config.ts b/src/Web/autopilot-monitor-web/lib/config.ts new file mode 100644 index 00000000..98643356 --- /dev/null +++ b/src/Web/autopilot-monitor-web/lib/config.ts @@ -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, +}; diff --git a/src/Web/autopilot-monitor-web/lib/msalConfig.ts b/src/Web/autopilot-monitor-web/lib/msalConfig.ts new file mode 100644 index 00000000..e39fd8df --- /dev/null +++ b/src/Web/autopilot-monitor-web/lib/msalConfig.ts @@ -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, + }, +}; diff --git a/src/Web/autopilot-monitor-web/next-env.d.ts b/src/Web/autopilot-monitor-web/next-env.d.ts new file mode 100644 index 00000000..830fb594 --- /dev/null +++ b/src/Web/autopilot-monitor-web/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/src/Web/autopilot-monitor-web/next.config.js b/src/Web/autopilot-monitor-web/next.config.js new file mode 100644 index 00000000..ca2117bc --- /dev/null +++ b/src/Web/autopilot-monitor-web/next.config.js @@ -0,0 +1,6 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + reactStrictMode: false, +} + +module.exports = nextConfig diff --git a/src/Web/autopilot-monitor-web/next.config.ts b/src/Web/autopilot-monitor-web/next.config.ts new file mode 100644 index 00000000..39151635 --- /dev/null +++ b/src/Web/autopilot-monitor-web/next.config.ts @@ -0,0 +1,8 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + /* config options here */ + reactStrictMode: true, +}; + +export default nextConfig; diff --git a/src/Web/autopilot-monitor-web/package-lock.json b/src/Web/autopilot-monitor-web/package-lock.json new file mode 100644 index 00000000..0203641b --- /dev/null +++ b/src/Web/autopilot-monitor-web/package-lock.json @@ -0,0 +1,6330 @@ +{ + "name": "autopilot-monitor-web", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "autopilot-monitor-web", + "version": "1.0.0", + "dependencies": { + "@azure/msal-browser": "^3.28.0", + "@azure/msal-react": "^2.1.3", + "@microsoft/signalr": "^10.0.0", + "next": "^15.1.6", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@types/node": "^22.10.5", + "@types/react": "^19.0.2", + "@types/react-dom": "^19.0.2", + "autoprefixer": "^10.4.20", + "eslint": "^9.18.0", + "eslint-config-next": "^15.1.6", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.17", + "typescript": "^5.7.3" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@azure/msal-browser": { + "version": "3.30.0", + "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-3.30.0.tgz", + "integrity": "sha512-I0XlIGVdM4E9kYP5eTjgW8fgATdzwxJvQ6bm2PNiHaZhEuUz47NYw1xHthC9R+lXz4i9zbShS0VdLyxd7n0GGA==", + "license": "MIT", + "dependencies": { + "@azure/msal-common": "14.16.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-common": { + "version": "14.16.1", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-14.16.1.tgz", + "integrity": "sha512-nyxsA6NA4SVKh5YyRpbSXiMr7oQbwark7JU9LMeg6tJYTSPyAGkdx61wPT4gyxZfxlSxMMEyAsWaubBlNyIa1w==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-react": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@azure/msal-react/-/msal-react-2.2.0.tgz", + "integrity": "sha512-2V+9JXeXyyjYNF92y5u0tU4el9px/V1+vkRuN+DtoxyiMHCtYQpJoaFdGWArh43zhz5aqQqiGW/iajPDSu3QsQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@azure/msal-browser": "^3.27.0", + "react": "^16.8.0 || ^17 || ^18" + } + }, + "node_modules/@emnapi/core": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", + "integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", + "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", + "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", + "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", + "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@img/colour": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", + "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@microsoft/signalr": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@microsoft/signalr/-/signalr-10.0.0.tgz", + "integrity": "sha512-0BRqz/uCx3JdrOqiqgFhih/+hfTERaUfCZXFB52uMaZJrKaPRzHzMuqVsJC/V3pt7NozcNXGspjKiQEK+X7P2w==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "eventsource": "^2.0.2", + "fetch-cookie": "^2.0.3", + "node-fetch": "^2.6.7", + "ws": "^7.5.10" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", + "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.10.0" + } + }, + "node_modules/@next/env": { + "version": "15.5.11", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.11.tgz", + "integrity": "sha512-g9s5SS9gC7GJCEOR3OV3zqs7C5VddqxP9X+/6BpMbdXRkqsWfFf2CJPBZNvNEtAkKTNuRgRXAgNxSAXzfLdaTg==", + "license": "MIT" + }, + "node_modules/@next/eslint-plugin-next": { + "version": "15.5.11", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-15.5.11.tgz", + "integrity": "sha512-tS/HYQOjIoX9ZNDQitba/baS8sTvo3ekY6Vgdx5lmhN4jov082bdApIChXr94qhMZHvEciz9DZglFFnhguQp/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-glob": "3.3.1" + } + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.7.tgz", + "integrity": "sha512-IZwtxCEpI91HVU/rAUOOobWSZv4P2DeTtNaCdHqLcTJU4wdNXgAySvKa/qJCgR5m6KI8UsKDXtO2B31jcaw1Yw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.7.tgz", + "integrity": "sha512-UP6CaDBcqaCBuiq/gfCEJw7sPEoX1aIjZHnBWN9v9qYHQdMKvCKcAVs4OX1vIjeE+tC5EIuwDTVIoXpUes29lg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.7.tgz", + "integrity": "sha512-NCslw3GrNIw7OgmRBxHtdWFQYhexoUCq+0oS2ccjyYLtcn1SzGzeM54jpTFonIMUjNbHmpKpziXnpxhSWLcmBA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.7.tgz", + "integrity": "sha512-nfymt+SE5cvtTrG9u1wdoxBr9bVB7mtKTcj0ltRn6gkP/2Nu1zM5ei8rwP9qKQP0Y//umK+TtkKgNtfboBxRrw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.7.tgz", + "integrity": "sha512-hvXcZvCaaEbCZcVzcY7E1uXN9xWZfFvkNHwbe/n4OkRhFWrs1J1QV+4U1BN06tXLdaS4DazEGXwgqnu/VMcmqw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.7.tgz", + "integrity": "sha512-4IUO539b8FmF0odY6/SqANJdgwn1xs1GkPO5doZugwZ3ETF6JUdckk7RGmsfSf7ws8Qb2YB5It33mvNL/0acqA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.7.tgz", + "integrity": "sha512-CpJVTkYI3ZajQkC5vajM7/ApKJUOlm6uP4BknM3XKvJ7VXAvCqSjSLmM0LKdYzn6nBJVSjdclx8nYJSa3xlTgQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.7.tgz", + "integrity": "sha512-gMzgBX164I6DN+9/PGA+9dQiwmTkE4TloBNx8Kv9UiGARsr9Nba7IpcBRA1iTV9vwlYnrE3Uy6I7Aj6qLjQuqw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nolyfill/is-core-module": { + "version": "1.0.39", + "resolved": "https://registry.npmjs.org/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz", + "integrity": "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.4.0" + } + }, + "node_modules/@rtsao/scc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", + "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rushstack/eslint-patch": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.15.0.tgz", + "integrity": "sha512-ojSshQPKwVvSMR8yT2L/QtUkV5SXi/IfDiJ4/8d6UbTPjiHVmxZzUAzGD8Tzks1b9+qQkZa0isUOvYObedITaw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json5": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", + "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.7", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.7.tgz", + "integrity": "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.10", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.10.tgz", + "integrity": "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.54.0.tgz", + "integrity": "sha512-hAAP5io/7csFStuOmR782YmTthKBJ9ND3WVL60hcOjvtGFb+HJxH4O5huAcmcZ9v9G8P+JETiZ/G1B8MALnWZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.54.0", + "@typescript-eslint/type-utils": "8.54.0", + "@typescript-eslint/utils": "8.54.0", + "@typescript-eslint/visitor-keys": "8.54.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.54.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.54.0.tgz", + "integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.54.0", + "@typescript-eslint/types": "8.54.0", + "@typescript-eslint/typescript-estree": "8.54.0", + "@typescript-eslint/visitor-keys": "8.54.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.54.0.tgz", + "integrity": "sha512-YPf+rvJ1s7MyiWM4uTRhE4DvBXrEV+d8oC3P9Y2eT7S+HBS0clybdMIPnhiATi9vZOYDc7OQ1L/i6ga6NFYK/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.54.0", + "@typescript-eslint/types": "^8.54.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.54.0.tgz", + "integrity": "sha512-27rYVQku26j/PbHYcVfRPonmOlVI6gihHtXFbTdB5sb6qA0wdAQAbyXFVarQ5t4HRojIz64IV90YtsjQSSGlQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.54.0", + "@typescript-eslint/visitor-keys": "8.54.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.54.0.tgz", + "integrity": "sha512-dRgOyT2hPk/JwxNMZDsIXDgyl9axdJI3ogZ2XWhBPsnZUv+hPesa5iuhdYt2gzwA9t8RE5ytOJ6xB0moV0Ujvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.54.0.tgz", + "integrity": "sha512-hiLguxJWHjjwL6xMBwD903ciAwd7DmK30Y9Axs/etOkftC3ZNN9K44IuRD/EB08amu+Zw6W37x9RecLkOo3pMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.54.0", + "@typescript-eslint/typescript-estree": "8.54.0", + "@typescript-eslint/utils": "8.54.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.54.0.tgz", + "integrity": "sha512-PDUI9R1BVjqu7AUDsRBbKMtwmjWcn4J3le+5LpcFgWULN3LvHC5rkc9gCVxbrsrGmO1jfPybN5s6h4Jy+OnkAA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.54.0.tgz", + "integrity": "sha512-BUwcskRaPvTk6fzVWgDPdUndLjB87KYDrN5EYGetnktoeAvPtO4ONHlAZDnj5VFnUANg0Sjm7j4usBlnoVMHwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.54.0", + "@typescript-eslint/tsconfig-utils": "8.54.0", + "@typescript-eslint/types": "8.54.0", + "@typescript-eslint/visitor-keys": "8.54.0", + "debug": "^4.4.3", + "minimatch": "^9.0.5", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.54.0.tgz", + "integrity": "sha512-9Cnda8GS57AQakvRyG0PTejJNlA2xhvyNtEVIMlDWOOeEyBkYWhGPnfrIAnqxLMTSTo6q8g12XVjjev5l1NvMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.54.0", + "@typescript-eslint/types": "8.54.0", + "@typescript-eslint/typescript-estree": "8.54.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.54.0.tgz", + "integrity": "sha512-VFlhGSl4opC0bprJiItPQ1RfUhGDIBokcPwaFH4yiBCaNPeld/9VeXbiPO1cLyorQi1G1vL+ecBk1x8o1axORA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.54.0", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", + "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz", + "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz", + "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", + "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz", + "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz", + "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz", + "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz", + "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz", + "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz", + "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz", + "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz", + "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz", + "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz", + "integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz", + "integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz", + "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^0.2.11" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz", + "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz", + "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", + "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-includes": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlast": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlastindex": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", + "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-shim-unscopables": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.tosorted": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ast-types-flow": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", + "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/autoprefixer": { + "version": "10.4.24", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.24.tgz", + "integrity": "sha512-uHZg7N9ULTVbutaIsDRoUkoS8/h3bdsmVJYZ5l3wv8Cp/6UIIoRDm90hZ+BwxUj/hGBEzLxdHNSKuFpn8WOyZw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.1", + "caniuse-lite": "^1.0.30001766", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/axe-core": { + "version": "4.11.1", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.1.tgz", + "integrity": "sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.19", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", + "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001767", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001767.tgz", + "integrity": "sha512-34+zUAMhSH+r+9eKmYG+k2Rpt8XttfE4yXAjoZvkAPs15xcYQhyBYdalJ65BzivAvGRMViEjy6oKr/S91loekQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/damerau-levenshtein": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", + "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.283", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.283.tgz", + "integrity": "sha512-3vifjt1HgrGW/h76UEeny+adYApveS9dH2h3p57JYzBSXJIKUJAvtmIytDKjcSCt9xHfrNCFJ7gts6vkhuq++w==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-abstract": { + "version": "1.24.1", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz", + "integrity": "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-iterator-helpers": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.2.2.tgz", + "integrity": "sha512-BrUQ0cPTB/IwXj23HtwHjS9n7O4h9FX94b4xc5zlTHxeLgTAdzYUDyy6KdExAl9lbN5rtfe44xpjpmj9grxs5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.1", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.1.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.3.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "iterator.prototype": "^1.1.5", + "safe-array-concat": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", + "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.39.2", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-next": { + "version": "15.5.11", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-15.5.11.tgz", + "integrity": "sha512-RQNY69VUv0BzXkLEKDh/OPUzA+krFOnYRxO0JA3UsW429ovLa2nXx8kZuXCl18P27PyJBdS3qgJJkIhi9H8SuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@next/eslint-plugin-next": "15.5.11", + "@rushstack/eslint-patch": "^1.10.3", + "@typescript-eslint/eslint-plugin": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "@typescript-eslint/parser": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "eslint-import-resolver-node": "^0.3.6", + "eslint-import-resolver-typescript": "^3.5.2", + "eslint-plugin-import": "^2.31.0", + "eslint-plugin-jsx-a11y": "^6.10.0", + "eslint-plugin-react": "^7.37.0", + "eslint-plugin-react-hooks": "^5.0.0" + }, + "peerDependencies": { + "eslint": "^7.23.0 || ^8.0.0 || ^9.0.0", + "typescript": ">=3.3.1" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/eslint-import-resolver-node": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", + "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7", + "is-core-module": "^2.13.0", + "resolve": "^1.22.4" + } + }, + "node_modules/eslint-import-resolver-node/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-import-resolver-typescript": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.10.1.tgz", + "integrity": "sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@nolyfill/is-core-module": "1.0.39", + "debug": "^4.4.0", + "get-tsconfig": "^4.10.0", + "is-bun-module": "^2.0.0", + "stable-hash": "^0.0.5", + "tinyglobby": "^0.2.13", + "unrs-resolver": "^1.6.2" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-import-resolver-typescript" + }, + "peerDependencies": { + "eslint": "*", + "eslint-plugin-import": "*", + "eslint-plugin-import-x": "*" + }, + "peerDependenciesMeta": { + "eslint-plugin-import": { + "optional": true + }, + "eslint-plugin-import-x": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", + "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7" + }, + "engines": { + "node": ">=4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import": { + "version": "2.32.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", + "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rtsao/scc": "^1.1.0", + "array-includes": "^3.1.9", + "array.prototype.findlastindex": "^1.2.6", + "array.prototype.flat": "^1.3.3", + "array.prototype.flatmap": "^1.3.3", + "debug": "^3.2.7", + "doctrine": "^2.1.0", + "eslint-import-resolver-node": "^0.3.9", + "eslint-module-utils": "^2.12.1", + "hasown": "^2.0.2", + "is-core-module": "^2.16.1", + "is-glob": "^4.0.3", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "object.groupby": "^1.0.3", + "object.values": "^1.2.1", + "semver": "^6.3.1", + "string.prototype.trimend": "^1.0.9", + "tsconfig-paths": "^3.15.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-import/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/eslint-plugin-jsx-a11y": { + "version": "6.10.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz", + "integrity": "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "aria-query": "^5.3.2", + "array-includes": "^3.1.8", + "array.prototype.flatmap": "^1.3.2", + "ast-types-flow": "^0.0.8", + "axe-core": "^4.10.0", + "axobject-query": "^4.1.0", + "damerau-levenshtein": "^1.0.8", + "emoji-regex": "^9.2.2", + "hasown": "^2.0.2", + "jsx-ast-utils": "^3.3.5", + "language-tags": "^1.0.9", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "safe-regex-test": "^1.0.3", + "string.prototype.includes": "^2.0.1" + }, + "engines": { + "node": ">=4.0" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-react": { + "version": "7.37.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", + "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", + "array.prototype.flatmap": "^1.3.3", + "array.prototype.tosorted": "^1.1.4", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.2.1", + "estraverse": "^5.3.0", + "hasown": "^2.0.2", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.9", + "object.fromentries": "^2.0.8", + "object.values": "^1.2.1", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.5", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.12", + "string.prototype.repeat": "^1.0.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", + "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react/node_modules/resolve": { + "version": "2.0.0-next.5", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz", + "integrity": "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/eslint-plugin-react/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/eventsource": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz", + "integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", + "integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fetch-cookie": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/fetch-cookie/-/fetch-cookie-2.2.0.tgz", + "integrity": "sha512-h9AgfjURuCgA2+2ISl8GbavpUdR+WGAM2McW/ovn4tVccegp8ZqCKWSBR8uRdM8dDNlx5WdKRWxBYUwteLDCNQ==", + "license": "Unlicense", + "dependencies": { + "set-cookie-parser": "^2.4.8", + "tough-cookie": "^4.0.0" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.1", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.1.tgz", + "integrity": "sha512-EoY1N2xCn44xU6750Sx7OjOIT59FkmstNc3X6y5xpz7D5cBtZRe/3pSlTkDJgqsOk3WwZPkWfonhhUJfttQo3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bun-module": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz", + "integrity": "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.7.1" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/iterator.prototype": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", + "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "get-proto": "^1.0.0", + "has-symbols": "^1.1.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, + "node_modules/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/language-subtag-registry": { + "version": "0.3.23", + "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", + "integrity": "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/language-tags": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.9.tgz", + "integrity": "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==", + "dev": true, + "license": "MIT", + "dependencies": { + "language-subtag-registry": "^0.3.20" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/napi-postinstall": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", + "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", + "dev": true, + "license": "MIT", + "bin": { + "napi-postinstall": "lib/cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/napi-postinstall" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/next": { + "version": "15.5.11", + "resolved": "https://registry.npmjs.org/next/-/next-15.5.11.tgz", + "integrity": "sha512-L2KPiKmqTDpRdeVDdPjhf43g2/VPe0NCNndq7OKDCgOLWtxe1kbr/zXGIZtYY7kZEAjRf7Bj/mwUFSr+tYC2Yg==", + "license": "MIT", + "dependencies": { + "@next/env": "15.5.11", + "@swc/helpers": "0.5.15", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.4.31", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "15.5.7", + "@next/swc-darwin-x64": "15.5.7", + "@next/swc-linux-arm64-gnu": "15.5.7", + "@next/swc-linux-arm64-musl": "15.5.7", + "@next/swc-linux-x64-gnu": "15.5.7", + "@next/swc-linux-x64-musl": "15.5.7", + "@next/swc-win32-arm64-msvc": "15.5.7", + "@next/swc-win32-x64-msvc": "15.5.7", + "sharp": "^0.34.3" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.51.1", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/next/node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.entries": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.groupby": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", + "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "license": "MIT" + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", + "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "devOptional": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stable-hash": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", + "integrity": "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string.prototype.includes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", + "integrity": "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string.prototype.matchall": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", + "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "regexp.prototype.flags": "^1.5.3", + "set-function-name": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.repeat": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", + "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/styled-jsx": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tailwindcss/node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/tailwindcss/node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "license": "BSD-3-Clause", + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/ts-api-utils": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", + "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/tsconfig-paths": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", + "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json5": "^0.0.29", + "json5": "^1.0.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/unrs-resolver": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", + "integrity": "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "napi-postinstall": "^0.3.0" + }, + "funding": { + "url": "https://opencollective.com/unrs-resolver" + }, + "optionalDependencies": { + "@unrs/resolver-binding-android-arm-eabi": "1.11.1", + "@unrs/resolver-binding-android-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-x64": "1.11.1", + "@unrs/resolver-binding-freebsd-x64": "1.11.1", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", + "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-musl": "1.11.1", + "@unrs/resolver-binding-wasm32-wasi": "1.11.1", + "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", + "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", + "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", + "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ws": { + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/src/Web/autopilot-monitor-web/package.json b/src/Web/autopilot-monitor-web/package.json new file mode 100644 index 00000000..08779175 --- /dev/null +++ b/src/Web/autopilot-monitor-web/package.json @@ -0,0 +1,30 @@ +{ + "name": "autopilot-monitor-web", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "@azure/msal-browser": "^3.28.0", + "@azure/msal-react": "^2.1.3", + "@microsoft/signalr": "^10.0.0", + "next": "^15.1.6", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@types/node": "^22.10.5", + "@types/react": "^19.0.2", + "@types/react-dom": "^19.0.2", + "autoprefixer": "^10.4.20", + "eslint": "^9.18.0", + "eslint-config-next": "^15.1.6", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.17", + "typescript": "^5.7.3" + } +} diff --git a/src/Web/autopilot-monitor-web/postcss.config.mjs b/src/Web/autopilot-monitor-web/postcss.config.mjs new file mode 100644 index 00000000..2ef30fcf --- /dev/null +++ b/src/Web/autopilot-monitor-web/postcss.config.mjs @@ -0,0 +1,9 @@ +/** @type {import('postcss-load-config').Config} */ +const config = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; + +export default config; diff --git a/src/Web/autopilot-monitor-web/tailwind.config.ts b/src/Web/autopilot-monitor-web/tailwind.config.ts new file mode 100644 index 00000000..d43da912 --- /dev/null +++ b/src/Web/autopilot-monitor-web/tailwind.config.ts @@ -0,0 +1,19 @@ +import type { Config } from "tailwindcss"; + +const config: Config = { + content: [ + "./pages/**/*.{js,ts,jsx,tsx,mdx}", + "./components/**/*.{js,ts,jsx,tsx,mdx}", + "./app/**/*.{js,ts,jsx,tsx,mdx}", + ], + theme: { + extend: { + colors: { + background: "var(--background)", + foreground: "var(--foreground)", + }, + }, + }, + plugins: [], +}; +export default config; diff --git a/src/Web/autopilot-monitor-web/tsconfig.json b/src/Web/autopilot-monitor-web/tsconfig.json new file mode 100644 index 00000000..d81d4ee1 --- /dev/null +++ b/src/Web/autopilot-monitor-web/tsconfig.json @@ -0,0 +1,40 @@ +{ + "compilerOptions": { + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": [ + "./*" + ] + }, + "target": "ES2017" + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts" + ], + "exclude": [ + "node_modules" + ] +}