mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 21:19:38 +00:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3392a23da6 | |||
| 5312216f6b | |||
| 72392f6f4c | |||
| d65157a092 | |||
| cf45911b69 | |||
| 1593232898 | |||
| e4c06a9564 | |||
| 9c50d98792 |
@@ -194,8 +194,8 @@ jobs:
|
||||
# Dev variant = literal-string substitution of the two URL/manifest defaults.
|
||||
# Both are uploaded only on -PublishAsStable runs (versioned namespace doesn't carry bootstrap).
|
||||
if ('${{ inputs.publish_as_stable }}' -eq 'true') {
|
||||
$stableUrl = '$AgentDownloadUrl = "https://autopilotmonitor.blob.core.windows.net/agent/AutopilotMonitor-Agent.zip"'
|
||||
$devUrl = '$AgentDownloadUrl = "https://autopilotmonitor.blob.core.windows.net/agent/AutopilotMonitor-Agent-dev.zip"'
|
||||
$stableUrl = '$AgentDownloadUrl = "https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent.zip"'
|
||||
$devUrl = '$AgentDownloadUrl = "https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent-dev.zip"'
|
||||
$stableMan = '$VersionJsonName = "version.json"'
|
||||
$devMan = '$VersionJsonName = "version-dev.json"'
|
||||
|
||||
|
||||
@@ -69,9 +69,21 @@ jobs:
|
||||
|
||||
- run: npm ci
|
||||
|
||||
# next-env.d.ts + .next type stubs are gitignored (Next-managed since
|
||||
# v16); typegen generates them so tsc sees the same types as a build.
|
||||
- name: Generate Next.js types
|
||||
run: npx next typegen
|
||||
|
||||
- name: Typecheck
|
||||
run: npx tsc --noEmit
|
||||
|
||||
# Errors fail outright. Warnings are ratcheted: the cap is the current
|
||||
# count of accepted warnings (React-Compiler hooks-rule findings demoted
|
||||
# in eslint.config.mjs + exhaustive-deps) — when you fix warnings, lower
|
||||
# the cap to match; never raise it.
|
||||
- name: Lint
|
||||
run: npm run lint -- --max-warnings 141
|
||||
|
||||
- name: Tests
|
||||
run: npm test
|
||||
|
||||
|
||||
@@ -53,6 +53,9 @@ __azurite_db*__.json
|
||||
node_modules/
|
||||
.next/
|
||||
out/
|
||||
# Managed by Next.js since v16 (regenerated by next dev/build/typegen; path
|
||||
# inside flips between .next/types and .next/dev/types) — must not be tracked.
|
||||
next-env.d.ts
|
||||
build/
|
||||
dist/
|
||||
!rules/dist/
|
||||
|
||||
@@ -1,369 +0,0 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Bootstrap script to deploy and start the Autopilot Monitor agent. (DEV)
|
||||
|
||||
.DESCRIPTION
|
||||
Designed to be deployed via Intune as a PowerShell Script during Autopilot.
|
||||
Runs very early in the enrollment process (first Intune action) and:
|
||||
1. Runs pre-flight guards to skip productive devices and ghost re-installs.
|
||||
An OOBE-relax exemption allows install when a single freshly-created profile
|
||||
appears DURING OOBE (the "Windows Backup for Organizations" restore case).
|
||||
2. Downloads the monitoring agent ZIP from Azure Blob Storage
|
||||
3. Verifies integrity via SHA-256 hash from the version manifest (mandatory)
|
||||
4. Extracts the agent into %ProgramData%\AutopilotMonitor\Agent
|
||||
5. Runs the agent in --install mode (registers Scheduled Task, spawns the runtime)
|
||||
6. Verifies the runtime process actually launched
|
||||
Agent self-destructs when enrollment completes.
|
||||
|
||||
OOBE-relax flow (guards 2+3): a real user profile (or a real LastLoggedOnUser)
|
||||
normally means "productive -> skip". Windows Backup for Organizations breaks that
|
||||
by restoring settings and creating ONE profile while still in OOBE. We relax guards
|
||||
2+3 ONLY when all three positives hold, and nothing else:
|
||||
(a) the OS positively reports OOBE InProgress (Test-OobeInProgress), AND
|
||||
(b) there is exactly ONE real user profile, AND
|
||||
(c) that profile was created within $OobeProfileMaxAgeMinutes minutes.
|
||||
Any miss keeps the original skip (SKIP-safe: we never install on uncertainty).
|
||||
|
||||
.PARAMETER AgentDownloadUrl
|
||||
URL to download the agent ZIP from. Defaults to the production blob; override only
|
||||
for parallel lab/dev assignments that point at a separate pre-release ZIP.
|
||||
|
||||
.PARAMETER VersionJsonName
|
||||
Filename of the integrity manifest in the same blob container as the agent ZIP.
|
||||
Defaults to "version.json"; override only for parallel lab/dev manifests.
|
||||
|
||||
.PARAMETER MaxBootstrapWindowHours
|
||||
Maximum device uptime (hours) within which the bootstrap is still considered
|
||||
valid. Devices booted more than this many hours ago are skipped because we no
|
||||
longer trust their OOBE state. Default: 12.
|
||||
|
||||
.PARAMETER OobeProfileMaxAgeMinutes
|
||||
Maximum age (minutes) of the single profile that may appear during OOBE for the
|
||||
OOBE-relax exemption to apply. The restore->bootstrap gap is always minutes; an
|
||||
older profile is treated as a productive device. Default: 15.
|
||||
|
||||
.NOTES
|
||||
- Agent is temporary and auto-removes after enrollment.
|
||||
- All files live under C:\ProgramData\AutopilotMonitor (easy cleanup).
|
||||
- One registry key remains after removal: HKLM\SOFTWARE\AutopilotMonitor\Deployed
|
||||
(prevents ghost re-installs on re-Autopilot of the same device).
|
||||
- Scheduled Task survives reboots during enrollment.
|
||||
- OOBE detection uses Windows.System.Profile.SystemSetupInfo.OutOfBoxExperienceState.
|
||||
Requires Windows 10, version 1809 (introduced in 10.0.17763.0). Older builds lack
|
||||
the class -> Test-OobeInProgress returns $false -> original (skip) behaviour. Docs:
|
||||
https://learn.microsoft.com/en-us/uwp/api/windows.system.profile.systemsetupinfo
|
||||
- This script MUST remain pure ASCII (no Unicode/UTF-8 special chars).
|
||||
PowerShell 5.1 (IME) reads scripts without BOM as ANSI, corrupting multi-byte chars.
|
||||
|
||||
.CHANGELOG
|
||||
2026-07-20 v2.2 Switched default download endpoint from the legacy blob
|
||||
(autopilotmonitor.blob.core.windows.net) to the Front Door alias
|
||||
https://download.autopilotmonitor.com/agent/ (route /agent/*).
|
||||
version.json is still derived from the same container.
|
||||
2026-06-25 v2.1 OOBE-relax: detect genuine OOBE via WinRT
|
||||
SystemSetupInfo.OutOfBoxExperienceState and exempt guards 2+3 when a
|
||||
single freshly-created profile appears in OOBE (Windows Backup for
|
||||
Organizations). SKIP-safe when the API is absent (< 1809).
|
||||
Removed legacy Content-MD5 fallback; SHA-256 manifest verification is
|
||||
now mandatory (no hash -> bootstrap aborts, no unverified install).
|
||||
2026-05-09 v2.0 Generic bootstrap: agent owns its own defaults (e.g. 600 s
|
||||
TenantId-wait), so the script only calls `--install` plain.
|
||||
Hardened post-install with a 10 s runtime-process verify.
|
||||
2026-04-09 v1.1 Introduced explicit script version, logged on startup.
|
||||
2026-03-31 Replaced OS age + MDM pre-flight checks with multi-signal guard
|
||||
(registry deployment marker, WMI/filesystem user profile, last
|
||||
logged-on user, 12 h bootstrap window).
|
||||
2026-03-30 Fixed non-ASCII characters that broke parsing under PowerShell 5.1.
|
||||
2026-03-29 Hardened integrity check: SHA-256 verification via version.json.
|
||||
2026-02-13 Simplified bootstrapper, introduced --install parameter for agent.
|
||||
2026-02-12 Robust download with integrity check (Content-MD5), boot time support,
|
||||
pre-flight check to skip if agent already installed.
|
||||
2026-02-05 Initial version.
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$AgentDownloadUrl = "https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent.zip",
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$VersionJsonName = "version.json",
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[int]$MaxBootstrapWindowHours = 12,
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[int]$OobeProfileMaxAgeMinutes = 15
|
||||
)
|
||||
|
||||
# Script version (bump on meaningful changes; see .CHANGELOG above)
|
||||
$ScriptVersion = "2.2"
|
||||
|
||||
# Configuration - Everything in ProgramData for easy cleanup
|
||||
$AgentBasePath = "$env:ProgramData\AutopilotMonitor"
|
||||
$AgentBinPath = "$AgentBasePath\Agent"
|
||||
$AgentLogPath = "$AgentBasePath\Logs"
|
||||
$LogFile = "$AgentLogPath\bootstrap_agent.log"
|
||||
|
||||
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 $AgentLogPath -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
|
||||
}
|
||||
|
||||
# Positive OOBE check via the documented WinRT API
|
||||
# Windows.System.Profile.SystemSetupInfo.OutOfBoxExperienceState.
|
||||
# Returns $true ONLY when the OS reports InProgress. Any other state (Completed/NotStarted),
|
||||
# an older OS without the class (< Win10 1809 / 10.0.17763.0), or any error returns $false
|
||||
# -> SKIP-safe (we never relax a guard on uncertainty).
|
||||
# Docs: https://learn.microsoft.com/en-us/uwp/api/windows.system.profile.systemsetupinfo
|
||||
function Test-OobeInProgress {
|
||||
try {
|
||||
$null = [Windows.System.Profile.SystemSetupInfo, Windows.System.Profile, ContentType = WindowsRuntime]
|
||||
return ([Windows.System.Profile.SystemSetupInfo]::OutOfBoxExperienceState).ToString() -eq 'InProgress'
|
||||
}
|
||||
catch {
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
Write-Log "===== Autopilot Monitor Bootstrap Started ====="
|
||||
Write-Log "Bootstrap script version: v$ScriptVersion"
|
||||
|
||||
# Guard 1: Agent was already deployed on this device (registry marker survives self-destruct)
|
||||
$deployed = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\AutopilotMonitor' -Name 'Deployed' -ErrorAction SilentlyContinue).Deployed
|
||||
if ($deployed) {
|
||||
Write-Log "SKIP: Agent was previously deployed at $deployed."
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Collect real (non-special) user profiles as full paths (WMI Special flag + filesystem).
|
||||
$excludePattern = '^(defaultuser\d*|Public|Default( User)?|All Users|WDAGUtilityAccount)$'
|
||||
|
||||
# NOTE: the outer @() is required. Without it the trailing Where/Select pipeline
|
||||
# unwraps a single result back to a scalar string, so $profilePaths[0] would index
|
||||
# into the string and return its first char ('C') instead of the full path.
|
||||
$profilePaths = @(
|
||||
@(
|
||||
try {
|
||||
Get-CimInstance Win32_UserProfile -ErrorAction Stop |
|
||||
Where-Object { -not $_.Special -and $_.LocalPath -like 'C:\Users\*' } |
|
||||
ForEach-Object { $_.LocalPath }
|
||||
} catch { Write-Log "INFO: WMI profile query failed, continuing with filesystem check." }
|
||||
|
||||
(Get-ChildItem 'C:\Users' -Directory -ErrorAction SilentlyContinue).FullName
|
||||
) | Where-Object { $_ -and (Split-Path $_ -Leaf) -notmatch $excludePattern } |
|
||||
Select-Object -Unique
|
||||
)
|
||||
|
||||
# OOBE-relax: the ONLY exemption to guards 2+3. Matches exactly the Windows Backup for
|
||||
# Organizations restore (one user signs in + restores DURING OOBE). All three must hold:
|
||||
# OOBE InProgress AND exactly one profile AND that profile created < N minutes ago.
|
||||
$oobeRelax = $false
|
||||
$oobeInProgress = Test-OobeInProgress
|
||||
if ($profilePaths.Count -eq 1 -and $oobeInProgress) {
|
||||
try {
|
||||
$created = (Get-Item -LiteralPath $profilePaths[0] -Force -ErrorAction Stop).CreationTimeUtc
|
||||
$ageMin = ((Get-Date).ToUniversalTime() - $created).TotalMinutes
|
||||
if ($ageMin -ge 0 -and $ageMin -lt $OobeProfileMaxAgeMinutes) {
|
||||
$oobeRelax = $true
|
||||
Write-Log ("OOBE-relax active: OOBE InProgress + single profile created {0:N1}min ago (< {1}min). Guards 2+3 relaxed (Windows Backup restore case)." -f $ageMin, $OobeProfileMaxAgeMinutes)
|
||||
} else {
|
||||
Write-Log ("OOBE-relax NOT applied: OOBE InProgress + single profile '{0}', but profile created {1:N1}min ago (created {2:u}) is outside the [0, {3})min window." -f (Split-Path $profilePaths[0] -Leaf), $ageMin, $created, $OobeProfileMaxAgeMinutes)
|
||||
}
|
||||
} catch { Write-Log "INFO: profile age check failed; OOBE-relax not applied. $($_.Exception.Message)" }
|
||||
} elseif ($profilePaths.Count -eq 1 -and -not $oobeInProgress) {
|
||||
Write-Log "OOBE-relax NOT applied: single profile present but OutOfBoxExperienceState != InProgress (OOBE already finished / not detectable)."
|
||||
} elseif ($profilePaths.Count -gt 1) {
|
||||
Write-Log ("OOBE-relax NOT applied: {0} real profiles present (relax requires exactly one)." -f $profilePaths.Count)
|
||||
}
|
||||
|
||||
# Guard 2: No real user profile should exist yet (primary productive-device guard),
|
||||
# unless the OOBE-relax signature holds.
|
||||
if ($profilePaths.Count -gt 0 -and -not $oobeRelax) {
|
||||
$details = (($profilePaths | ForEach-Object {
|
||||
$leaf = Split-Path $_ -Leaf
|
||||
try {
|
||||
$c = (Get-Item -LiteralPath $_ -Force -ErrorAction Stop).CreationTimeUtc
|
||||
"{0} (created {1:u}, {2:N1}min ago)" -f $leaf, $c, ((Get-Date).ToUniversalTime() - $c).TotalMinutes
|
||||
} catch { "$leaf (age unknown)" }
|
||||
}) | Select-Object -First 3) -join '; '
|
||||
Write-Log "SKIP: Real user profile(s) found ($details). OOBE InProgress=$oobeInProgress. Device appears productive."
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Guard 3: LastLoggedOnUser - during Device ESP no real user has logged on yet
|
||||
# (same OOBE-relax exemption: the restoring user signs in during OOBE).
|
||||
$lastLoggedOnUser = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\LogonUI' -Name 'LastLoggedOnUser' -EA SilentlyContinue).LastLoggedOnUser
|
||||
if ($lastLoggedOnUser -and $lastLoggedOnUser -notmatch 'defaultuser\d*' -and -not $oobeRelax) {
|
||||
Write-Log "SKIP: LastLoggedOnUser found ($lastLoggedOnUser). Device appears productive."
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Guard 4: Bootstrap window check - device uptime must be within accepted OOBE window.
|
||||
# Sleep/standby does not reset uptime, only real boot/restart does.
|
||||
$lastBoot = (Get-CimInstance Win32_OperatingSystem).LastBootUpTime
|
||||
$uptimeHours = ((Get-Date) - $lastBoot).TotalHours
|
||||
Write-Log "Device uptime: $([int]$uptimeHours)h (last boot: $lastBoot)"
|
||||
if ($uptimeHours -gt $MaxBootstrapWindowHours) {
|
||||
Write-Log "SKIP: Device uptime is $([int]$uptimeHours)h. OOBE state is older than accepted bootstrap window of ${MaxBootstrapWindowHours}h."
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Guard 5: Is the agent already installed? (leftover from previous run)
|
||||
if (Test-Path $AgentBinPath) {
|
||||
$existingAgent = Get-ChildItem -Path $AgentBinPath -Filter "AutopilotMonitor.Agent.exe" -ErrorAction SilentlyContinue
|
||||
if ($existingAgent) {
|
||||
Write-Log "SKIP: Agent already installed at $($existingAgent.FullName)."
|
||||
exit 0
|
||||
}
|
||||
}
|
||||
|
||||
Write-Log "Pre-flight checks passed -- no prior deployment, no productive-device signals, within bootstrap window (oobeRelax=$oobeRelax)"
|
||||
|
||||
# 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 {
|
||||
# Derive manifest URL from the agent download URL (same blob container)
|
||||
$versionJsonUrl = $AgentDownloadUrl -replace '[^/]+$', $VersionJsonName
|
||||
$expectedSha256 = $null
|
||||
$manifestAttempts = 3
|
||||
$manifestDelays = @(1, 2) # short backoff between attempts; do not block enrollment
|
||||
|
||||
for ($attempt = 1; $attempt -le $manifestAttempts; $attempt++) {
|
||||
try {
|
||||
Write-Log "Fetching $VersionJsonName (attempt ${attempt}/${manifestAttempts}) from $versionJsonUrl for integrity verification..."
|
||||
$versionJsonResponse = Invoke-RestMethod -Uri $versionJsonUrl -UseBasicParsing -TimeoutSec 5 -ErrorAction Stop
|
||||
if ($versionJsonResponse.sha256) {
|
||||
$expectedSha256 = $versionJsonResponse.sha256.ToLowerInvariant()
|
||||
Write-Log "SHA-256 hash from manifest: $expectedSha256 (version: $($versionJsonResponse.version))"
|
||||
} else {
|
||||
Write-Log "WARNING: Manifest has no sha256 field - integrity cannot be verified."
|
||||
}
|
||||
break # manifest reached (with or without hash); retrying would not help
|
||||
}
|
||||
catch {
|
||||
Write-Log "WARNING: Could not fetch integrity manifest (attempt $attempt): $($_.Exception.Message)"
|
||||
if ($attempt -lt $manifestAttempts) {
|
||||
Start-Sleep -Seconds $manifestDelays[$attempt - 1]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$zipPath = Join-Path $env:TEMP "AutopilotMonitor-Agent.zip"
|
||||
$maxDownloadAttempts = 3
|
||||
$downloadAttempt = 0
|
||||
|
||||
do {
|
||||
$downloadAttempt++
|
||||
try {
|
||||
Write-Log "Download attempt ${downloadAttempt}/${maxDownloadAttempts}"
|
||||
$downloadStopwatch = [System.Diagnostics.Stopwatch]::StartNew()
|
||||
Invoke-WebRequest `
|
||||
-Uri $AgentDownloadUrl `
|
||||
-OutFile $zipPath `
|
||||
-UseBasicParsing `
|
||||
-TimeoutSec 30 `
|
||||
-ErrorAction Stop
|
||||
$downloadStopwatch.Stop()
|
||||
$downloadSeconds = [math]::Round($downloadStopwatch.Elapsed.TotalSeconds, 1)
|
||||
Write-Log "Downloaded agent to $zipPath (took ${downloadSeconds}s)"
|
||||
break
|
||||
}
|
||||
catch {
|
||||
if ($downloadAttempt -ge $maxDownloadAttempts) {
|
||||
throw
|
||||
}
|
||||
|
||||
$retryDelaysInSeconds = @(2, 4, 8)
|
||||
$retryDelaySeconds = $retryDelaysInSeconds[$downloadAttempt - 1]
|
||||
Write-Log "Download failed (attempt $downloadAttempt): $($_.Exception.Message). Retrying in ${retryDelaySeconds}s..."
|
||||
Start-Sleep -Seconds $retryDelaySeconds
|
||||
}
|
||||
} while ($downloadAttempt -lt $maxDownloadAttempts)
|
||||
|
||||
# SHA-256 from the version manifest is the only accepted integrity proof.
|
||||
# No hash -> refuse to install an unverified ZIP (SKIP-safe security posture).
|
||||
if (-not $expectedSha256) {
|
||||
throw "No SHA-256 hash available from manifest - refusing to install unverified agent ZIP."
|
||||
}
|
||||
$actualSha256 = (Get-FileHash $zipPath -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
Write-Log "Validating SHA-256 hash against manifest"
|
||||
if ($actualSha256 -ne $expectedSha256) {
|
||||
throw "SHA-256 integrity check FAILED. Expected='$expectedSha256', Actual='$actualSha256'. Download may be tampered or corrupted."
|
||||
}
|
||||
Write-Log "SHA-256 integrity check passed"
|
||||
|
||||
Expand-Archive -Path $zipPath -DestinationPath $AgentBinPath -Force
|
||||
Write-Log "Extracted agent to $AgentBinPath"
|
||||
|
||||
Remove-Item $zipPath -Force -ErrorAction SilentlyContinue
|
||||
Write-Log "Cleaned up temporary files"
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
Write-Log "Calling agent install mode (--install)..."
|
||||
& $agentExePath --install
|
||||
$installExitCode = $LASTEXITCODE
|
||||
if ($installExitCode -ne 0) {
|
||||
throw "Agent install failed with exit code $installExitCode"
|
||||
}
|
||||
Write-Log "Agent install mode completed successfully"
|
||||
|
||||
$runtimeProcessName = 'AutopilotMonitor.Agent'
|
||||
$verifyTimeoutSec = 10
|
||||
$verifyDeadline = (Get-Date).AddSeconds($verifyTimeoutSec)
|
||||
$runtimeProc = $null
|
||||
while ((Get-Date) -lt $verifyDeadline) {
|
||||
$runtimeProc = Get-Process -Name $runtimeProcessName -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||
if ($null -ne $runtimeProc) { break }
|
||||
Start-Sleep -Milliseconds 500
|
||||
}
|
||||
if ($null -ne $runtimeProc) {
|
||||
$startedUtc = 'unavailable'
|
||||
try { $startedUtc = $runtimeProc.StartTime.ToUniversalTime().ToString('o') } catch { }
|
||||
Write-Log ("Runtime process verified: name={0}.exe pid={1} startedUtc={2}" -f $runtimeProcessName, $runtimeProc.Id, $startedUtc)
|
||||
} else {
|
||||
Write-Log ("WARNING: Runtime process verification FAILED. Agent --install reported success but no '{0}.exe' process appeared within {1}s. Likely silent block (AV/EDR, AppLocker/WDAC) of the runtime launch. Agent should still come up at next boot via the BootTrigger task. Check Event Viewer > Microsoft > Windows > TaskScheduler/Operational and AV/EDR logs for '{0}.exe'." -f $runtimeProcessName, $verifyTimeoutSec)
|
||||
}
|
||||
|
||||
Write-Log "===== Bootstrap Completed Successfully ====="
|
||||
|
||||
exit 0
|
||||
}
|
||||
catch {
|
||||
Write-Log "===== Bootstrap FAILED ====="
|
||||
Write-Log "ERROR: $($_.Exception.Message)"
|
||||
Write-Log "Stack trace: $($_.ScriptStackTrace)"
|
||||
Write-Log "Please check log file: $LogFile"
|
||||
|
||||
$errMsg = "AutopilotMonitor bootstrap failed: $($_.Exception.Message)"
|
||||
if ($errMsg.Length -gt 2048) { $errMsg = $errMsg.Substring(0, 1045) + '...' }
|
||||
[Console]::Error.WriteLine($errMsg)
|
||||
|
||||
exit 1
|
||||
}
|
||||
@@ -5,18 +5,28 @@
|
||||
.DESCRIPTION
|
||||
Designed to be deployed via Intune as a PowerShell Script during Autopilot.
|
||||
Runs very early in the enrollment process (first Intune action) and:
|
||||
1. Runs five pre-flight guards to skip productive devices and ghost re-installs
|
||||
1. Runs pre-flight guards to skip productive devices and ghost re-installs.
|
||||
An OOBE-relax exemption allows install when a single freshly-created profile
|
||||
appears DURING OOBE (the "Windows Backup for Organizations" restore case).
|
||||
2. Downloads the monitoring agent ZIP from Azure Blob Storage
|
||||
3. Verifies integrity via SHA-256 hash from the version manifest
|
||||
(legacy fallback: Content-MD5 header from the blob response)
|
||||
3. Verifies integrity via SHA-256 hash from the version manifest (mandatory)
|
||||
4. Extracts the agent into %ProgramData%\AutopilotMonitor\Agent
|
||||
5. Runs the agent in --install mode (registers Scheduled Task, spawns the runtime)
|
||||
6. Verifies the runtime process actually launched
|
||||
Agent self-destructs when enrollment completes.
|
||||
|
||||
OOBE-relax flow (guards 2+3): a real user profile (or a real LastLoggedOnUser)
|
||||
normally means "productive -> skip". Windows Backup for Organizations breaks that
|
||||
by restoring settings and creating ONE profile while still in OOBE. We relax guards
|
||||
2+3 ONLY when all three positives hold, and nothing else:
|
||||
(a) the OS positively reports OOBE InProgress (Test-OobeInProgress), AND
|
||||
(b) there is exactly ONE real user profile, AND
|
||||
(c) that profile was created within $OobeProfileMaxAgeMinutes minutes.
|
||||
Any miss keeps the original skip (SKIP-safe: we never install on uncertainty).
|
||||
|
||||
.PARAMETER AgentDownloadUrl
|
||||
URL to download the agent ZIP from. Defaults to the production blob; override only
|
||||
for parallel lab/dev assignments that point at a separate pre-release ZIP.
|
||||
URL to download the agent ZIP from. Defaults to the production download endpoint;
|
||||
override only for parallel lab/dev assignments that point at a separate pre-release ZIP.
|
||||
|
||||
.PARAMETER VersionJsonName
|
||||
Filename of the integrity manifest in the same blob container as the agent ZIP.
|
||||
@@ -27,16 +37,35 @@
|
||||
valid. Devices booted more than this many hours ago are skipped because we no
|
||||
longer trust their OOBE state. Default: 12.
|
||||
|
||||
.PARAMETER OobeProfileMaxAgeMinutes
|
||||
Maximum age (minutes) of the single profile that may appear during OOBE for the
|
||||
OOBE-relax exemption to apply. The restore->bootstrap gap is always minutes; an
|
||||
older profile is treated as a productive device. Default: 15.
|
||||
|
||||
.NOTES
|
||||
- Agent is temporary and auto-removes after enrollment.
|
||||
- All files live under C:\ProgramData\AutopilotMonitor (easy cleanup).
|
||||
- One registry key remains after removal: HKLM\SOFTWARE\AutopilotMonitor\Deployed
|
||||
(prevents ghost re-installs on re-Autopilot of the same device).
|
||||
- Scheduled Task survives reboots during enrollment.
|
||||
- OOBE detection uses Windows.System.Profile.SystemSetupInfo.OutOfBoxExperienceState.
|
||||
Requires Windows 10, version 1809 (introduced in 10.0.17763.0). Older builds lack
|
||||
the class -> Test-OobeInProgress returns $false -> original (skip) behaviour. Docs:
|
||||
https://learn.microsoft.com/en-us/uwp/api/windows.system.profile.systemsetupinfo
|
||||
- This script MUST remain pure ASCII (no Unicode/UTF-8 special chars).
|
||||
PowerShell 5.1 (IME) reads scripts without BOM as ANSI, corrupting multi-byte chars.
|
||||
|
||||
.CHANGELOG
|
||||
2026-07-20 v2.2 Switched default download endpoint from the legacy blob
|
||||
(autopilotmonitor.blob.core.windows.net) to the Front Door alias
|
||||
https://download.autopilotmonitor.com/agent/ (route /agent/*).
|
||||
version.json is still derived from the same container.
|
||||
2026-06-25 v2.1 OOBE-relax: detect genuine OOBE via WinRT
|
||||
SystemSetupInfo.OutOfBoxExperienceState and exempt guards 2+3 when a
|
||||
single freshly-created profile appears in OOBE (Windows Backup for
|
||||
Organizations). SKIP-safe when the API is absent (< 1809).
|
||||
Removed legacy Content-MD5 fallback; SHA-256 manifest verification is
|
||||
now mandatory (no hash -> bootstrap aborts, no unverified install).
|
||||
2026-05-09 v2.0 Generic bootstrap: agent owns its own defaults (e.g. 600 s
|
||||
TenantId-wait), so the script only calls `--install` plain.
|
||||
Hardened post-install with a 10 s runtime-process verify.
|
||||
@@ -55,17 +84,20 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$AgentDownloadUrl = "https://autopilotmonitor.blob.core.windows.net/agent/AutopilotMonitor-Agent.zip",
|
||||
[string]$AgentDownloadUrl = "https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent.zip",
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$VersionJsonName = "version.json",
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[int]$MaxBootstrapWindowHours = 12
|
||||
[int]$MaxBootstrapWindowHours = 12,
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[int]$OobeProfileMaxAgeMinutes = 15
|
||||
)
|
||||
|
||||
# Script version (bump on meaningful changes; see .CHANGELOG above)
|
||||
$ScriptVersion = "2.0"
|
||||
$ScriptVersion = "2.2"
|
||||
|
||||
# Configuration - Everything in ProgramData for easy cleanup
|
||||
$AgentBasePath = "$env:ProgramData\AutopilotMonitor"
|
||||
@@ -85,26 +117,19 @@ function Write-Log {
|
||||
Add-Content -Path $LogFile -Value $logMessage -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
function Get-FileMd5Base64 {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Path
|
||||
)
|
||||
|
||||
$md5 = [System.Security.Cryptography.MD5]::Create()
|
||||
# Positive OOBE check via the documented WinRT API
|
||||
# Windows.System.Profile.SystemSetupInfo.OutOfBoxExperienceState.
|
||||
# Returns $true ONLY when the OS reports InProgress. Any other state (Completed/NotStarted),
|
||||
# an older OS without the class (< Win10 1809 / 10.0.17763.0), or any error returns $false
|
||||
# -> SKIP-safe (we never relax a guard on uncertainty).
|
||||
# Docs: https://learn.microsoft.com/en-us/uwp/api/windows.system.profile.systemsetupinfo
|
||||
function Test-OobeInProgress {
|
||||
try {
|
||||
$stream = [System.IO.File]::OpenRead($Path)
|
||||
try {
|
||||
$hashBytes = $md5.ComputeHash($stream)
|
||||
}
|
||||
finally {
|
||||
$stream.Dispose()
|
||||
}
|
||||
|
||||
return [Convert]::ToBase64String($hashBytes)
|
||||
$null = [Windows.System.Profile.SystemSetupInfo, Windows.System.Profile, ContentType = WindowsRuntime]
|
||||
return ([Windows.System.Profile.SystemSetupInfo]::OutOfBoxExperienceState).ToString() -eq 'InProgress'
|
||||
}
|
||||
finally {
|
||||
$md5.Dispose()
|
||||
catch {
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,30 +144,65 @@ try {
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Guard 2: No real user profile should exist yet (primary productive-device guard).
|
||||
# Combines WMI (Win32_UserProfile.Special flag) and filesystem for maximum reliability.
|
||||
$excludePattern = '^(defaultuser\d*|Public|Default( User)?|All Users)$'
|
||||
# Collect real (non-special) user profiles as full paths (WMI Special flag + filesystem).
|
||||
$excludePattern = '^(defaultuser\d*|Public|Default( User)?|All Users|WDAGUtilityAccount)$'
|
||||
|
||||
$profileNames = @(
|
||||
# NOTE: the outer @() is required. Without it the trailing Where/Select pipeline
|
||||
# unwraps a single result back to a scalar string, so $profilePaths[0] would index
|
||||
# into the string and return its first char ('C') instead of the full path.
|
||||
$profilePaths = @(
|
||||
@(
|
||||
try {
|
||||
Get-CimInstance Win32_UserProfile -ErrorAction Stop |
|
||||
Where-Object { -not $_.Special -and $_.LocalPath -like 'C:\Users\*' } |
|
||||
ForEach-Object { $_.LocalPath }
|
||||
} catch { Write-Log "INFO: WMI profile query failed, continuing with filesystem check." }
|
||||
|
||||
(Get-ChildItem 'C:\Users' -Directory -ErrorAction SilentlyContinue).FullName
|
||||
) | Where-Object { $_ -and (Split-Path $_ -Leaf) -notmatch $excludePattern } |
|
||||
Select-Object -Unique
|
||||
)
|
||||
|
||||
# OOBE-relax: the ONLY exemption to guards 2+3. Matches exactly the Windows Backup for
|
||||
# Organizations restore (one user signs in + restores DURING OOBE). All three must hold:
|
||||
# OOBE InProgress AND exactly one profile AND that profile created < N minutes ago.
|
||||
$oobeRelax = $false
|
||||
$oobeInProgress = Test-OobeInProgress
|
||||
if ($profilePaths.Count -eq 1 -and $oobeInProgress) {
|
||||
try {
|
||||
Get-CimInstance Win32_UserProfile -ErrorAction Stop |
|
||||
Where-Object { -not $_.Special -and $_.LocalPath -like 'C:\Users\*' } |
|
||||
ForEach-Object { Split-Path $_.LocalPath -Leaf }
|
||||
} catch { Write-Log "INFO: WMI profile query failed, continuing with filesystem check." }
|
||||
$created = (Get-Item -LiteralPath $profilePaths[0] -Force -ErrorAction Stop).CreationTimeUtc
|
||||
$ageMin = ((Get-Date).ToUniversalTime() - $created).TotalMinutes
|
||||
if ($ageMin -ge 0 -and $ageMin -lt $OobeProfileMaxAgeMinutes) {
|
||||
$oobeRelax = $true
|
||||
Write-Log ("OOBE-relax active: OOBE InProgress + single profile created {0:N1}min ago (< {1}min). Guards 2+3 relaxed (Windows Backup restore case)." -f $ageMin, $OobeProfileMaxAgeMinutes)
|
||||
} else {
|
||||
Write-Log ("OOBE-relax NOT applied: OOBE InProgress + single profile '{0}', but profile created {1:N1}min ago (created {2:u}) is outside the [0, {3})min window." -f (Split-Path $profilePaths[0] -Leaf), $ageMin, $created, $OobeProfileMaxAgeMinutes)
|
||||
}
|
||||
} catch { Write-Log "INFO: profile age check failed; OOBE-relax not applied. $($_.Exception.Message)" }
|
||||
} elseif ($profilePaths.Count -eq 1 -and -not $oobeInProgress) {
|
||||
Write-Log "OOBE-relax NOT applied: single profile present but OutOfBoxExperienceState != InProgress (OOBE already finished / not detectable)."
|
||||
} elseif ($profilePaths.Count -gt 1) {
|
||||
Write-Log ("OOBE-relax NOT applied: {0} real profiles present (relax requires exactly one)." -f $profilePaths.Count)
|
||||
}
|
||||
|
||||
(Get-ChildItem 'C:\Users' -Directory -ErrorAction SilentlyContinue).Name
|
||||
) | Where-Object { $_ -and $_ -notmatch $excludePattern } |
|
||||
Select-Object -Unique
|
||||
|
||||
if ($profileNames) {
|
||||
$names = ($profileNames | Select-Object -First 3) -join ', '
|
||||
Write-Log "SKIP: Real user profile(s) found ($names). Device appears productive."
|
||||
# Guard 2: No real user profile should exist yet (primary productive-device guard),
|
||||
# unless the OOBE-relax signature holds.
|
||||
if ($profilePaths.Count -gt 0 -and -not $oobeRelax) {
|
||||
$details = (($profilePaths | ForEach-Object {
|
||||
$leaf = Split-Path $_ -Leaf
|
||||
try {
|
||||
$c = (Get-Item -LiteralPath $_ -Force -ErrorAction Stop).CreationTimeUtc
|
||||
"{0} (created {1:u}, {2:N1}min ago)" -f $leaf, $c, ((Get-Date).ToUniversalTime() - $c).TotalMinutes
|
||||
} catch { "$leaf (age unknown)" }
|
||||
}) | Select-Object -First 3) -join '; '
|
||||
Write-Log "SKIP: Real user profile(s) found ($details). OOBE InProgress=$oobeInProgress. Device appears productive."
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Guard 3: LastLoggedOnUser - during Device ESP no real user has logged on yet.
|
||||
# Guard 3: LastLoggedOnUser - during Device ESP no real user has logged on yet
|
||||
# (same OOBE-relax exemption: the restoring user signs in during OOBE).
|
||||
$lastLoggedOnUser = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\LogonUI' -Name 'LastLoggedOnUser' -EA SilentlyContinue).LastLoggedOnUser
|
||||
if ($lastLoggedOnUser -and $lastLoggedOnUser -notmatch 'defaultuser\d*') {
|
||||
if ($lastLoggedOnUser -and $lastLoggedOnUser -notmatch 'defaultuser\d*' -and -not $oobeRelax) {
|
||||
Write-Log "SKIP: LastLoggedOnUser found ($lastLoggedOnUser). Device appears productive."
|
||||
exit 0
|
||||
}
|
||||
@@ -166,7 +226,7 @@ try {
|
||||
}
|
||||
}
|
||||
|
||||
Write-Log "Pre-flight checks passed -- no prior deployment, no real user profiles, no logged-on user, within bootstrap window"
|
||||
Write-Log "Pre-flight checks passed -- no prior deployment, no productive-device signals, within bootstrap window (oobeRelax=$oobeRelax)"
|
||||
|
||||
# Download and extract agent binaries
|
||||
$agentExePath = Join-Path $AgentBinPath "AutopilotMonitor.Agent.exe"
|
||||
@@ -181,38 +241,47 @@ try {
|
||||
# Derive manifest URL from the agent download URL (same blob container)
|
||||
$versionJsonUrl = $AgentDownloadUrl -replace '[^/]+$', $VersionJsonName
|
||||
$expectedSha256 = $null
|
||||
$manifestAttempts = 3
|
||||
$manifestDelays = @(1, 2) # short backoff between attempts; do not block enrollment
|
||||
|
||||
try {
|
||||
Write-Log "Fetching $VersionJsonName from $versionJsonUrl for integrity verification..."
|
||||
$versionJsonResponse = Invoke-RestMethod -Uri $versionJsonUrl -UseBasicParsing -TimeoutSec 5 -ErrorAction Stop
|
||||
if ($versionJsonResponse.sha256) {
|
||||
$expectedSha256 = $versionJsonResponse.sha256.ToLowerInvariant()
|
||||
Write-Log "SHA-256 hash from manifest: $expectedSha256 (version: $($versionJsonResponse.version))"
|
||||
} else {
|
||||
Write-Log "Manifest has no sha256 field - falling back to legacy MD5 check (older build)"
|
||||
for ($attempt = 1; $attempt -le $manifestAttempts; $attempt++) {
|
||||
try {
|
||||
Write-Log "Fetching $VersionJsonName (attempt ${attempt}/${manifestAttempts}) from $versionJsonUrl for integrity verification..."
|
||||
$versionJsonResponse = Invoke-RestMethod -Uri $versionJsonUrl -UseBasicParsing -TimeoutSec 5 -ErrorAction Stop
|
||||
if ($versionJsonResponse.sha256) {
|
||||
$expectedSha256 = $versionJsonResponse.sha256.ToLowerInvariant()
|
||||
Write-Log "SHA-256 hash from manifest: $expectedSha256 (version: $($versionJsonResponse.version))"
|
||||
} else {
|
||||
Write-Log "WARNING: Manifest has no sha256 field - integrity cannot be verified."
|
||||
}
|
||||
break # manifest reached (with or without hash); retrying would not help
|
||||
}
|
||||
catch {
|
||||
Write-Log "WARNING: Could not fetch integrity manifest (attempt $attempt): $($_.Exception.Message)"
|
||||
if ($attempt -lt $manifestAttempts) {
|
||||
Start-Sleep -Seconds $manifestDelays[$attempt - 1]
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Log "WARNING: Could not fetch manifest - falling back to legacy MD5 check: $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
$zipPath = Join-Path $env:TEMP "AutopilotMonitor-Agent.zip"
|
||||
$maxDownloadAttempts = 3
|
||||
$downloadAttempt = 0
|
||||
$downloadResponse = $null
|
||||
|
||||
do {
|
||||
$downloadAttempt++
|
||||
try {
|
||||
Write-Log "Download attempt ${downloadAttempt}/${maxDownloadAttempts}"
|
||||
$downloadResponse = Invoke-WebRequest `
|
||||
$downloadStopwatch = [System.Diagnostics.Stopwatch]::StartNew()
|
||||
Invoke-WebRequest `
|
||||
-Uri $AgentDownloadUrl `
|
||||
-OutFile $zipPath `
|
||||
-UseBasicParsing `
|
||||
-TimeoutSec 30 `
|
||||
-ErrorAction Stop `
|
||||
-PassThru
|
||||
Write-Log "Downloaded agent to $zipPath"
|
||||
-ErrorAction Stop
|
||||
$downloadStopwatch.Stop()
|
||||
$downloadSeconds = [math]::Round($downloadStopwatch.Elapsed.TotalSeconds, 1)
|
||||
Write-Log "Downloaded agent to $zipPath (took ${downloadSeconds}s)"
|
||||
break
|
||||
}
|
||||
catch {
|
||||
@@ -227,29 +296,17 @@ try {
|
||||
}
|
||||
} while ($downloadAttempt -lt $maxDownloadAttempts)
|
||||
|
||||
if ($expectedSha256) {
|
||||
$actualSha256 = (Get-FileHash $zipPath -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
Write-Log "Validating SHA-256 hash against manifest"
|
||||
if ($actualSha256 -ne $expectedSha256) {
|
||||
throw "SHA-256 integrity check FAILED. Expected='$expectedSha256', Actual='$actualSha256'. Download may be tampered or corrupted."
|
||||
}
|
||||
Write-Log "SHA-256 integrity check passed"
|
||||
# SHA-256 from the version manifest is the only accepted integrity proof.
|
||||
# No hash -> refuse to install an unverified ZIP (SKIP-safe security posture).
|
||||
if (-not $expectedSha256) {
|
||||
throw "No SHA-256 hash available from manifest - refusing to install unverified agent ZIP."
|
||||
}
|
||||
else {
|
||||
$expectedMd5Header = $downloadResponse.Headers["Content-MD5"]
|
||||
$expectedMd5 = if ($expectedMd5Header -is [System.Array]) { "$($expectedMd5Header[0])".Trim() } else { "$expectedMd5Header".Trim() }
|
||||
if ($expectedMd5 -notmatch '\S') {
|
||||
Write-Log "WARNING: No SHA-256 and no Content-MD5 header - skipping integrity validation"
|
||||
}
|
||||
else {
|
||||
$actualMd5 = Get-FileMd5Base64 -Path $zipPath
|
||||
Write-Log "Validating Content-MD5 header against downloaded ZIP"
|
||||
if ($actualMd5 -ne $expectedMd5) {
|
||||
throw "MD5 integrity check failed. Expected (Content-MD5)='$expectedMd5', Actual='$actualMd5'"
|
||||
}
|
||||
Write-Log "MD5 integrity check passed"
|
||||
}
|
||||
$actualSha256 = (Get-FileHash $zipPath -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
Write-Log "Validating SHA-256 hash against manifest"
|
||||
if ($actualSha256 -ne $expectedSha256) {
|
||||
throw "SHA-256 integrity check FAILED. Expected='$expectedSha256', Actual='$actualSha256'. Download may be tampered or corrupted."
|
||||
}
|
||||
Write-Log "SHA-256 integrity check passed"
|
||||
|
||||
Expand-Archive -Path $zipPath -DestinationPath $AgentBinPath -Force
|
||||
Write-Log "Extracted agent to $AgentBinPath"
|
||||
|
||||
@@ -1,213 +0,0 @@
|
||||
# Autopilot Monitor Bootstrap Guard Test (DEV)
|
||||
# Dry-run only: does not exit, does not install, only reports what WOULD happen.
|
||||
# Mirrors Install-AutopilotMonitor-dev.ps1 incl. the OOBE-relax exemption for the
|
||||
# "Windows Backup for Organizations" restore case (guards 2+3).
|
||||
|
||||
param(
|
||||
[string]$AgentBinPath = 'C:\ProgramData\AutopilotMonitor\Agent',
|
||||
[int]$OobeProfileMaxAgeMinutes = 15
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'SilentlyContinue'
|
||||
|
||||
function Write-Step {
|
||||
param(
|
||||
[string]$Status,
|
||||
[string]$Message
|
||||
)
|
||||
|
||||
$prefix = switch ($Status) {
|
||||
'PASS' { '[PASS]' }
|
||||
'SKIP' { '[SKIP]' }
|
||||
'WARN' { '[WARN]' }
|
||||
'INFO' { '[INFO]' }
|
||||
default { '[....]' }
|
||||
}
|
||||
|
||||
Write-Host "$prefix $Message"
|
||||
}
|
||||
|
||||
function Get-RegistryValueSafe {
|
||||
param(
|
||||
[string]$Path,
|
||||
[string]$Name
|
||||
)
|
||||
|
||||
try {
|
||||
return (Get-ItemProperty -Path $Path -Name $Name -ErrorAction Stop).$Name
|
||||
}
|
||||
catch {
|
||||
return $null
|
||||
}
|
||||
}
|
||||
|
||||
# Positive OOBE check via WinRT Windows.System.Profile.SystemSetupInfo.OutOfBoxExperienceState.
|
||||
# Requires Windows 10, version 1809 (10.0.17763.0). Older builds / errors -> $false (SKIP-safe).
|
||||
# Docs: https://learn.microsoft.com/en-us/uwp/api/windows.system.profile.systemsetupinfo
|
||||
function Test-OobeInProgress {
|
||||
try {
|
||||
$null = [Windows.System.Profile.SystemSetupInfo, Windows.System.Profile, ContentType = WindowsRuntime]
|
||||
return ([Windows.System.Profile.SystemSetupInfo]::OutOfBoxExperienceState).ToString() -eq 'InProgress'
|
||||
}
|
||||
catch {
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
$AgentExePath = Join-Path $AgentBinPath 'AutopilotMonitor.Agent.exe'
|
||||
$MaxBootstrapWindowHours = 12
|
||||
|
||||
$wouldInstall = $true
|
||||
$reasons = New-Object System.Collections.Generic.List[string]
|
||||
|
||||
Write-Host ''
|
||||
Write-Host '=== Autopilot Monitor Bootstrap Guard Test (Dry Run, DEV) ==='
|
||||
Write-Host ''
|
||||
|
||||
# Guard 1: Agent was already deployed on this device (survives self-destruct)
|
||||
$deployed = Get-RegistryValueSafe -Path 'HKLM:\SOFTWARE\AutopilotMonitor' -Name 'Deployed'
|
||||
if ($deployed) {
|
||||
Write-Step -Status 'SKIP' -Message "Guard 1: Agent was previously deployed at '$deployed'."
|
||||
$wouldInstall = $false
|
||||
$reasons.Add("Previously deployed marker exists: $deployed")
|
||||
} else {
|
||||
Write-Step -Status 'PASS' -Message 'Guard 1: No previous deployment marker found.'
|
||||
}
|
||||
|
||||
# Collect real (non-special) user profiles as full paths (combined WMI/filesystem view).
|
||||
$excludePattern = '^(defaultuser\d*|Public|Default( User)?|All Users|WDAGUtilityAccount)$'
|
||||
$wmiProfileQueryFailed = $false
|
||||
|
||||
# NOTE: the outer @() is required. Without it the trailing Where/Select pipeline
|
||||
# unwraps a single result back to a scalar string, so $profilePaths[0] would index
|
||||
# into the string and return its first char ('C') instead of the full path.
|
||||
$profilePaths = @(
|
||||
@(
|
||||
try {
|
||||
Get-CimInstance Win32_UserProfile -ErrorAction Stop |
|
||||
Where-Object { -not $_.Special -and $_.LocalPath -like 'C:\Users\*' } |
|
||||
ForEach-Object { $_.LocalPath }
|
||||
}
|
||||
catch {
|
||||
$wmiProfileQueryFailed = $true
|
||||
}
|
||||
|
||||
(Get-ChildItem 'C:\Users' -Directory -ErrorAction SilentlyContinue).FullName
|
||||
) |
|
||||
Where-Object { $_ -and (Split-Path $_ -Leaf) -notmatch $excludePattern } |
|
||||
Select-Object -Unique
|
||||
)
|
||||
|
||||
if ($wmiProfileQueryFailed) {
|
||||
Write-Step -Status 'WARN' -Message 'Profiles: WMI query failed, filesystem check still applied.'
|
||||
}
|
||||
|
||||
$profileNames = $profilePaths | ForEach-Object { Split-Path $_ -Leaf }
|
||||
|
||||
# OOBE-relax: the ONLY exemption to guards 2+3. Matches exactly the Windows Backup for
|
||||
# Organizations restore: OOBE InProgress AND exactly one profile AND that profile fresh.
|
||||
$oobeInProgress = Test-OobeInProgress
|
||||
$profileAgeMin = $null
|
||||
$oobeRelax = $false
|
||||
if ($profilePaths.Count -eq 1) {
|
||||
try {
|
||||
$created = (Get-Item -LiteralPath $profilePaths[0] -Force -ErrorAction Stop).CreationTimeUtc
|
||||
$profileAgeMin = [math]::Round(((Get-Date).ToUniversalTime() - $created).TotalMinutes, 1)
|
||||
} catch { }
|
||||
if ($oobeInProgress -and $null -ne $profileAgeMin -and $profileAgeMin -ge 0 -and $profileAgeMin -lt $OobeProfileMaxAgeMinutes) {
|
||||
$oobeRelax = $true
|
||||
}
|
||||
}
|
||||
|
||||
Write-Step -Status 'INFO' -Message "OOBE: OutOfBoxExperienceState InProgress = $oobeInProgress ; profileCount = $($profilePaths.Count) ; singleProfileAgeMin = $profileAgeMin ; OOBE-relax = $oobeRelax"
|
||||
|
||||
# Guard 2: No real user profile should exist yet (unless OOBE-relax holds)
|
||||
if ($profilePaths.Count -gt 0 -and -not $oobeRelax) {
|
||||
$names = ($profileNames | Select-Object -First 3) -join ', '
|
||||
Write-Step -Status 'SKIP' -Message "Guard 2: Real user profile(s) found ($names). Device appears productive."
|
||||
$wouldInstall = $false
|
||||
$reasons.Add("Real user profile(s) found: $names")
|
||||
} elseif ($profilePaths.Count -gt 0 -and $oobeRelax) {
|
||||
$names = ($profileNames | Select-Object -First 3) -join ', '
|
||||
Write-Step -Status 'PASS' -Message "Guard 2: Profile ($names) present but OOBE-relax active (single fresh profile in OOBE) -- not blocking."
|
||||
} else {
|
||||
Write-Step -Status 'PASS' -Message 'Guard 2: No real user profiles found (combined WMI/filesystem view).'
|
||||
}
|
||||
|
||||
# Guard 3: LastLoggedOnUser (unless OOBE-relax holds)
|
||||
$lastLoggedOnUser = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\LogonUI' -Name 'LastLoggedOnUser' -EA SilentlyContinue).LastLoggedOnUser
|
||||
if ($lastLoggedOnUser -and $lastLoggedOnUser -notmatch 'defaultuser\d*' -and -not $oobeRelax) {
|
||||
Write-Step -Status 'SKIP' -Message "Guard 3: LastLoggedOnUser found ($lastLoggedOnUser). Device appears productive."
|
||||
$wouldInstall = $false
|
||||
$reasons.Add("LastLoggedOnUser found: $lastLoggedOnUser")
|
||||
} elseif ($lastLoggedOnUser -and $lastLoggedOnUser -notmatch 'defaultuser\d*' -and $oobeRelax) {
|
||||
Write-Step -Status 'PASS' -Message "Guard 3: LastLoggedOnUser ($lastLoggedOnUser) present but OOBE-relax active -- not blocking."
|
||||
} else {
|
||||
Write-Step -Status 'PASS' -Message "Guard 3: No real LastLoggedOnUser (value: '$lastLoggedOnUser')."
|
||||
}
|
||||
|
||||
# Guard 4: Bootstrap window check (sleep/standby does NOT reset uptime, only real boot does)
|
||||
$lastBoot = $null
|
||||
$uptimeHours = $null
|
||||
|
||||
try {
|
||||
$lastBoot = (Get-CimInstance Win32_OperatingSystem -ErrorAction Stop).LastBootUpTime
|
||||
$uptimeHours = ((Get-Date) - $lastBoot).TotalHours
|
||||
|
||||
if ($uptimeHours -gt $MaxBootstrapWindowHours) {
|
||||
Write-Step -Status 'SKIP' -Message "Guard 4: Device uptime is $([int]$uptimeHours)h. Older than accepted bootstrap window of ${MaxBootstrapWindowHours}h."
|
||||
$wouldInstall = $false
|
||||
$reasons.Add("Uptime exceeds bootstrap window: $([int]$uptimeHours)h > ${MaxBootstrapWindowHours}h")
|
||||
} else {
|
||||
Write-Step -Status 'PASS' -Message "Guard 4: Device uptime is $([int]$uptimeHours)h and within bootstrap window of ${MaxBootstrapWindowHours}h."
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Step -Status 'WARN' -Message 'Guard 4: Could not determine LastBootUpTime / uptime.'
|
||||
}
|
||||
|
||||
# Guard 5: Agent binary already present
|
||||
if (Test-Path $AgentExePath) {
|
||||
Write-Step -Status 'SKIP' -Message "Guard 5: Agent already installed at '$AgentExePath'."
|
||||
$wouldInstall = $false
|
||||
$reasons.Add("Agent binary already present: $AgentExePath")
|
||||
} else {
|
||||
Write-Step -Status 'PASS' -Message "Guard 5: Agent binary not present at '$AgentExePath'."
|
||||
}
|
||||
|
||||
Write-Host ''
|
||||
Write-Host '=== Result ==='
|
||||
Write-Host ''
|
||||
|
||||
if ($wouldInstall) {
|
||||
Write-Host '[DECISION] WOULD INSTALL agent on this device.'
|
||||
} else {
|
||||
Write-Host '[DECISION] WOULD SKIP agent installation on this device.'
|
||||
}
|
||||
|
||||
Write-Host ''
|
||||
Write-Host '=== Summary ==='
|
||||
|
||||
if ($reasons.Count -eq 0) {
|
||||
Write-Host 'No blocking reasons found.'
|
||||
} else {
|
||||
foreach ($reason in $reasons) {
|
||||
Write-Host " - $reason"
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host ''
|
||||
Write-Host '=== Raw Values ==='
|
||||
Write-Host "Deployed marker : $deployed"
|
||||
Write-Host "DetectedProfileNames : $($profileNames -join ', ')"
|
||||
Write-Host "ProfileCount : $($profilePaths.Count)"
|
||||
Write-Host "OobeInProgress (WinRT) : $oobeInProgress"
|
||||
Write-Host "SingleProfileAgeMinutes : $profileAgeMin"
|
||||
Write-Host "OobeProfileMaxAgeMinutes : $OobeProfileMaxAgeMinutes"
|
||||
Write-Host "OobeRelaxActive : $oobeRelax"
|
||||
Write-Host "LastLoggedOnUser : $lastLoggedOnUser"
|
||||
Write-Host "MaxBootstrapWindowHours : $MaxBootstrapWindowHours"
|
||||
Write-Host "LastBootUpTime : $lastBoot"
|
||||
Write-Host "UptimeHours : $([string]$uptimeHours)"
|
||||
Write-Host "AgentExePath : $AgentExePath"
|
||||
Write-Host ''
|
||||
@@ -1,8 +1,11 @@
|
||||
# Autopilot Monitor Bootstrap Guard Test
|
||||
# Dry-run only: does not exit, does not install, only reports what WOULD happen.
|
||||
# Mirrors Install-AutopilotMonitor.ps1 incl. the OOBE-relax exemption for the
|
||||
# "Windows Backup for Organizations" restore case (guards 2+3).
|
||||
|
||||
param(
|
||||
[string]$AgentBinPath = 'C:\ProgramData\AutopilotMonitor\Agent'
|
||||
[string]$AgentBinPath = 'C:\ProgramData\AutopilotMonitor\Agent',
|
||||
[int]$OobeProfileMaxAgeMinutes = 15
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'SilentlyContinue'
|
||||
@@ -38,6 +41,19 @@ function Get-RegistryValueSafe {
|
||||
}
|
||||
}
|
||||
|
||||
# Positive OOBE check via WinRT Windows.System.Profile.SystemSetupInfo.OutOfBoxExperienceState.
|
||||
# Requires Windows 10, version 1809 (10.0.17763.0). Older builds / errors -> $false (SKIP-safe).
|
||||
# Docs: https://learn.microsoft.com/en-us/uwp/api/windows.system.profile.systemsetupinfo
|
||||
function Test-OobeInProgress {
|
||||
try {
|
||||
$null = [Windows.System.Profile.SystemSetupInfo, Windows.System.Profile, ContentType = WindowsRuntime]
|
||||
return ([Windows.System.Profile.SystemSetupInfo]::OutOfBoxExperienceState).ToString() -eq 'InProgress'
|
||||
}
|
||||
catch {
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
$AgentExePath = Join-Path $AgentBinPath 'AutopilotMonitor.Agent.exe'
|
||||
$MaxBootstrapWindowHours = 12
|
||||
|
||||
@@ -58,63 +74,79 @@ if ($deployed) {
|
||||
Write-Step -Status 'PASS' -Message 'Guard 1: No previous deployment marker found.'
|
||||
}
|
||||
|
||||
# Guard 2: No real user profile should exist yet (primary productive-device guard)
|
||||
# NOTE: OOBEInProgress is NOT used -- it is unreliable (observed =0 during active enrollment).
|
||||
$excludePattern = '^(defaultuser\d*|Public|Default( User)?|All Users)$'
|
||||
# Collect real (non-special) user profiles as full paths (combined WMI/filesystem view).
|
||||
$excludePattern = '^(defaultuser\d*|Public|Default( User)?|All Users|WDAGUtilityAccount)$'
|
||||
$wmiProfileQueryFailed = $false
|
||||
|
||||
$profileNames = @(
|
||||
# WMI/CIM view -- Special flag reliably excludes SYSTEM/LocalService/NetworkService
|
||||
try {
|
||||
Get-CimInstance Win32_UserProfile -ErrorAction Stop |
|
||||
Where-Object {
|
||||
-not $_.Special -and
|
||||
$_.LocalPath -like 'C:\Users\*'
|
||||
} |
|
||||
ForEach-Object {
|
||||
Split-Path $_.LocalPath -Leaf
|
||||
}
|
||||
}
|
||||
catch {
|
||||
$wmiProfileQueryFailed = $true
|
||||
}
|
||||
# NOTE: the outer @() is required. Without it the trailing Where/Select pipeline
|
||||
# unwraps a single result back to a scalar string, so $profilePaths[0] would index
|
||||
# into the string and return its first char ('C') instead of the full path.
|
||||
$profilePaths = @(
|
||||
@(
|
||||
try {
|
||||
Get-CimInstance Win32_UserProfile -ErrorAction Stop |
|
||||
Where-Object { -not $_.Special -and $_.LocalPath -like 'C:\Users\*' } |
|
||||
ForEach-Object { $_.LocalPath }
|
||||
}
|
||||
catch {
|
||||
$wmiProfileQueryFailed = $true
|
||||
}
|
||||
|
||||
# Filesystem view -- catches profiles WMI might miss
|
||||
(Get-ChildItem 'C:\Users' -Directory -ErrorAction SilentlyContinue).Name
|
||||
) |
|
||||
Where-Object {
|
||||
$_ -and $_ -notmatch $excludePattern
|
||||
} |
|
||||
Select-Object -Unique
|
||||
(Get-ChildItem 'C:\Users' -Directory -ErrorAction SilentlyContinue).FullName
|
||||
) |
|
||||
Where-Object { $_ -and (Split-Path $_ -Leaf) -notmatch $excludePattern } |
|
||||
Select-Object -Unique
|
||||
)
|
||||
|
||||
if ($wmiProfileQueryFailed) {
|
||||
Write-Step -Status 'WARN' -Message 'Guard 2: WMI profile query failed, filesystem check still applied.'
|
||||
Write-Step -Status 'WARN' -Message 'Profiles: WMI query failed, filesystem check still applied.'
|
||||
}
|
||||
|
||||
if ($profileNames) {
|
||||
$profileNames = $profilePaths | ForEach-Object { Split-Path $_ -Leaf }
|
||||
|
||||
# OOBE-relax: the ONLY exemption to guards 2+3. Matches exactly the Windows Backup for
|
||||
# Organizations restore: OOBE InProgress AND exactly one profile AND that profile fresh.
|
||||
$oobeInProgress = Test-OobeInProgress
|
||||
$profileAgeMin = $null
|
||||
$oobeRelax = $false
|
||||
if ($profilePaths.Count -eq 1) {
|
||||
try {
|
||||
$created = (Get-Item -LiteralPath $profilePaths[0] -Force -ErrorAction Stop).CreationTimeUtc
|
||||
$profileAgeMin = [math]::Round(((Get-Date).ToUniversalTime() - $created).TotalMinutes, 1)
|
||||
} catch { }
|
||||
if ($oobeInProgress -and $null -ne $profileAgeMin -and $profileAgeMin -ge 0 -and $profileAgeMin -lt $OobeProfileMaxAgeMinutes) {
|
||||
$oobeRelax = $true
|
||||
}
|
||||
}
|
||||
|
||||
Write-Step -Status 'INFO' -Message "OOBE: OutOfBoxExperienceState InProgress = $oobeInProgress ; profileCount = $($profilePaths.Count) ; singleProfileAgeMin = $profileAgeMin ; OOBE-relax = $oobeRelax"
|
||||
|
||||
# Guard 2: No real user profile should exist yet (unless OOBE-relax holds)
|
||||
if ($profilePaths.Count -gt 0 -and -not $oobeRelax) {
|
||||
$names = ($profileNames | Select-Object -First 3) -join ', '
|
||||
Write-Step -Status 'SKIP' -Message "Guard 2: Real user profile(s) found ($names). Device appears productive."
|
||||
$wouldInstall = $false
|
||||
$reasons.Add("Real user profile(s) found: $names")
|
||||
} elseif ($profilePaths.Count -gt 0 -and $oobeRelax) {
|
||||
$names = ($profileNames | Select-Object -First 3) -join ', '
|
||||
Write-Step -Status 'PASS' -Message "Guard 2: Profile ($names) present but OOBE-relax active (single fresh profile in OOBE) -- not blocking."
|
||||
} else {
|
||||
Write-Step -Status 'PASS' -Message 'Guard 2: No real user profiles found (combined WMI/filesystem view).'
|
||||
}
|
||||
|
||||
# Guard 3: LastLoggedOnUser -- during Device ESP no real user has logged on yet
|
||||
# Guard 3: LastLoggedOnUser (unless OOBE-relax holds)
|
||||
$lastLoggedOnUser = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\LogonUI' -Name 'LastLoggedOnUser' -EA SilentlyContinue).LastLoggedOnUser
|
||||
if ($lastLoggedOnUser -and $lastLoggedOnUser -notmatch 'defaultuser\d*') {
|
||||
if ($lastLoggedOnUser -and $lastLoggedOnUser -notmatch 'defaultuser\d*' -and -not $oobeRelax) {
|
||||
Write-Step -Status 'SKIP' -Message "Guard 3: LastLoggedOnUser found ($lastLoggedOnUser). Device appears productive."
|
||||
$wouldInstall = $false
|
||||
$reasons.Add("LastLoggedOnUser found: $lastLoggedOnUser")
|
||||
} elseif ($lastLoggedOnUser -and $lastLoggedOnUser -notmatch 'defaultuser\d*' -and $oobeRelax) {
|
||||
Write-Step -Status 'PASS' -Message "Guard 3: LastLoggedOnUser ($lastLoggedOnUser) present but OOBE-relax active -- not blocking."
|
||||
} else {
|
||||
Write-Step -Status 'PASS' -Message "Guard 3: No real LastLoggedOnUser (value: '$lastLoggedOnUser')."
|
||||
}
|
||||
|
||||
# Guard 4: Bootstrap window check
|
||||
# NOT "how long may enrollment take" -- agent handles that internally (6h emergency break).
|
||||
# This is "how old can the OOBE state be before I no longer trust it for initial install".
|
||||
# 12h bootstrap window vs 6h agent emergency break = consistent layered approach.
|
||||
# Sleep/standby does NOT reset uptime -- only real boot/restart does.
|
||||
# Guard 4: Bootstrap window check (sleep/standby does NOT reset uptime, only real boot does)
|
||||
$lastBoot = $null
|
||||
$uptimeHours = $null
|
||||
|
||||
@@ -134,7 +166,7 @@ catch {
|
||||
Write-Step -Status 'WARN' -Message 'Guard 4: Could not determine LastBootUpTime / uptime.'
|
||||
}
|
||||
|
||||
# Guard 4: Agent binary already present
|
||||
# Guard 5: Agent binary already present
|
||||
if (Test-Path $AgentExePath) {
|
||||
Write-Step -Status 'SKIP' -Message "Guard 5: Agent already installed at '$AgentExePath'."
|
||||
$wouldInstall = $false
|
||||
@@ -168,6 +200,11 @@ Write-Host ''
|
||||
Write-Host '=== Raw Values ==='
|
||||
Write-Host "Deployed marker : $deployed"
|
||||
Write-Host "DetectedProfileNames : $($profileNames -join ', ')"
|
||||
Write-Host "ProfileCount : $($profilePaths.Count)"
|
||||
Write-Host "OobeInProgress (WinRT) : $oobeInProgress"
|
||||
Write-Host "SingleProfileAgeMinutes : $profileAgeMin"
|
||||
Write-Host "OobeProfileMaxAgeMinutes : $OobeProfileMaxAgeMinutes"
|
||||
Write-Host "OobeRelaxActive : $oobeRelax"
|
||||
Write-Host "LastLoggedOnUser : $lastLoggedOnUser"
|
||||
Write-Host "MaxBootstrapWindowHours : $MaxBootstrapWindowHours"
|
||||
Write-Host "LastBootUpTime : $lastBoot"
|
||||
|
||||
@@ -131,7 +131,7 @@ public class AuthFunctionSideEffectTests
|
||||
_globalNotificationMock.Verify(x => x.CreateNotificationAsync(
|
||||
"preview_signup", "New Preview Signup",
|
||||
It.Is<string>(m => m.Contains(TenantId) && m.Contains("contoso.com") && m.Contains(Upn)),
|
||||
null), Times.Once);
|
||||
$"/admin/tenants/management?tenantId={TenantId}"), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -326,7 +326,8 @@ public class AuthFunction
|
||||
_ = _globalNotificationService.CreateNotificationAsync(
|
||||
"preview_signup",
|
||||
"New Preview Signup",
|
||||
$"Tenant {tenantId} ({domain}), UPN: {upn}");
|
||||
$"Tenant {tenantId} ({domain}), UPN: {upn}",
|
||||
href: $"/admin/tenants/management?tenantId={Uri.EscapeDataString(tenantId)}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
+2
-1
@@ -137,7 +137,8 @@ namespace AutopilotMonitor.Functions.Functions.Reports
|
||||
_ = _globalNotificationService.CreateNotificationAsync(
|
||||
"diag_files_report",
|
||||
"New Diag Files Report",
|
||||
$"{userIdentifier} (Tenant: {request.TenantId})");
|
||||
$"{userIdentifier} (Tenant: {request.TenantId})",
|
||||
href: $"/admin/reports/session-reports?reportId={Uri.EscapeDataString(metadata.ReportId)}");
|
||||
|
||||
_logger.LogInformation("Diag-files report submitted: ReportId={ReportId}, By={User}",
|
||||
metadata.ReportId, userIdentifier);
|
||||
|
||||
+2
-1
@@ -140,7 +140,8 @@ namespace AutopilotMonitor.Functions.Functions.Reports
|
||||
_ = _globalNotificationService.CreateNotificationAsync(
|
||||
"session_report",
|
||||
"New Session Report",
|
||||
$"{userIdentifier} — Session {sessionId} (Tenant: {request.TenantId})");
|
||||
$"{userIdentifier} — Session {sessionId} (Tenant: {request.TenantId})",
|
||||
href: $"/admin/reports/session-reports?reportId={Uri.EscapeDataString(metadata.ReportId)}");
|
||||
|
||||
_logger.LogInformation("Session report submitted: ReportId={ReportId}, Session={SessionId}, By={User}",
|
||||
metadata.ReportId, sessionId, userIdentifier);
|
||||
|
||||
@@ -26,13 +26,13 @@ export const metadata: Metadata = {
|
||||
"Autopilot Monitor overview",
|
||||
],
|
||||
openGraph: {
|
||||
title: "About AutopilotMonitor – Real-Time Windows Autopilot Monitoring",
|
||||
title: "About Autopilot Monitor – Real-Time Windows Autopilot Monitoring",
|
||||
description:
|
||||
"Free, open-source real-time monitoring and troubleshooting for Windows Autopilot enrollments. Track every phase, run analyze rules, and resolve issues faster — built by Oliver Kieselbach.",
|
||||
url: `${SITE_URL}/about`,
|
||||
},
|
||||
twitter: {
|
||||
title: "About AutopilotMonitor – Real-Time Windows Autopilot Monitoring",
|
||||
title: "About Autopilot Monitor – Real-Time Windows Autopilot Monitoring",
|
||||
description:
|
||||
"Free, open-source real-time monitoring and troubleshooting for Windows Autopilot enrollments. Track every phase, run analyze rules, and resolve issues faster.",
|
||||
},
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Link from "next/link";
|
||||
import { PublicPageHeader } from "../../components/PublicPageHeader";
|
||||
import { LandingNavbar } from "../../components/landing/LandingNavbar";
|
||||
import { SiteFooter } from "../../components/SiteFooter";
|
||||
import { DOCS_URL } from "@/utils/config";
|
||||
|
||||
const FEATURES = [
|
||||
@@ -47,25 +48,40 @@ const FEATURES = [
|
||||
},
|
||||
];
|
||||
|
||||
// Landing v2: one accent, no rainbow — every feature card uses the
|
||||
// green brand accent regardless of its legacy color key.
|
||||
const ACCENT_STYLE = {
|
||||
bg: "bg-[var(--lp-accent-soft)]",
|
||||
text: "text-[var(--lp-ink)]",
|
||||
dot: "bg-[var(--lp-accent)]",
|
||||
};
|
||||
const colorMap: Record<string, { bg: string; text: string; dot: string }> = {
|
||||
blue: { bg: "bg-blue-100", text: "text-blue-600", dot: "bg-blue-400" },
|
||||
indigo: { bg: "bg-indigo-100", text: "text-indigo-600", dot: "bg-indigo-400" },
|
||||
purple: { bg: "bg-purple-100", text: "text-purple-600", dot: "bg-purple-400" },
|
||||
green: { bg: "bg-green-100", text: "text-green-600", dot: "bg-green-400" },
|
||||
orange: { bg: "bg-orange-100", text: "text-orange-600", dot: "bg-orange-400" },
|
||||
red: { bg: "bg-red-100", text: "text-red-600", dot: "bg-red-400" },
|
||||
blue: ACCENT_STYLE,
|
||||
indigo: ACCENT_STYLE,
|
||||
purple: ACCENT_STYLE,
|
||||
green: ACCENT_STYLE,
|
||||
orange: ACCENT_STYLE,
|
||||
red: ACCENT_STYLE,
|
||||
};
|
||||
|
||||
export default function AboutPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<PublicPageHeader title="About Autopilot Monitor" />
|
||||
<div className="landing-v2 min-h-screen bg-[var(--lp-bg)]">
|
||||
<LandingNavbar />
|
||||
<header className="px-4 sm:px-6 lg:px-8 pt-14 sm:pt-16">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-[var(--lp-ink-faint)]">About</p>
|
||||
<h1 className="mt-3 text-3xl sm:text-4xl font-bold tracking-tight text-[var(--lp-ink)]">
|
||||
About Autopilot Monitor
|
||||
</h1>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-10 space-y-10">
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-10 space-y-10 text-[15px]">
|
||||
|
||||
{/* What is Autopilot Monitor */}
|
||||
<section className="bg-white rounded-xl shadow-sm border border-gray-100 p-8">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">What is Autopilot Monitor?</h2>
|
||||
<section className="bg-[var(--lp-surface)] border border-[var(--lp-line-soft)] rounded-xl p-8">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">What is Autopilot Monitor?</h2>
|
||||
<p className="text-gray-700 leading-relaxed mb-4">
|
||||
<strong>Autopilot Monitor</strong> is a free, open-source, real-time monitoring and troubleshooting
|
||||
platform for <strong>Windows Autopilot enrollments</strong> managed through{" "}
|
||||
@@ -100,7 +116,7 @@ export default function AboutPage() {
|
||||
{FEATURES.map((f) => {
|
||||
const c = colorMap[f.color];
|
||||
return (
|
||||
<div key={f.title} className="bg-white rounded-xl border border-gray-100 shadow-sm p-6">
|
||||
<div key={f.title} className="bg-[var(--lp-surface)] border border-[var(--lp-line-soft)] rounded-xl p-6">
|
||||
<h3 className={`text-base font-semibold mb-2 ${c.text}`}>{f.title}</h3>
|
||||
<p className="text-sm text-gray-600 leading-relaxed mb-3">{f.description}</p>
|
||||
<ul className="space-y-1">
|
||||
@@ -118,8 +134,8 @@ export default function AboutPage() {
|
||||
</section>
|
||||
|
||||
{/* Who It's For */}
|
||||
<section className="bg-white rounded-xl shadow-sm border border-gray-100 p-8">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">Who Is It For?</h2>
|
||||
<section className="bg-[var(--lp-surface)] border border-[var(--lp-line-soft)] rounded-xl p-8">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">Who Is It For?</h2>
|
||||
<p className="text-gray-700 leading-relaxed mb-5">
|
||||
Autopilot Monitor is built for anyone responsible for deploying or supporting
|
||||
Windows devices through Microsoft Intune and Autopilot.
|
||||
@@ -142,17 +158,17 @@ export default function AboutPage() {
|
||||
"Autopilot Monitor is a multi-tenant service with strict per-tenant data isolation — telemetry, configuration, and diagnostics are partitioned and access-scoped to each tenant. MSPs can be granted delegated read access across several customer tenants from a single login.",
|
||||
},
|
||||
].map((item) => (
|
||||
<div key={item.title} className="rounded-lg bg-blue-50 border border-blue-100 p-4">
|
||||
<h3 className="text-sm font-semibold text-blue-900 mb-2">{item.title}</h3>
|
||||
<p className="text-sm text-blue-800 leading-relaxed">{item.description}</p>
|
||||
<div key={item.title} className="rounded-lg bg-[var(--lp-accent-soft)] border border-[var(--lp-accent-line)] p-4">
|
||||
<h3 className="text-sm font-semibold text-[var(--lp-ink)] mb-2">{item.title}</h3>
|
||||
<p className="text-sm text-[var(--lp-ink-soft)] leading-relaxed">{item.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* How It Works */}
|
||||
<section className="bg-white rounded-xl shadow-sm border border-gray-100 p-8">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">How It Works</h2>
|
||||
<section className="bg-[var(--lp-surface)] border border-[var(--lp-line-soft)] rounded-xl p-8">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">How It Works</h2>
|
||||
<p className="text-gray-700 leading-relaxed mb-5">
|
||||
Autopilot Monitor uses a lightweight .NET agent deployed to devices via an Intune bootstrapper
|
||||
script. The agent monitors the enrollment process in real time and streams telemetry events
|
||||
@@ -169,7 +185,7 @@ export default function AboutPage() {
|
||||
{ step: "5", text: "On completion, the agent uploads a diagnostics bundle if configured, then removes itself." },
|
||||
].map((item) => (
|
||||
<li key={item.step} className="flex items-start gap-3 text-sm text-gray-700">
|
||||
<span className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-blue-600 text-white text-xs font-bold">
|
||||
<span className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-[var(--lp-accent)] text-white text-xs font-bold">
|
||||
{item.step}
|
||||
</span>
|
||||
{item.text}
|
||||
@@ -179,8 +195,8 @@ export default function AboutPage() {
|
||||
</section>
|
||||
|
||||
{/* Tech Stack & Platform */}
|
||||
<section className="bg-white rounded-xl shadow-sm border border-gray-100 p-8">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">Technology & Platform</h2>
|
||||
<section className="bg-[var(--lp-surface)] border border-[var(--lp-line-soft)] rounded-xl p-8">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">Technology & Platform</h2>
|
||||
<p className="text-gray-700 leading-relaxed mb-5">
|
||||
Autopilot Monitor is built on modern, enterprise-grade technology designed to scale with large
|
||||
device fleets and multi-tenant deployments.
|
||||
@@ -190,7 +206,7 @@ export default function AboutPage() {
|
||||
{
|
||||
title: "Backend",
|
||||
items: [
|
||||
"Azure Functions (.NET 10 Isolated, Flex Consumption) — serverless, scalable API",
|
||||
"Azure Functions (.NET Isolated, Flex Consumption) — serverless, scalable API",
|
||||
"Azure Table Storage — high-throughput event ingestion",
|
||||
"Azure Blob Storage — diagnostics and log bundle storage",
|
||||
"Azure SignalR Service — real-time push to the portal",
|
||||
@@ -199,7 +215,7 @@ export default function AboutPage() {
|
||||
{
|
||||
title: "Portal (Web Frontend)",
|
||||
items: [
|
||||
"Next.js 15 (React 18) + TypeScript — fast, server-rendered React app",
|
||||
"Next.js (React) + TypeScript — fast, statically delivered web app",
|
||||
"Microsoft Entra ID (MSAL) — secure authentication",
|
||||
"Role-based access control (Admin / Operator / Viewer)",
|
||||
"Multi-tenant architecture with delegated MSP access",
|
||||
@@ -241,8 +257,8 @@ export default function AboutPage() {
|
||||
</section>
|
||||
|
||||
{/* Open Source */}
|
||||
<section className="bg-white rounded-xl shadow-sm border border-gray-100 p-8">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">Open Source & Free to Use</h2>
|
||||
<section className="bg-[var(--lp-surface)] border border-[var(--lp-line-soft)] rounded-xl p-8">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">Open Source & Free to Use</h2>
|
||||
<p className="text-gray-700 leading-relaxed mb-4">
|
||||
Autopilot Monitor is fully open source and free to use. The complete source code is available on
|
||||
GitHub under an open license. Contributions, bug reports, and feature requests from the
|
||||
@@ -257,16 +273,16 @@ export default function AboutPage() {
|
||||
</p>
|
||||
<p className="text-gray-700 leading-relaxed mb-4">
|
||||
The hosted service is operated by <strong>glueckkanja AG</strong> — see the{" "}
|
||||
<a href="https://www.glueckkanja.com/en/imprint" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:text-blue-800 underline">Imprint</a> for company
|
||||
details and the <Link href="/terms" className="text-blue-600 hover:text-blue-800 underline">Terms of Use</Link>{" "}
|
||||
<a href="https://www.glueckkanja.com/en/imprint" target="_blank" rel="noopener noreferrer" className="text-[var(--lp-accent-ink)] hover:opacity-80 underline">Imprint</a> for company
|
||||
details and the <Link href="/terms" className="text-[var(--lp-accent-ink)] hover:opacity-80 underline">Terms of Use</Link>{" "}
|
||||
for what each plan does and does not commit to.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-3 mt-5">
|
||||
<a
|
||||
href="https://github.com/okieselbach/Autopilot-Monitor"
|
||||
href="https://github.com/okieselbach/AutopilotMonitor"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-4 py-2.5 text-sm font-medium text-gray-700 hover:border-gray-300 hover:bg-gray-100 transition-colors"
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-[var(--lp-line-soft)] bg-gray-50 px-4 py-2.5 text-sm font-medium text-gray-700 hover:border-gray-300 hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z" />
|
||||
@@ -274,10 +290,10 @@ export default function AboutPage() {
|
||||
View on GitHub
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/okieselbach/Autopilot-Monitor/issues"
|
||||
href="https://github.com/okieselbach/AutopilotMonitor/issues"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-4 py-2.5 text-sm font-medium text-gray-700 hover:border-gray-300 hover:bg-gray-100 transition-colors"
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-[var(--lp-line-soft)] bg-gray-50 px-4 py-2.5 text-sm font-medium text-gray-700 hover:border-gray-300 hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
Submit Feedback
|
||||
</a>
|
||||
@@ -285,7 +301,7 @@ export default function AboutPage() {
|
||||
href="https://www.linkedin.com/in/oliver-kieselbach/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-4 py-2.5 text-sm font-medium text-gray-700 hover:border-gray-300 hover:bg-gray-100 transition-colors"
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-[var(--lp-line-soft)] bg-gray-50 px-4 py-2.5 text-sm font-medium text-gray-700 hover:border-gray-300 hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
Oliver Kieselbach on LinkedIn
|
||||
</a>
|
||||
@@ -293,22 +309,23 @@ export default function AboutPage() {
|
||||
</section>
|
||||
|
||||
{/* Quick Links */}
|
||||
<section className="bg-white rounded-xl shadow-sm border border-gray-100 p-8">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">Explore Further</h2>
|
||||
<section className="bg-[var(--lp-surface)] border border-[var(--lp-line-soft)] rounded-xl p-8">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">Explore Further</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm">
|
||||
<a href={DOCS_URL} target="_blank" rel="noopener noreferrer" className="rounded-lg border border-gray-200 px-4 py-3 hover:border-blue-300 hover:bg-blue-50/50 transition-colors text-gray-700 font-medium">
|
||||
<a href={DOCS_URL} target="_blank" rel="noopener noreferrer" className="rounded-lg border border-[var(--lp-line-soft)] px-4 py-3 hover:border-[var(--lp-accent-line)] hover:bg-[var(--lp-accent-soft)] transition-colors text-gray-700 font-medium">
|
||||
Documentation →
|
||||
</a>
|
||||
<a href={`${DOCS_URL}/changelog/platform-changelog`} target="_blank" rel="noopener noreferrer" className="rounded-lg border border-gray-200 px-4 py-3 hover:border-blue-300 hover:bg-blue-50/50 transition-colors text-gray-700 font-medium">
|
||||
<a href={`${DOCS_URL}/changelog/platform-changelog`} target="_blank" rel="noopener noreferrer" className="rounded-lg border border-[var(--lp-line-soft)] px-4 py-3 hover:border-[var(--lp-accent-line)] hover:bg-[var(--lp-accent-soft)] transition-colors text-gray-700 font-medium">
|
||||
Changelog →
|
||||
</a>
|
||||
<Link href="/privacy" className="rounded-lg border border-gray-200 px-4 py-3 hover:border-blue-300 hover:bg-blue-50/50 transition-colors text-gray-700 font-medium">
|
||||
<Link href="/privacy" className="rounded-lg border border-[var(--lp-line-soft)] px-4 py-3 hover:border-[var(--lp-accent-line)] hover:bg-[var(--lp-accent-soft)] transition-colors text-gray-700 font-medium">
|
||||
Privacy Policy →
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,24 +3,15 @@
|
||||
import { useMemo } from "react";
|
||||
import { usePageSections } from "../../hooks/usePageSections";
|
||||
import { PageSectionItem } from "../../contexts/SidebarContext";
|
||||
import { route } from "../../lib/routes";
|
||||
import {
|
||||
GearIcon,
|
||||
NoSymbolIcon,
|
||||
DocumentTextIcon,
|
||||
ChartBarIcon,
|
||||
BuildingOfficeIcon,
|
||||
ShieldCheckIcon,
|
||||
} from "../../lib/sidebarIcons";
|
||||
|
||||
// Inline icon (same as MetricsSidebar had)
|
||||
function TrendingUpIcon({ className = "w-5 h-5" }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 18L9 11.25l4.306 4.307a11.95 11.95 0 015.814-5.519l2.74-1.22m0 0l-5.94-2.28m5.94 2.28l-2.28 5.941" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// Inline icon for Ops (wrench)
|
||||
function WrenchIcon({ className = "w-5 h-5" }: { className?: string }) {
|
||||
return (
|
||||
@@ -47,29 +38,29 @@ function GlobeIcon({ className = "w-5 h-5" }: { className?: string }) {
|
||||
export function AdminPageSections() {
|
||||
const items: PageSectionItem[] = useMemo(() => [
|
||||
// Tenants
|
||||
{ id: "management", label: "Tenant Management", href: "/admin/tenants/management", group: "Tenants", groupIcon: <BuildingOfficeIcon /> },
|
||||
{ id: "config-report", label: "Config Report", href: "/admin/tenants/config-report", group: "Tenants" },
|
||||
{ id: "management", label: "Tenant Management", href: route("/admin/tenants/management"), group: "Tenants", groupIcon: <BuildingOfficeIcon /> },
|
||||
{ id: "config-report", label: "Config Report", href: route("/admin/tenants/config-report"), group: "Tenants" },
|
||||
|
||||
// Metrics
|
||||
{ id: "platform-metrics", label: "Platform Metrics", href: "/admin/metrics/platform-metrics", group: "Metrics", groupIcon: <ChartBarIcon /> },
|
||||
{ id: "usage", label: "Platform Usage", href: "/admin/metrics/usage", group: "Metrics" },
|
||||
{ id: "mcp-usage", label: "MCP Usage", href: "/admin/metrics/mcp-usage", group: "Metrics" },
|
||||
{ id: "platform-metrics", label: "Platform Metrics", href: route("/admin/metrics/platform-metrics"), group: "Metrics", groupIcon: <ChartBarIcon /> },
|
||||
{ id: "usage", label: "Platform Usage", href: route("/admin/metrics/usage"), group: "Metrics" },
|
||||
{ id: "mcp-usage", label: "MCP Usage", href: route("/admin/metrics/mcp-usage"), group: "Metrics" },
|
||||
|
||||
// Reports
|
||||
{ id: "session-reports", label: "Session Reports", href: "/admin/reports/session-reports", group: "Reports", groupIcon: <DocumentTextIcon /> },
|
||||
{ id: "user-feedback", label: "User Feedback", href: "/admin/reports/user-feedback", group: "Reports" },
|
||||
{ id: "session-export", label: "Session Export", href: "/admin/reports/session-export", group: "Reports" },
|
||||
{ id: "session-reports", label: "Session Reports", href: route("/admin/reports/session-reports"), group: "Reports", groupIcon: <DocumentTextIcon /> },
|
||||
{ id: "user-feedback", label: "User Feedback", href: route("/admin/reports/user-feedback"), group: "Reports" },
|
||||
{ id: "session-export", label: "Session Export", href: route("/admin/reports/session-export"), group: "Reports" },
|
||||
|
||||
// Security
|
||||
{ id: "device-block", label: "Device Block", href: "/admin/security/device-block", group: "Security", groupIcon: <ShieldCheckIcon /> },
|
||||
{ id: "version-block", label: "Version Block", href: "/admin/security/version-block", group: "Security" },
|
||||
{ id: "vulnerability-data", label: "Vulnerability Data", href: "/admin/security/vulnerability-data", group: "Security" },
|
||||
{ id: "device-block", label: "Device Block", href: route("/admin/security/device-block"), group: "Security", groupIcon: <ShieldCheckIcon /> },
|
||||
{ id: "version-block", label: "Version Block", href: route("/admin/security/version-block"), group: "Security" },
|
||||
{ id: "vulnerability-data", label: "Vulnerability Data", href: route("/admin/security/vulnerability-data"), group: "Security" },
|
||||
|
||||
// Settings
|
||||
{ id: "global", label: "Global Settings", href: "/admin/settings/global", group: "Settings", groupIcon: <GearIcon /> },
|
||||
{ id: "diagnostics-log-paths", label: "Diagnostics Log Paths", href: "/admin/settings/diagnostics-log-paths", group: "Settings" },
|
||||
{ id: "config-reseed", label: "Config Reseed", href: "/admin/settings/config-reseed", group: "Settings" },
|
||||
{ id: "usage-plans", label: "Usage Plans", href: "/admin/settings/usage-plans", group: "Settings" },
|
||||
{ id: "global", label: "Global Settings", href: route("/admin/settings/global"), group: "Settings", groupIcon: <GearIcon /> },
|
||||
{ id: "diagnostics-log-paths", label: "Diagnostics Log Paths", href: route("/admin/settings/diagnostics-log-paths"), group: "Settings" },
|
||||
{ id: "config-reseed", label: "Config Reseed", href: route("/admin/settings/config-reseed"), group: "Settings" },
|
||||
{ id: "usage-plans", label: "Usage Plans", href: route("/admin/settings/usage-plans"), group: "Settings" },
|
||||
|
||||
// Ops (single page)
|
||||
{ id: "ops", label: "Maintenance", href: "/admin/ops", group: "Ops", groupIcon: <WrenchIcon /> },
|
||||
|
||||
@@ -90,7 +90,7 @@ export function RestoreRowDiffModal({
|
||||
This is an <strong>authentication / authorization</strong> table
|
||||
(<code className="font-mono">GlobalAdmins</code>, <code className="font-mono">TenantAdmins</code>,{" "}
|
||||
<code className="font-mono">McpUsers</code>). Restoring this row will overwrite the live{" "}
|
||||
<code className="font-mono">IsEnabled</code> flag — confirm that the backup row's enable/disable
|
||||
<code className="font-mono">IsEnabled</code> flag — confirm that the backup row's enable/disable
|
||||
state is what you intend.
|
||||
</div>
|
||||
</div>
|
||||
@@ -159,7 +159,7 @@ export function RestoreRowDiffModal({
|
||||
type="button"
|
||||
onClick={commit}
|
||||
disabled={committing}
|
||||
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white text-sm rounded-md transition-colors"
|
||||
className="px-3 py-1.5 bg-green-600 hover:bg-green-700 disabled:opacity-50 text-white text-sm rounded-md transition-colors"
|
||||
>
|
||||
{committing ? "Restoring…" : "Restore row"}
|
||||
</button>
|
||||
|
||||
@@ -276,7 +276,7 @@ function RestoreInputRow({
|
||||
// — only null is rejected. Match that here so an Azure Table row with
|
||||
// a literal empty key (legal per spec) can be previewed/restored.
|
||||
disabled={disabled || !tableName}
|
||||
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white text-sm rounded-md transition-colors"
|
||||
className="px-3 py-1.5 bg-green-600 hover:bg-green-700 disabled:opacity-50 text-white text-sm rounded-md transition-colors"
|
||||
>
|
||||
Preview restore
|
||||
</button>
|
||||
|
||||
@@ -134,7 +134,7 @@ export default function BackupsListPage() {
|
||||
type="button"
|
||||
onClick={triggerBackup}
|
||||
disabled={triggering || activeJob !== null}
|
||||
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white text-sm rounded-md transition-colors"
|
||||
className="px-3 py-1.5 bg-green-600 hover:bg-green-700 disabled:opacity-50 text-white text-sm rounded-md transition-colors"
|
||||
>
|
||||
{triggering ? "Triggering…" : "Run backup now"}
|
||||
</button>
|
||||
|
||||
@@ -104,7 +104,7 @@ export function AdminConfigSettingsSection({
|
||||
max="1000"
|
||||
value={globalRateLimit}
|
||||
onChange={(e) => setGlobalRateLimit(parseInt(e.target.value) || 100)}
|
||||
className="mt-1 block w-full px-4 py-2 border border-indigo-300 dark:border-indigo-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
|
||||
className="mt-1 block w-full px-4 py-2 border border-indigo-300 dark:border-indigo-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
@@ -121,7 +121,7 @@ export function AdminConfigSettingsSection({
|
||||
max="10000"
|
||||
value={userRateLimit}
|
||||
onChange={(e) => setUserRateLimit(parseInt(e.target.value) || 120)}
|
||||
className="mt-1 block w-full px-4 py-2 border border-indigo-300 dark:border-indigo-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
|
||||
className="mt-1 block w-full px-4 py-2 border border-indigo-300 dark:border-indigo-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
@@ -138,7 +138,7 @@ export function AdminConfigSettingsSection({
|
||||
max="10000"
|
||||
value={globalAdminRateLimit}
|
||||
onChange={(e) => setGlobalAdminRateLimit(parseInt(e.target.value) || 600)}
|
||||
className="mt-1 block w-full px-4 py-2 border border-indigo-300 dark:border-indigo-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
|
||||
className="mt-1 block w-full px-4 py-2 border border-indigo-300 dark:border-indigo-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
@@ -158,7 +158,7 @@ export function AdminConfigSettingsSection({
|
||||
value={platformStatsBlobSasUrl}
|
||||
onChange={(e) => setPlatformStatsBlobSasUrl(e.target.value)}
|
||||
placeholder="https://storageaccount.blob.core.windows.net/publicstats?sv=...&sig=..."
|
||||
className="mt-1 block w-full px-4 py-2 border border-indigo-300 dark:border-indigo-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors font-mono text-sm"
|
||||
className="mt-1 block w-full px-4 py-2 border border-indigo-300 dark:border-indigo-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors font-mono text-sm"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
@@ -177,7 +177,7 @@ export function AdminConfigSettingsSection({
|
||||
value={agentMigrateApiBaseUrl}
|
||||
onChange={(e) => setAgentMigrateApiBaseUrl(e.target.value)}
|
||||
placeholder="https://autopilotmonitor-api-us.azurewebsites.net"
|
||||
className="mt-1 block w-full px-4 py-2 border border-indigo-300 dark:border-indigo-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors font-mono text-sm"
|
||||
className="mt-1 block w-full px-4 py-2 border border-indigo-300 dark:border-indigo-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors font-mono text-sm"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
@@ -195,7 +195,7 @@ export function AdminConfigSettingsSection({
|
||||
onChange={(e) => setAgentMigrateTenantOverridesJson(e.target.value)}
|
||||
placeholder='{"11111111-2222-3333-4444-555555555555": "https://autopilotmonitor-api-us.azurewebsites.net", "66666666-7777-8888-9999-000000000000": ""}'
|
||||
rows={3}
|
||||
className="mt-1 block w-full px-4 py-2 border border-indigo-300 dark:border-indigo-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors font-mono text-sm"
|
||||
className="mt-1 block w-full px-4 py-2 border border-indigo-300 dark:border-indigo-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors font-mono text-sm"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
@@ -214,7 +214,7 @@ export function AdminConfigSettingsSection({
|
||||
max="120"
|
||||
value={collectorIdleTimeoutMinutes}
|
||||
onChange={(e) => setCollectorIdleTimeoutMinutes(parseInt(e.target.value) || 0)}
|
||||
className="mt-1 block w-full px-4 py-2 border border-indigo-300 dark:border-indigo-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
|
||||
className="mt-1 block w-full px-4 py-2 border border-indigo-300 dark:border-indigo-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
@@ -233,7 +233,7 @@ export function AdminConfigSettingsSection({
|
||||
max="60"
|
||||
value={desktopDetectorNoCandidateTimeoutMinutes}
|
||||
onChange={(e) => setDesktopDetectorNoCandidateTimeoutMinutes(parseInt(e.target.value) || 0)}
|
||||
className="mt-1 block w-full px-4 py-2 border border-indigo-300 dark:border-indigo-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
|
||||
className="mt-1 block w-full px-4 py-2 border border-indigo-300 dark:border-indigo-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
@@ -253,7 +253,7 @@ export function AdminConfigSettingsSection({
|
||||
max="168"
|
||||
value={slaNotificationCooldownHours}
|
||||
onChange={(e) => setSlaNotificationCooldownHours(parseInt(e.target.value) || 24)}
|
||||
className="mt-1 block w-full px-4 py-2 border border-indigo-300 dark:border-indigo-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
|
||||
className="mt-1 block w-full px-4 py-2 border border-indigo-300 dark:border-indigo-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
@@ -264,7 +264,7 @@ export function AdminConfigSettingsSection({
|
||||
type="checkbox"
|
||||
checked={allowAgentDowngrade}
|
||||
onChange={(e) => setAllowAgentDowngrade(e.target.checked)}
|
||||
className="mt-1 h-5 w-5 rounded border-indigo-300 dark:border-indigo-600 text-indigo-600 focus:ring-indigo-500"
|
||||
className="mt-1 h-5 w-5 rounded border-indigo-300 dark:border-indigo-600 text-green-600 focus:ring-green-500"
|
||||
/>
|
||||
<span>
|
||||
<span className="text-indigo-900 dark:text-indigo-100 font-medium">Allow agent downgrade</span>
|
||||
@@ -289,7 +289,7 @@ export function AdminConfigSettingsSection({
|
||||
value={modernDeploymentHarmlessEventIds}
|
||||
onChange={(e) => setModernDeploymentHarmlessEventIds(e.target.value)}
|
||||
placeholder="100, 1005, 1010"
|
||||
className="mt-1 block w-full px-4 py-2 border border-indigo-300 dark:border-indigo-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors font-mono text-sm"
|
||||
className="mt-1 block w-full px-4 py-2 border border-indigo-300 dark:border-indigo-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors font-mono text-sm"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
@@ -300,7 +300,7 @@ export function AdminConfigSettingsSection({
|
||||
type="checkbox"
|
||||
checked={enableIndexDualWrite}
|
||||
onChange={(e) => setEnableIndexDualWrite(e.target.checked)}
|
||||
className="mt-1 h-5 w-5 rounded border-indigo-300 dark:border-indigo-600 text-indigo-600 focus:ring-indigo-500"
|
||||
className="mt-1 h-5 w-5 rounded border-indigo-300 dark:border-indigo-600 text-green-600 focus:ring-green-500"
|
||||
/>
|
||||
<span>
|
||||
<span className="text-indigo-900 dark:text-indigo-100 font-medium">V2 Decision Engine: Enable index-table dual-write</span>
|
||||
@@ -328,7 +328,7 @@ export function AdminConfigSettingsSection({
|
||||
type="checkbox"
|
||||
checked={sessionDeletionKillSwitch}
|
||||
onChange={(e) => setSessionDeletionKillSwitch(e.target.checked)}
|
||||
className="mt-1 h-5 w-5 rounded border-red-300 dark:border-red-600 text-red-600 focus:ring-red-500"
|
||||
className="mt-1 h-5 w-5 rounded border-red-300 dark:border-red-600 text-red-600 accent-red-600 focus:ring-red-500"
|
||||
/>
|
||||
<span>
|
||||
<span className="text-red-900 dark:text-red-100 font-medium">Cascade-Delete Kill-Switch (emergency)</span>
|
||||
|
||||
@@ -542,7 +542,7 @@ function DeviceBlockSectionInner({
|
||||
<td className="px-3 py-2">
|
||||
<button
|
||||
onClick={() => navigator.clipboard.writeText(d.tenantId)}
|
||||
className="text-xs font-mono text-gray-500 hover:text-blue-600 transition-colors"
|
||||
className="text-xs font-mono text-gray-500 hover:text-green-600 transition-colors"
|
||||
title={d.tenantId}
|
||||
>
|
||||
{d.tenantId.split("-").slice(0, 2).join("-")}...
|
||||
|
||||
@@ -90,7 +90,7 @@ export function DiagnosticsLogPathsSection({
|
||||
updated[idx] = { ...entry, includeSubfolders: !entry.includeSubfolders };
|
||||
setGlobalDiagPaths(updated);
|
||||
}}
|
||||
className="w-3.5 h-3.5 rounded border-teal-400 text-teal-600 focus:ring-teal-500"
|
||||
className="w-3.5 h-3.5 rounded border-teal-400 text-green-600 focus:ring-green-500"
|
||||
/>
|
||||
<span className="text-xs text-teal-600 dark:text-teal-400">Include subfolders</span>
|
||||
</label>
|
||||
@@ -148,7 +148,7 @@ export function DiagnosticsLogPathsSection({
|
||||
type="checkbox"
|
||||
checked={newDiagSubfolders}
|
||||
onChange={() => setNewDiagSubfolders(!newDiagSubfolders)}
|
||||
className="w-3.5 h-3.5 rounded border-gray-400 text-teal-600 focus:ring-teal-500"
|
||||
className="w-3.5 h-3.5 rounded border-gray-400 text-green-600 focus:ring-green-500"
|
||||
/>
|
||||
<span className="text-xs text-gray-600 dark:text-gray-400">Include subfolders</span>
|
||||
</label>
|
||||
|
||||
@@ -146,11 +146,6 @@ export function DistressReportsSection({
|
||||
return sortDir === "asc" ? cmp : -cmp;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchReports();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const fetchReports = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
@@ -172,6 +167,11 @@ export function DistressReportsSection({
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchReports();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Summary stats
|
||||
const errorTypeCounts = reports.reduce<Record<string, number>>((acc, r) => {
|
||||
acc[r.errorType] = (acc[r.errorType] ?? 0) + 1;
|
||||
|
||||
@@ -35,6 +35,10 @@ export function FeedbackSection({ getAccessToken, setError }: FeedbackSectionPro
|
||||
const [activeTab, setActiveTab] = useState<FeedbackTab>("InApp");
|
||||
const [inAppFilter, setInAppFilter] = useState<InAppFilter>("all");
|
||||
const [currentPage, setCurrentPage] = useState(0);
|
||||
// Reference clock for the "Xm ago" labels — render must stay pure
|
||||
// (react-hooks/purity), so it's captured at mount and refreshed per fetch,
|
||||
// keeping the labels in step with the data they describe.
|
||||
const [nowMs, setNowMs] = useState(() => Date.now());
|
||||
const entriesPerPage = 5;
|
||||
|
||||
const fetchFeedback = useCallback(async () => {
|
||||
@@ -56,6 +60,7 @@ export function FeedbackSection({ getAccessToken, setError }: FeedbackSectionPro
|
||||
return dateB - dateA;
|
||||
});
|
||||
setEntries(sorted);
|
||||
setNowMs(Date.now());
|
||||
} catch (err) {
|
||||
if (err instanceof TokenExpiredError) {
|
||||
console.error("Session expired while fetching feedback");
|
||||
@@ -114,7 +119,7 @@ export function FeedbackSection({ getAccessToken, setError }: FeedbackSectionPro
|
||||
|
||||
const formatTimeAgo = (dateStr: string | null): string => {
|
||||
if (!dateStr) return "unknown";
|
||||
const diff = Date.now() - new Date(dateStr).getTime();
|
||||
const diff = nowMs - new Date(dateStr).getTime();
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
|
||||
@@ -17,6 +17,9 @@ export function MaintenanceSection({
|
||||
}: MaintenanceSectionProps) {
|
||||
const [triggeringMaintenance, setTriggeringMaintenance] = useState(false);
|
||||
const [maintenanceDate, setMaintenanceDate] = useState<string>("");
|
||||
// Latest selectable day (yesterday), fixed at mount — render must stay pure
|
||||
// (react-hooks/purity) and the cap doesn't need a live midnight rollover.
|
||||
const [maxMaintenanceDate] = useState(() => new Date(Date.now() - 86400000).toISOString().split('T')[0]);
|
||||
const [reseedingRules, setReseedingRules] = useState(false);
|
||||
const [reseedingGatherRules, setReseedingGatherRules] = useState(false);
|
||||
const [reseedingImePatterns, setReseedingImePatterns] = useState(false);
|
||||
@@ -244,7 +247,7 @@ export function MaintenanceSection({
|
||||
type="date"
|
||||
value={maintenanceDate}
|
||||
onChange={(e) => setMaintenanceDate(e.target.value)}
|
||||
max={new Date(Date.now() - 86400000).toISOString().split('T')[0]}
|
||||
max={maxMaintenanceDate}
|
||||
className="w-full max-w-xs px-3 py-2 border border-purple-300 dark:border-purple-600 rounded-lg text-sm bg-white dark:bg-gray-600 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
|
||||
|
||||
@@ -17,6 +17,9 @@ export function MaintenanceTriggerSection({
|
||||
}: MaintenanceTriggerSectionProps) {
|
||||
const [triggeringMaintenance, setTriggeringMaintenance] = useState(false);
|
||||
const [maintenanceDate, setMaintenanceDate] = useState<string>("");
|
||||
// Latest selectable day (yesterday), fixed at mount — render must stay pure
|
||||
// (react-hooks/purity) and the cap doesn't need a live midnight rollover.
|
||||
const [maxMaintenanceDate] = useState(() => new Date(Date.now() - 86400000).toISOString().split('T')[0]);
|
||||
const [refreshingVersions, setRefreshingVersions] = useState(false);
|
||||
|
||||
const handleRefreshLatestVersions = async () => {
|
||||
@@ -143,7 +146,7 @@ export function MaintenanceTriggerSection({
|
||||
type="date"
|
||||
value={maintenanceDate}
|
||||
onChange={(e) => setMaintenanceDate(e.target.value)}
|
||||
max={new Date(Date.now() - 86400000).toISOString().split('T')[0]}
|
||||
max={maxMaintenanceDate}
|
||||
className="w-full max-w-xs px-3 py-2 border border-purple-300 dark:border-purple-600 rounded-lg text-sm bg-white dark:bg-gray-600 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
|
||||
|
||||
@@ -232,14 +232,6 @@ export function OpsAlertRulesSection({
|
||||
const enabledRulesCount = rules.filter(r => r.enabled).length;
|
||||
const enabledProviders = [telegramEnabled, teamsEnabled, slackEnabled].filter(Boolean).length;
|
||||
|
||||
// Get category for a given event type
|
||||
const getCategoryForEvent = (eventType: string): string => {
|
||||
for (const [cat, types] of Object.entries(OPS_EVENT_TYPES)) {
|
||||
if (types.includes(eventType)) return cat;
|
||||
}
|
||||
return "Unknown";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Alert Rules */}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { sessionUrl } from "@/lib/routes";
|
||||
import { sessionUrl, deviceBlockUrl } from "@/lib/routes";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { api } from "@/lib/api";
|
||||
@@ -558,7 +558,6 @@ export function OpsEventsSection({
|
||||
const sessionId = extractSessionId(selectedEvent.details);
|
||||
if (!sessionId) return null;
|
||||
const reason = buildAutoReason(selectedEvent.eventType, sessionId);
|
||||
const baseHref = `/admin/security/device-block?sessionId=${encodeURIComponent(sessionId)}&reason=${encodeURIComponent(reason)}`;
|
||||
return (
|
||||
<div className="mt-5 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-2">
|
||||
@@ -574,14 +573,14 @@ export function OpsEventsSection({
|
||||
View session
|
||||
</Link>
|
||||
<Link
|
||||
href={`${baseHref}&action=Block`}
|
||||
href={deviceBlockUrl(sessionId, reason, "Block")}
|
||||
onClick={() => setSelectedEvent(null)}
|
||||
className="inline-flex items-center px-3 py-1.5 rounded-md text-xs font-medium bg-orange-100 text-orange-800 hover:bg-orange-200 dark:bg-orange-900/40 dark:text-orange-200 dark:hover:bg-orange-900/60 border border-orange-300 dark:border-orange-700"
|
||||
>
|
||||
Block this device
|
||||
</Link>
|
||||
<Link
|
||||
href={`${baseHref}&action=Kill`}
|
||||
href={deviceBlockUrl(sessionId, reason, "Kill")}
|
||||
onClick={() => setSelectedEvent(null)}
|
||||
className="inline-flex items-center px-3 py-1.5 rounded-md text-xs font-medium bg-red-700 text-white hover:bg-red-800 dark:bg-red-700 dark:hover:bg-red-800"
|
||||
>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Suspense, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { api } from "@/lib/api";
|
||||
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
|
||||
import TruncatedLabel from "@/components/TruncatedLabel";
|
||||
@@ -76,7 +77,18 @@ function CopyButton({ value }: { value: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function SessionReportsSection({
|
||||
export function SessionReportsSection(props: SessionReportsSectionProps) {
|
||||
// useSearchParams() in the inner component requires a Suspense boundary for
|
||||
// static prerender (this section renders inside the prerendered
|
||||
// /admin/reports/session-reports page).
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<SessionReportsSectionInner {...props} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionReportsSectionInner({
|
||||
getAccessToken,
|
||||
setError,
|
||||
}: SessionReportsSectionProps) {
|
||||
@@ -101,6 +113,25 @@ export function SessionReportsSection({
|
||||
const [continuationStack, setContinuationStack] = useState<Array<string | null>>([]);
|
||||
const [pageNumber, setPageNumber] = useState(1);
|
||||
|
||||
// Deep link from GA notifications: ?reportId=… auto-opens the report modal once
|
||||
// the first page has loaded. Reports are listed newest-first, so a report reached
|
||||
// via a fresh notification is on page 1; older ones get a hint instead.
|
||||
const searchParams = useSearchParams();
|
||||
const autoOpenedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoOpenedRef.current || loading) return;
|
||||
const reportIdParam = searchParams?.get("reportId");
|
||||
if (!reportIdParam) return;
|
||||
autoOpenedRef.current = true;
|
||||
const match = reports.find((r) => r.reportId === reportIdParam);
|
||||
if (match) {
|
||||
setSelectedReport(match);
|
||||
} else {
|
||||
setError(`Report ${reportIdParam} is not on the first page — it may be older. Page through or filter by tenant to find it.`);
|
||||
}
|
||||
}, [loading, reports, searchParams, setError]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedReport) {
|
||||
setAdminNoteValue(selectedReport.adminNote ?? "");
|
||||
@@ -279,7 +310,7 @@ export function SessionReportsSection({
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleApplyTenantFilter();
|
||||
}}
|
||||
className="w-72 px-2 py-1 text-xs font-mono border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
|
||||
className="w-72 px-2 py-1 text-xs font-mono border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-green-500 focus:border-green-500"
|
||||
/>
|
||||
<button
|
||||
onClick={handleApplyTenantFilter}
|
||||
@@ -514,7 +545,7 @@ export function SessionReportsSection({
|
||||
readOnly={!canMutate}
|
||||
rows={3}
|
||||
placeholder={canMutate ? "Add an internal note about this report..." : "No note"}
|
||||
className="w-full text-sm rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-indigo-500 resize-none placeholder:text-gray-400 read-only:opacity-60 read-only:cursor-not-allowed"
|
||||
className="w-full text-sm rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-green-500 resize-none placeholder:text-gray-400 read-only:opacity-60 read-only:cursor-not-allowed"
|
||||
/>
|
||||
<div className="flex items-center justify-between mt-1.5">
|
||||
<div className="text-xs">
|
||||
@@ -533,7 +564,7 @@ export function SessionReportsSection({
|
||||
<button
|
||||
onClick={handleSaveAdminNote}
|
||||
disabled={!canMutate || savingNote}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-indigo-600 hover:bg-indigo-700 disabled:bg-indigo-400 text-white rounded-md transition-colors text-xs font-medium disabled:cursor-not-allowed"
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-green-600 hover:bg-green-700 disabled:bg-indigo-400 text-white rounded-md transition-colors text-xs font-medium disabled:cursor-not-allowed"
|
||||
>
|
||||
{savingNote ? (
|
||||
<>
|
||||
@@ -555,7 +586,7 @@ export function SessionReportsSection({
|
||||
<button
|
||||
onClick={() => handleDownload(selectedReport.blobName)}
|
||||
disabled={downloadingBlob === selectedReport.blobName}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 hover:bg-indigo-700 disabled:bg-indigo-400 text-white rounded-md transition-colors text-sm font-medium"
|
||||
className="flex items-center gap-2 px-4 py-2 bg-green-600 hover:bg-green-700 disabled:bg-indigo-400 text-white rounded-md transition-colors text-sm font-medium"
|
||||
>
|
||||
{downloadingBlob === selectedReport.blobName ? (
|
||||
<>
|
||||
|
||||
@@ -423,7 +423,7 @@ export function TenantAdminSection({
|
||||
onChange={(e) => setNewAdminEmail(e.target.value)}
|
||||
placeholder="admin@tenant.com"
|
||||
autoComplete="off"
|
||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Suspense, useEffect, useRef, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { api } from "@/lib/api";
|
||||
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
|
||||
import { TenantAdminSection } from "./TenantAdminSection";
|
||||
@@ -48,7 +49,18 @@ export interface TenantManagementSectionProps {
|
||||
setSuccessMessage: (message: string | null) => void;
|
||||
}
|
||||
|
||||
export function TenantManagementSection({
|
||||
export function TenantManagementSection(props: TenantManagementSectionProps) {
|
||||
// useSearchParams() in the inner component requires a Suspense boundary for
|
||||
// static prerender (this section renders inside the prerendered
|
||||
// /admin/tenants/management page).
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<TenantManagementSectionInner {...props} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function TenantManagementSectionInner({
|
||||
tenants,
|
||||
loadingTenants,
|
||||
fetchTenants,
|
||||
@@ -62,6 +74,21 @@ export function TenantManagementSection({
|
||||
// Read-only Global Readers may view tenants (incl. config report) but not edit them.
|
||||
const canMutate = useCanMutatePlatform();
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
// Mount-time clock for trial-expiry checks — render must stay pure
|
||||
// (react-hooks/purity); day-granularity expiry doesn't need a live clock.
|
||||
const [nowMs] = useState(() => Date.now());
|
||||
|
||||
// Deep link from GA notifications: ?tenantId=… seeds the search box once, so the
|
||||
// list opens filtered to the tenant the notification is about.
|
||||
const searchParams = useSearchParams();
|
||||
const seededSearchRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (seededSearchRef.current) return;
|
||||
const tenantIdParam = searchParams?.get("tenantId");
|
||||
if (!tenantIdParam) return;
|
||||
seededSearchRef.current = true;
|
||||
setSearchQuery(tenantIdParam);
|
||||
}, [searchParams]);
|
||||
const [showOnlyWaitlist, setShowOnlyWaitlist] = useState(false);
|
||||
const [showOnlyReady, setShowOnlyReady] = useState(false);
|
||||
const [tenantSectionExpanded, setTenantSectionExpanded] = useState(false);
|
||||
@@ -331,7 +358,7 @@ export function TenantManagementSection({
|
||||
placeholder={'Search by domain or tenant ID (use "..." for exact match)'}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-10 pr-10 py-2 border border-gray-300 rounded-lg text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
||||
className="w-full pl-10 pr-10 py-2 border border-gray-300 rounded-lg text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
@@ -349,7 +376,7 @@ export function TenantManagementSection({
|
||||
onClick={() => { setShowOnlyReady(v => !v); setCurrentPage(0); }}
|
||||
className={`flex items-center space-x-1 px-3 py-2 text-sm rounded-lg border transition-colors whitespace-nowrap ${
|
||||
showOnlyReady
|
||||
? 'bg-blue-600 text-white border-blue-600'
|
||||
? 'bg-green-600 text-white border-blue-600'
|
||||
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
@@ -432,7 +459,7 @@ export function TenantManagementSection({
|
||||
className={`px-3 py-2 text-sm rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${
|
||||
previewApproved.has(tenant.tenantId)
|
||||
? 'bg-amber-500 text-white hover:bg-amber-600'
|
||||
: 'bg-blue-600 text-white hover:bg-blue-700'
|
||||
: 'bg-green-600 text-white hover:bg-green-700'
|
||||
}`}
|
||||
>
|
||||
{togglingPreviewTenant === tenant.tenantId
|
||||
@@ -573,7 +600,7 @@ export function TenantManagementSection({
|
||||
{(() => {
|
||||
const isEnterpriseTier = editingTenant.planTier === "enterprise";
|
||||
const trialActive = !!editingTenant.trialExpiresUtc &&
|
||||
new Date(editingTenant.trialExpiresUtc).getTime() > Date.now();
|
||||
new Date(editingTenant.trialExpiresUtc).getTime() > nowMs;
|
||||
const effective = isEnterpriseTier || trialActive ? "Enterprise" : "Community";
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
|
||||
@@ -670,7 +697,7 @@ export function TenantManagementSection({
|
||||
value={notificationEmail}
|
||||
onChange={(e) => setNotificationEmail(e.target.value)}
|
||||
placeholder="user@example.com"
|
||||
className="flex-1 min-w-0 px-3 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
|
||||
className="flex-1 min-w-0 px-3 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleSendWelcomeEmail(editingTenant.tenantId, notificationEmail)}
|
||||
@@ -718,7 +745,7 @@ export function TenantManagementSection({
|
||||
const v = e.target.value.trim();
|
||||
setEditingTenant({ ...editingTenant, customRateLimitRequestsPerMinute: v === "" ? null : (parseInt(v) || null) });
|
||||
}}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">Per-device (agent/cert) limit. Leave blank to inherit the global default.</p>
|
||||
</div>
|
||||
@@ -736,7 +763,7 @@ export function TenantManagementSection({
|
||||
const v = e.target.value.trim();
|
||||
setEditingTenant({ ...editingTenant, customUserRateLimitRequestsPerMinute: v === "" ? null : (parseInt(v) || null) });
|
||||
}}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">Per-user (portal) limit for standard users. Leave blank to inherit the global default. Does not apply to Global Admins.</p>
|
||||
</div>
|
||||
@@ -787,7 +814,7 @@ export function TenantManagementSection({
|
||||
const val = parseInt(e.target.value);
|
||||
setEditingTenant({ ...editingTenant, dataRetentionDays: isNaN(val) ? 90 : val });
|
||||
}}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
|
||||
/>
|
||||
{editingTenant.dataRetentionDays === 0 ? (
|
||||
<p className="text-xs text-amber-600 mt-1 font-medium">⚠ Infinite retention — data will never be automatically deleted</p>
|
||||
|
||||
@@ -16,7 +16,7 @@ export function TenantSearchSelect({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = "Search tenant by name or ID\u2026",
|
||||
focusRingClass = "focus:ring-blue-500 focus:border-blue-500",
|
||||
focusRingClass = "focus:ring-green-500 focus:border-green-500",
|
||||
}: TenantSearchSelectProps) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
@@ -472,7 +472,7 @@ export function UnmappedSoftwareTab({
|
||||
{autoResolving && autoResolveProgress && (
|
||||
<div className="mb-3 px-4 py-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-md text-sm">
|
||||
<div className="flex items-center gap-2 text-blue-700 dark:text-blue-300">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600" />
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-green-600" />
|
||||
<span>
|
||||
Auto-resolving... Batch {autoResolveProgress.batchIndex}/{autoResolveProgress.totalBatches}
|
||||
{autoResolveProgress.resolvedSoFar > 0 && <> · Resolved: {autoResolveProgress.resolvedSoFar}</>}
|
||||
@@ -532,7 +532,7 @@ export function UnmappedSoftwareTab({
|
||||
checked={allOnPageSelected && entries.length > 0}
|
||||
onChange={allOnPageSelected ? deselectAll : selectAllOnPage}
|
||||
disabled={autoResolving}
|
||||
className="h-4 w-4 rounded border-gray-300 text-amber-600 focus:ring-amber-500 disabled:opacity-40"
|
||||
className="h-4 w-4 rounded border-gray-300 text-green-600 focus:ring-green-500 disabled:opacity-40"
|
||||
/>
|
||||
</th>
|
||||
<th className="px-3 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Software Name</th>
|
||||
@@ -561,7 +561,7 @@ export function UnmappedSoftwareTab({
|
||||
checked={selectedEntries.has(key)}
|
||||
onChange={() => toggleRowSelection(entry)}
|
||||
disabled={autoResolving}
|
||||
className="h-4 w-4 rounded border-gray-300 text-amber-600 focus:ring-amber-500 disabled:opacity-40"
|
||||
className="h-4 w-4 rounded border-gray-300 text-green-600 focus:ring-green-500 disabled:opacity-40"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -517,7 +517,7 @@ export function VulnerabilityDataSection({
|
||||
<button
|
||||
onClick={handleSyncMsrc}
|
||||
disabled={!canMutate || syncingMsrc || syncing || reseedingSeed || reseedingCommunity}
|
||||
className="inline-flex items-center px-4 py-2 text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors shadow-sm"
|
||||
className="inline-flex items-center px-4 py-2 text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors shadow-sm"
|
||||
title="Refreshes the MSRC CVRF index (last 6 Patch Tuesdays from api.msrc.microsoft.com)"
|
||||
>
|
||||
{syncingMsrc ? (
|
||||
|
||||
@@ -143,7 +143,7 @@ function CustomsArchiveDetailContent() {
|
||||
type="button"
|
||||
onClick={load}
|
||||
disabled={loading}
|
||||
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white text-sm rounded-md transition-colors"
|
||||
className="px-3 py-1.5 bg-green-600 hover:bg-green-700 disabled:opacity-50 text-white text-sm rounded-md transition-colors"
|
||||
>
|
||||
{loading ? "Loading…" : "Refresh"}
|
||||
</button>
|
||||
|
||||
@@ -102,7 +102,7 @@ export default function CustomsArchivePage() {
|
||||
type="button"
|
||||
onClick={load}
|
||||
disabled={loading}
|
||||
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white text-sm rounded-md transition-colors"
|
||||
className="px-3 py-1.5 bg-green-600 hover:bg-green-700 disabled:opacity-50 text-white text-sm rounded-md transition-colors"
|
||||
>
|
||||
{loading ? "Loading…" : "Refresh"}
|
||||
</button>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useMemo, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { api } from '@/lib/api';
|
||||
import TruncatedLabel from '@/components/TruncatedLabel';
|
||||
import { useAuth } from '../../../../contexts/AuthContext';
|
||||
@@ -138,9 +137,7 @@ function pN(values: number[], percentile: number): number {
|
||||
// ── Component ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export function SectionAgentMetrics() {
|
||||
const router = useRouter();
|
||||
|
||||
const { getAccessToken, user } = useAuth();
|
||||
const { getAccessToken } = useAuth();
|
||||
const { addNotification } = useNotifications();
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -845,8 +842,6 @@ function StatCard({ label, value, detail, color }: { label: string; value: strin
|
||||
function FootprintBadge({
|
||||
value,
|
||||
thresholds,
|
||||
unit,
|
||||
formatFn,
|
||||
}: {
|
||||
value: number;
|
||||
thresholds: [number, number, number];
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { SegmentedControl, TIME_RANGE_OPTIONS } from "@/components/SegmentedControl";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useAuth } from "../../../../contexts/AuthContext";
|
||||
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
|
||||
@@ -134,21 +135,11 @@ export function SectionMcpUsage() {
|
||||
<p className="text-sm text-gray-500">API usage across all MCP-enabled users</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex rounded-md shadow-sm">
|
||||
{(["7d", "30d", "90d"] as DateRange[]).map((range) => (
|
||||
<button
|
||||
key={range}
|
||||
onClick={() => setDateRange(range)}
|
||||
className={`px-3 py-1.5 text-sm font-medium border ${
|
||||
dateRange === range
|
||||
? "bg-indigo-600 text-white border-indigo-600 z-10"
|
||||
: "bg-white text-gray-700 border-gray-300 hover:bg-gray-50"
|
||||
} ${range === "7d" ? "rounded-l-md" : ""} ${range === "90d" ? "rounded-r-md" : ""} -ml-px first:ml-0`}
|
||||
>
|
||||
{range}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<SegmentedControl
|
||||
options={TIME_RANGE_OPTIONS}
|
||||
value={dateRange}
|
||||
onChange={(v) => setDateRange(v as DateRange)}
|
||||
/>
|
||||
<button
|
||||
onClick={() => fetchData(dateRange)}
|
||||
disabled={loading}
|
||||
|
||||
@@ -186,7 +186,7 @@ export function SectionPlatformUsage() {
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center p-4">
|
||||
<div className="min-h-screen bg-[var(--lp-bg)] flex items-center justify-center p-4">
|
||||
<div className="bg-white rounded-lg shadow-xl p-8 max-w-md w-full">
|
||||
<div className="text-center">
|
||||
<svg className="h-12 w-12 text-red-500 mx-auto" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
@@ -197,7 +197,7 @@ export function SectionPlatformUsage() {
|
||||
<div className="mt-6 space-y-3">
|
||||
<button
|
||||
onClick={() => fetchMetrics()}
|
||||
className="w-full px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
className="w-full px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
|
||||
import { useAdminConfig } from "../../../AdminConfigContext";
|
||||
@@ -276,7 +276,7 @@ export function RestoreBrowserTab({ getAccessToken, setError, setSuccessMessage
|
||||
setSelectedTenantId("");
|
||||
}
|
||||
}}
|
||||
className="h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-purple-600 focus:ring-purple-500"
|
||||
className="h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-purple-600 accent-purple-600 focus:ring-purple-500"
|
||||
/>
|
||||
<label
|
||||
htmlFor="restore-browser-only-with-data"
|
||||
|
||||
+2
-2
@@ -136,7 +136,7 @@ export function RestoreConfirmDialog({
|
||||
placeholder="e.g. customer support — accidental delete"
|
||||
maxLength={200}
|
||||
disabled={submitting}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded text-sm text-gray-900 dark:text-white bg-white dark:bg-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-60"
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded text-sm text-gray-900 dark:text-white bg-white dark:bg-gray-900 focus:outline-none focus:ring-2 focus:ring-green-500 disabled:opacity-60"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Stored on the <code className="bg-gray-100 dark:bg-gray-700 px-1 rounded">deletion_restored</code> audit row when the real restore runs. Dry-runs are intentionally audit-free.
|
||||
@@ -174,7 +174,7 @@ export function RestoreConfirmDialog({
|
||||
<button
|
||||
onClick={() => submit(true)}
|
||||
disabled={submitting}
|
||||
className="px-3 py-2 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50"
|
||||
className="px-3 py-2 text-sm bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50"
|
||||
>
|
||||
{submitting ? "Running…" : dryRunResult ? "Run dry-run again" : "Run dry-run"}
|
||||
</button>
|
||||
|
||||
@@ -139,7 +139,6 @@ function TabButton({
|
||||
function InFlightTab({
|
||||
getAccessToken,
|
||||
setError,
|
||||
setSuccessMessage,
|
||||
}: {
|
||||
getAccessToken: (forceRefresh?: boolean) => Promise<string | null>;
|
||||
setError: (error: string | null) => void;
|
||||
@@ -191,7 +190,7 @@ function InFlightTab({
|
||||
renderRowActions={(row) => (
|
||||
<button
|
||||
onClick={() => setPreviewing(row)}
|
||||
className="px-3 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700"
|
||||
className="px-3 py-1 text-xs bg-green-600 text-white rounded hover:bg-green-700"
|
||||
>
|
||||
Preview
|
||||
</button>
|
||||
@@ -368,7 +367,7 @@ function StrandedTab({
|
||||
renderRowActions={(row) => (
|
||||
<button
|
||||
onClick={() => setPreviewing(row)}
|
||||
className="px-3 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700"
|
||||
className="px-3 py-1 text-xs bg-green-600 text-white rounded hover:bg-green-700"
|
||||
>
|
||||
Preview
|
||||
</button>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { notFound } from "next/navigation";
|
||||
import { REPORTS_NAV_SECTIONS, type ReportsSectionId } from "../reportsNavSections";
|
||||
import { type ReportsSectionId } from "../reportsNavSections";
|
||||
import { SectionSessionReports } from "../sections/SectionSessionReports";
|
||||
import { SectionDistressReports } from "../sections/SectionDistressReports";
|
||||
import { SectionUserFeedback } from "../sections/SectionUserFeedback";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { notFound } from "next/navigation";
|
||||
import { SECURITY_NAV_SECTIONS, type SecuritySectionId } from "../securityNavSections";
|
||||
import { type SecuritySectionId } from "../securityNavSections";
|
||||
import { SectionDeviceBlock } from "../sections/SectionDeviceBlock";
|
||||
import { SectionVersionBlock } from "../sections/SectionVersionBlock";
|
||||
import { SectionVulnerabilityData } from "../sections/SectionVulnerabilityData";
|
||||
|
||||
@@ -144,7 +144,7 @@ export function SectionUsagePlans() {
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving || !hasChanges}
|
||||
className="px-4 py-2 text-sm bg-indigo-600 text-white rounded-md hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
className="px-4 py-2 text-sm bg-green-600 text-white rounded-md hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{saving ? "Saving..." : "Save Changes"}
|
||||
</button>
|
||||
@@ -182,7 +182,7 @@ export function SectionUsagePlans() {
|
||||
<p className="text-gray-500 mb-4">No plan tiers defined yet.</p>
|
||||
<button
|
||||
onClick={addTier}
|
||||
className="px-4 py-2 text-sm bg-indigo-600 text-white rounded-md hover:bg-indigo-700 transition-colors"
|
||||
className="px-4 py-2 text-sm bg-green-600 text-white rounded-md hover:bg-green-700 transition-colors"
|
||||
>
|
||||
Create First Plan Tier
|
||||
</button>
|
||||
@@ -203,7 +203,7 @@ export function SectionUsagePlans() {
|
||||
value={tier.name}
|
||||
onChange={(e) => updateTier(index, "name", e.target.value)}
|
||||
placeholder="e.g., free, pro, enterprise"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||
/>
|
||||
</div>
|
||||
{/* Daily Limit */}
|
||||
@@ -214,7 +214,7 @@ export function SectionUsagePlans() {
|
||||
min={0}
|
||||
value={tier.dailyRequestLimit}
|
||||
onChange={(e) => updateTier(index, "dailyRequestLimit", parseInt(e.target.value) || 0)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||
/>
|
||||
</div>
|
||||
{/* Monthly Limit */}
|
||||
@@ -225,7 +225,7 @@ export function SectionUsagePlans() {
|
||||
min={0}
|
||||
value={tier.monthlyRequestLimit}
|
||||
onChange={(e) => updateTier(index, "monthlyRequestLimit", parseInt(e.target.value) || 0)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||
/>
|
||||
</div>
|
||||
{/* Description */}
|
||||
@@ -236,7 +236,7 @@ export function SectionUsagePlans() {
|
||||
value={tier.description}
|
||||
onChange={(e) => updateTier(index, "description", e.target.value)}
|
||||
placeholder="Brief description"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+4
-3
@@ -335,6 +335,9 @@ export function SectionTenantConfigReport() {
|
||||
|
||||
const [tenants, setTenants] = useState<TenantInfo[]>([]);
|
||||
const [selectedTenantId, setSelectedTenantId] = useState('');
|
||||
// Mount-time clock for trial-expiry checks — render must stay pure
|
||||
// (react-hooks/purity); day-granularity expiry doesn't need a live clock.
|
||||
const [nowMs] = useState(() => Date.now());
|
||||
const [config, setConfig] = useState<TenantConfig | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadingTenants, setLoadingTenants] = useState(true);
|
||||
@@ -426,8 +429,6 @@ export function SectionTenantConfigReport() {
|
||||
})()
|
||||
: [];
|
||||
|
||||
const selectedTenant = tenants.find((t) => t.tenantId === selectedTenantId);
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto px-4 py-8">
|
||||
{/* Header */}
|
||||
@@ -533,7 +534,7 @@ export function SectionTenantConfigReport() {
|
||||
<ConfigRow label="Onboarded At" value={config.onboardedAt} isDate />
|
||||
<ConfigRow
|
||||
label="Plan Tier"
|
||||
value={`${config.planTier || 'free'}${config.trialExpiresUtc && new Date(config.trialExpiresUtc).getTime() > Date.now() ? ` (trial until ${formatDate(config.trialExpiresUtc)})` : ''}`}
|
||||
value={`${config.planTier || 'free'}${config.trialExpiresUtc && new Date(config.trialExpiresUtc).getTime() > nowMs ? ` (trial until ${formatDate(config.trialExpiresUtc)})` : ''}`}
|
||||
configKey="planTier"
|
||||
defaults={DEFAULTS}
|
||||
/>
|
||||
|
||||
@@ -578,7 +578,7 @@ export default function AnalyzeRuleCard({
|
||||
<span>Export</span>
|
||||
</button>
|
||||
{canEdit && (
|
||||
<button onClick={() => onStartEditing(rule)} className="px-4 py-2 text-sm bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors flex items-center space-x-2" title="Edit rule">
|
||||
<button onClick={() => onStartEditing(rule)} className="px-4 py-2 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors flex items-center space-x-2" title="Edit rule">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /></svg>
|
||||
<span>Edit</span>
|
||||
</button>
|
||||
@@ -655,7 +655,7 @@ export default function AnalyzeRuleCard({
|
||||
} else {
|
||||
onSaveEdit(rule);
|
||||
}
|
||||
}} disabled={saving} className="px-6 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center space-x-2 text-sm font-medium">
|
||||
}} disabled={saving} className="px-6 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center space-x-2 text-sm font-medium">
|
||||
{saving ? (<><div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div><span>Saving...</span></>) : (<span>Save Changes</span>)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
+32
-32
@@ -25,7 +25,7 @@ export default function AnalyzeRuleFormFields({ form, setForm, showRuleId, exist
|
||||
{showRuleId && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Rule ID <span className="text-red-500">*</span></label>
|
||||
<input type="text" value={form.ruleId} onChange={(e) => setForm({ ...form, ruleId: e.target.value })} placeholder="e.g., ANALYZE-CUSTOM-001" autoComplete="off" className={`w-full px-4 py-2 border rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 ${isDuplicate ? "border-red-400 focus:ring-red-300 focus:border-red-400" : "border-gray-300 focus:ring-indigo-500 focus:border-indigo-500"}`} />
|
||||
<input type="text" value={form.ruleId} onChange={(e) => setForm({ ...form, ruleId: e.target.value })} placeholder="e.g., ANALYZE-CUSTOM-001" autoComplete="off" className={`w-full px-4 py-2 border rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 ${isDuplicate ? "border-red-400 focus:ring-red-300 focus:border-red-400" : "border-gray-300 focus:ring-green-500 focus:border-green-500"}`} />
|
||||
{isDuplicate && (
|
||||
<p className="mt-1 text-xs text-red-600">A rule with this ID already exists. Please choose a unique Rule ID.</p>
|
||||
)}
|
||||
@@ -33,31 +33,31 @@ export default function AnalyzeRuleFormFields({ form, setForm, showRuleId, exist
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Title <span className="text-red-500">*</span></label>
|
||||
<input type="text" value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} placeholder="e.g., Proxy Authentication Failure" autoComplete="off" className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500" />
|
||||
<input type="text" value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} placeholder="e.g., Proxy Authentication Failure" autoComplete="off" className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Description</label>
|
||||
<textarea value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} placeholder="Describe what this rule detects..." rows={2} className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 resize-none" />
|
||||
<textarea value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} placeholder="Describe what this rule detects..." rows={2} className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 resize-none" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Severity</label>
|
||||
<select value={form.severity} onChange={(e) => setForm({ ...form, severity: e.target.value })} className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 bg-white focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500">
|
||||
<select value={form.severity} onChange={(e) => setForm({ ...form, severity: e.target.value })} className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 bg-white focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500">
|
||||
{SEVERITIES.map((s) => (<option key={s} value={s}>{s.charAt(0).toUpperCase() + s.slice(1)}</option>))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Category</label>
|
||||
<select value={form.category} onChange={(e) => setForm({ ...form, category: e.target.value })} className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 bg-white focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500">
|
||||
<select value={form.category} onChange={(e) => setForm({ ...form, category: e.target.value })} className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 bg-white focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500">
|
||||
{CATEGORIES.map((cat) => (<option key={cat} value={cat}>{cat.charAt(0).toUpperCase() + cat.slice(1)}</option>))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Trigger Type</label>
|
||||
<select value={form.trigger} onChange={(e) => setForm({ ...form, trigger: e.target.value })} className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 bg-white focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500">
|
||||
<select value={form.trigger} onChange={(e) => setForm({ ...form, trigger: e.target.value })} className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 bg-white focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500">
|
||||
{TRIGGERS.map((t) => (<option key={t} value={t}>{t.charAt(0).toUpperCase() + t.slice(1)}</option>))}
|
||||
</select>
|
||||
</div>
|
||||
@@ -70,7 +70,7 @@ export default function AnalyzeRuleFormFields({ form, setForm, showRuleId, exist
|
||||
<label className="block text-sm font-semibold text-gray-700">Preconditions</label>
|
||||
<p className="text-xs text-gray-500">Optional. Rule is silently skipped (no result) when ANY precondition fails. Useful e.g. to skip on virtual machines.</p>
|
||||
</div>
|
||||
<button type="button" onClick={() => setForm({ ...form, preconditions: [...form.preconditions, { ...EMPTY_PRECONDITION }] })} className="text-xs text-indigo-600 hover:text-indigo-800 font-medium">+ Add Precondition</button>
|
||||
<button type="button" onClick={() => setForm({ ...form, preconditions: [...form.preconditions, { ...EMPTY_PRECONDITION }] })} className="text-xs text-green-700 hover:text-green-800 font-medium">+ Add Precondition</button>
|
||||
</div>
|
||||
{form.preconditions.length === 0 ? (
|
||||
<p className="text-xs text-gray-400 italic">No preconditions. Rule applies to every device.</p>
|
||||
@@ -108,7 +108,7 @@ export default function AnalyzeRuleFormFields({ form, setForm, showRuleId, exist
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="block text-sm font-semibold text-gray-700">Conditions</label>
|
||||
<button type="button" onClick={() => setForm({ ...form, conditions: [...form.conditions, { ...EMPTY_CONDITION }] })} className="text-xs text-indigo-600 hover:text-indigo-800 font-medium">+ Add Condition</button>
|
||||
<button type="button" onClick={() => setForm({ ...form, conditions: [...form.conditions, { ...EMPTY_CONDITION }] })} className="text-xs text-green-700 hover:text-green-800 font-medium">+ Add Condition</button>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{form.conditions.map((cond, idx) => {
|
||||
@@ -134,22 +134,22 @@ export default function AnalyzeRuleFormFields({ form, setForm, showRuleId, exist
|
||||
|
||||
{/* Row 1: Signal, Source, Event Type A */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||
<input type="text" value={cond.signal} onChange={(e) => updateCond({ signal: e.target.value })} placeholder="Signal name" autoComplete="off" className="px-3 py-1.5 border border-gray-300 rounded text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-indigo-500" />
|
||||
<select value={cond.source} onChange={(e) => updateCond({ source: e.target.value })} className="px-3 py-1.5 border border-gray-300 rounded text-sm text-gray-900 bg-white focus:outline-none focus:ring-1 focus:ring-indigo-500">
|
||||
<input type="text" value={cond.signal} onChange={(e) => updateCond({ signal: e.target.value })} placeholder="Signal name" autoComplete="off" className="px-3 py-1.5 border border-gray-300 rounded text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-green-500" />
|
||||
<select value={cond.source} onChange={(e) => updateCond({ source: e.target.value })} className="px-3 py-1.5 border border-gray-300 rounded text-sm text-gray-900 bg-white focus:outline-none focus:ring-1 focus:ring-green-500">
|
||||
{SOURCES.map((s) => (<option key={s} value={s}>{s}</option>))}
|
||||
</select>
|
||||
<input type="text" value={cond.eventType} onChange={(e) => updateCond({ eventType: e.target.value })} placeholder={isCorrelation ? "Event A type (e.g. app_install_completed)" : "Event type"} autoComplete="off" className="px-3 py-1.5 border border-gray-300 rounded text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-indigo-500" />
|
||||
<input type="text" value={cond.eventType} onChange={(e) => updateCond({ eventType: e.target.value })} placeholder={isCorrelation ? "Event A type (e.g. app_install_completed)" : "Event type"} autoComplete="off" className="px-3 py-1.5 border border-gray-300 rounded text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-green-500" />
|
||||
</div>
|
||||
|
||||
{/* Row 2: Data field / Operator / Value / Required */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-4 gap-2">
|
||||
<input type="text" value={cond.dataField} onChange={(e) => updateCond({ dataField: e.target.value })} placeholder={isCorrelation ? "Filter field on Event B" : isArray ? "Array field (e.g. artifacts)" : "Data field"} autoComplete="off" className="px-3 py-1.5 border border-gray-300 rounded text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-indigo-500" />
|
||||
<select value={cond.operator} onChange={(e) => updateCond({ operator: e.target.value })} className="px-3 py-1.5 border border-gray-300 rounded text-sm text-gray-900 bg-white focus:outline-none focus:ring-1 focus:ring-indigo-500">
|
||||
<input type="text" value={cond.dataField} onChange={(e) => updateCond({ dataField: e.target.value })} placeholder={isCorrelation ? "Filter field on Event B" : isArray ? "Array field (e.g. artifacts)" : "Data field"} autoComplete="off" className="px-3 py-1.5 border border-gray-300 rounded text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-green-500" />
|
||||
<select value={cond.operator} onChange={(e) => updateCond({ operator: e.target.value })} className="px-3 py-1.5 border border-gray-300 rounded text-sm text-gray-900 bg-white focus:outline-none focus:ring-1 focus:ring-green-500">
|
||||
{OPERATORS.map((o) => (<option key={o} value={o}>{o}</option>))}
|
||||
</select>
|
||||
<input type="text" value={cond.value} onChange={(e) => updateCond({ value: e.target.value })} placeholder={isCorrelation ? "Filter value on Event B" : isArray ? "Value / allow-list regex" : "Value"} autoComplete="off" className="px-3 py-1.5 border border-gray-300 rounded text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-indigo-500" />
|
||||
<input type="text" value={cond.value} onChange={(e) => updateCond({ value: e.target.value })} placeholder={isCorrelation ? "Filter value on Event B" : isArray ? "Value / allow-list regex" : "Value"} autoComplete="off" className="px-3 py-1.5 border border-gray-300 rounded text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-green-500" />
|
||||
<label className="flex items-center space-x-2 text-sm text-gray-700">
|
||||
<input type="checkbox" checked={cond.required} onChange={(e) => updateCond({ required: e.target.checked })} className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500" />
|
||||
<input type="checkbox" checked={cond.required} onChange={(e) => updateCond({ required: e.target.checked })} className="rounded border-gray-300 text-green-600 focus:ring-green-500" />
|
||||
<span>Required</span>
|
||||
</label>
|
||||
</div>
|
||||
@@ -163,15 +163,15 @@ export default function AnalyzeRuleFormFields({ form, setForm, showRuleId, exist
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">Event B type <span className="text-red-500">*</span></label>
|
||||
<input type="text" value={cond.correlateEventType ?? ""} onChange={(e) => updateCond({ correlateEventType: e.target.value })} placeholder="e.g. app_install_failed" autoComplete="off" className="w-full px-3 py-1.5 border border-indigo-300 rounded text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-indigo-500" />
|
||||
<input type="text" value={cond.correlateEventType ?? ""} onChange={(e) => updateCond({ correlateEventType: e.target.value })} placeholder="e.g. app_install_failed" autoComplete="off" className="w-full px-3 py-1.5 border border-indigo-300 rounded text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-green-500" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">Join field <span className="text-red-500">*</span></label>
|
||||
<input type="text" value={cond.joinField ?? ""} onChange={(e) => updateCond({ joinField: e.target.value })} placeholder="e.g. appId" autoComplete="off" className="w-full px-3 py-1.5 border border-indigo-300 rounded text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-indigo-500" />
|
||||
<input type="text" value={cond.joinField ?? ""} onChange={(e) => updateCond({ joinField: e.target.value })} placeholder="e.g. appId" autoComplete="off" className="w-full px-3 py-1.5 border border-indigo-300 rounded text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-green-500" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">Time window (seconds, optional)</label>
|
||||
<input type="number" min={0} value={cond.timeWindowSeconds ?? ""} onChange={(e) => updateCond({ timeWindowSeconds: e.target.value === "" ? null : parseInt(e.target.value) || 0 })} placeholder="e.g. 300" className="w-full px-3 py-1.5 border border-indigo-300 rounded text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-indigo-500" />
|
||||
<input type="number" min={0} value={cond.timeWindowSeconds ?? ""} onChange={(e) => updateCond({ timeWindowSeconds: e.target.value === "" ? null : parseInt(e.target.value) || 0 })} placeholder="e.g. 300" className="w-full px-3 py-1.5 border border-indigo-300 rounded text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-green-500" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -179,11 +179,11 @@ export default function AnalyzeRuleFormFields({ form, setForm, showRuleId, exist
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">Event A filter (optional)</label>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||
<input type="text" value={cond.eventAFilterField ?? ""} onChange={(e) => updateCond({ eventAFilterField: e.target.value })} placeholder="Filter field on Event A" autoComplete="off" className="px-3 py-1.5 border border-indigo-300 rounded text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-indigo-500" />
|
||||
<select value={cond.eventAFilterOperator ?? "equals"} onChange={(e) => updateCond({ eventAFilterOperator: e.target.value })} className="px-3 py-1.5 border border-indigo-300 rounded text-sm text-gray-900 bg-white focus:outline-none focus:ring-1 focus:ring-indigo-500">
|
||||
<input type="text" value={cond.eventAFilterField ?? ""} onChange={(e) => updateCond({ eventAFilterField: e.target.value })} placeholder="Filter field on Event A" autoComplete="off" className="px-3 py-1.5 border border-indigo-300 rounded text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-green-500" />
|
||||
<select value={cond.eventAFilterOperator ?? "equals"} onChange={(e) => updateCond({ eventAFilterOperator: e.target.value })} className="px-3 py-1.5 border border-indigo-300 rounded text-sm text-gray-900 bg-white focus:outline-none focus:ring-1 focus:ring-green-500">
|
||||
{OPERATORS.map((o) => (<option key={o} value={o}>{o}</option>))}
|
||||
</select>
|
||||
<input type="text" value={cond.eventAFilterValue ?? ""} onChange={(e) => updateCond({ eventAFilterValue: e.target.value })} placeholder="Filter value on Event A" autoComplete="off" className="px-3 py-1.5 border border-indigo-300 rounded text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-indigo-500" />
|
||||
<input type="text" value={cond.eventAFilterValue ?? ""} onChange={(e) => updateCond({ eventAFilterValue: e.target.value })} placeholder="Filter value on Event A" autoComplete="off" className="px-3 py-1.5 border border-indigo-300 rounded text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-green-500" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -212,11 +212,11 @@ export default function AnalyzeRuleFormFields({ form, setForm, showRuleId, exist
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Base Confidence (%)</label>
|
||||
<input type="number" min={0} max={100} value={form.baseConfidence} onChange={(e) => setForm({ ...form, baseConfidence: parseInt(e.target.value) || 0 })} className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500" />
|
||||
<input type="number" min={0} max={100} value={form.baseConfidence} onChange={(e) => setForm({ ...form, baseConfidence: parseInt(e.target.value) || 0 })} className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Confidence Threshold (%)</label>
|
||||
<input type="number" min={0} max={100} value={form.confidenceThreshold} onChange={(e) => setForm({ ...form, confidenceThreshold: parseInt(e.target.value) || 0 })} className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500" />
|
||||
<input type="number" min={0} max={100} value={form.confidenceThreshold} onChange={(e) => setForm({ ...form, confidenceThreshold: parseInt(e.target.value) || 0 })} className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -224,7 +224,7 @@ export default function AnalyzeRuleFormFields({ form, setForm, showRuleId, exist
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="block text-sm font-semibold text-gray-700">Confidence Factors</label>
|
||||
<button type="button" onClick={() => setForm({ ...form, confidenceFactors: [...form.confidenceFactors, { ...EMPTY_FACTOR }] })} className="text-xs text-indigo-600 hover:text-indigo-800 font-medium">+ Add Factor</button>
|
||||
<button type="button" onClick={() => setForm({ ...form, confidenceFactors: [...form.confidenceFactors, { ...EMPTY_FACTOR }] })} className="text-xs text-green-700 hover:text-green-800 font-medium">+ Add Factor</button>
|
||||
</div>
|
||||
{form.confidenceFactors.length === 0 ? (
|
||||
<p className="text-xs text-gray-400 italic">No confidence factors. Click "+ Add Factor" to add one.</p>
|
||||
@@ -232,9 +232,9 @@ export default function AnalyzeRuleFormFields({ form, setForm, showRuleId, exist
|
||||
<div className="space-y-2">
|
||||
{form.confidenceFactors.map((factor, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2">
|
||||
<input type="text" value={factor.signal} onChange={(e) => { const f = [...form.confidenceFactors]; f[idx] = { ...f[idx], signal: e.target.value }; setForm({ ...form, confidenceFactors: f }); }} placeholder="Signal" autoComplete="off" className="flex-1 px-3 py-1.5 border border-gray-300 rounded text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-indigo-500" />
|
||||
<input type="text" value={factor.condition} onChange={(e) => { const f = [...form.confidenceFactors]; f[idx] = { ...f[idx], condition: e.target.value }; setForm({ ...form, confidenceFactors: f }); }} placeholder="Condition" autoComplete="off" className="flex-1 px-3 py-1.5 border border-gray-300 rounded text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-indigo-500" />
|
||||
<input type="number" value={factor.weight} onChange={(e) => { const f = [...form.confidenceFactors]; f[idx] = { ...f[idx], weight: parseInt(e.target.value) || 0 }; setForm({ ...form, confidenceFactors: f }); }} className="w-20 px-3 py-1.5 border border-gray-300 rounded text-sm text-gray-900 focus:outline-none focus:ring-1 focus:ring-indigo-500" />
|
||||
<input type="text" value={factor.signal} onChange={(e) => { const f = [...form.confidenceFactors]; f[idx] = { ...f[idx], signal: e.target.value }; setForm({ ...form, confidenceFactors: f }); }} placeholder="Signal" autoComplete="off" className="flex-1 px-3 py-1.5 border border-gray-300 rounded text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-green-500" />
|
||||
<input type="text" value={factor.condition} onChange={(e) => { const f = [...form.confidenceFactors]; f[idx] = { ...f[idx], condition: e.target.value }; setForm({ ...form, confidenceFactors: f }); }} placeholder="Condition" autoComplete="off" className="flex-1 px-3 py-1.5 border border-gray-300 rounded text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-green-500" />
|
||||
<input type="number" value={factor.weight} onChange={(e) => { const f = [...form.confidenceFactors]; f[idx] = { ...f[idx], weight: parseInt(e.target.value) || 0 }; setForm({ ...form, confidenceFactors: f }); }} className="w-20 px-3 py-1.5 border border-gray-300 rounded text-sm text-gray-900 focus:outline-none focus:ring-1 focus:ring-green-500" />
|
||||
<span className="text-xs text-gray-500">%</span>
|
||||
<button type="button" onClick={() => setForm({ ...form, confidenceFactors: form.confidenceFactors.filter((_, i) => i !== idx) })} className="text-red-400 hover:text-red-600">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /></svg>
|
||||
@@ -248,14 +248,14 @@ export default function AnalyzeRuleFormFields({ form, setForm, showRuleId, exist
|
||||
{/* Explanation */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Explanation</label>
|
||||
<textarea value={form.explanation} onChange={(e) => setForm({ ...form, explanation: e.target.value })} placeholder="Detailed explanation shown when this rule fires..." rows={3} className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 resize-none" />
|
||||
<textarea value={form.explanation} onChange={(e) => setForm({ ...form, explanation: e.target.value })} placeholder="Detailed explanation shown when this rule fires..." rows={3} className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 resize-none" />
|
||||
</div>
|
||||
|
||||
{/* Remediation Steps */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="block text-sm font-semibold text-gray-700">Remediation Steps</label>
|
||||
<button type="button" onClick={() => setForm({ ...form, remediation: [...form.remediation, { title: "", steps: [""] }] })} className="text-xs text-indigo-600 hover:text-indigo-800 font-medium">+ Add Section</button>
|
||||
<button type="button" onClick={() => setForm({ ...form, remediation: [...form.remediation, { title: "", steps: [""] }] })} className="text-xs text-green-700 hover:text-green-800 font-medium">+ Add Section</button>
|
||||
</div>
|
||||
{form.remediation.length === 0 ? (
|
||||
<p className="text-xs text-gray-400 italic">No remediation steps. Click "+ Add Section" to add one.</p>
|
||||
@@ -289,7 +289,7 @@ export default function AnalyzeRuleFormFields({ form, setForm, showRuleId, exist
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="block text-sm font-semibold text-gray-700">Related Documentation</label>
|
||||
<button type="button" onClick={() => setForm({ ...form, relatedDocs: [...form.relatedDocs, { title: "", url: "" }] })} className="text-xs text-indigo-600 hover:text-indigo-800 font-medium">+ Add Link</button>
|
||||
<button type="button" onClick={() => setForm({ ...form, relatedDocs: [...form.relatedDocs, { title: "", url: "" }] })} className="text-xs text-green-700 hover:text-green-800 font-medium">+ Add Link</button>
|
||||
</div>
|
||||
{form.relatedDocs.length === 0 ? (
|
||||
<p className="text-xs text-gray-400 italic">No related docs. Click "+ Add Link" to add one.</p>
|
||||
@@ -297,8 +297,8 @@ export default function AnalyzeRuleFormFields({ form, setForm, showRuleId, exist
|
||||
<div className="space-y-2">
|
||||
{form.relatedDocs.map((doc, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2">
|
||||
<input type="text" value={doc.title} onChange={(e) => { const d = [...form.relatedDocs]; d[idx] = { ...d[idx], title: e.target.value }; setForm({ ...form, relatedDocs: d }); }} placeholder="Link title" autoComplete="off" className="flex-1 px-3 py-1.5 border border-gray-300 rounded text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-indigo-500" />
|
||||
<input type="text" value={doc.url} onChange={(e) => { const d = [...form.relatedDocs]; d[idx] = { ...d[idx], url: e.target.value }; setForm({ ...form, relatedDocs: d }); }} placeholder="https://..." autoComplete="off" className="flex-1 px-3 py-1.5 border border-gray-300 rounded text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-indigo-500" />
|
||||
<input type="text" value={doc.title} onChange={(e) => { const d = [...form.relatedDocs]; d[idx] = { ...d[idx], title: e.target.value }; setForm({ ...form, relatedDocs: d }); }} placeholder="Link title" autoComplete="off" className="flex-1 px-3 py-1.5 border border-gray-300 rounded text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-green-500" />
|
||||
<input type="text" value={doc.url} onChange={(e) => { const d = [...form.relatedDocs]; d[idx] = { ...d[idx], url: e.target.value }; setForm({ ...form, relatedDocs: d }); }} placeholder="https://..." autoComplete="off" className="flex-1 px-3 py-1.5 border border-gray-300 rounded text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-green-500" />
|
||||
<button type="button" onClick={() => setForm({ ...form, relatedDocs: form.relatedDocs.filter((_, i) => i !== idx) })} className="text-red-400 hover:text-red-600">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /></svg>
|
||||
</button>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { AnalyzeRule, TemplateVariable } from "../types";
|
||||
import { AnalyzeRule } from "../types";
|
||||
|
||||
interface TemplateConfigModalProps {
|
||||
rule: AnalyzeRule;
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ProtectedRoute } from "../../components/ProtectedRoute";
|
||||
import { useAuth } from "../../contexts/AuthContext";
|
||||
import { api } from "@/lib/api";
|
||||
@@ -25,8 +23,6 @@ import TemplateConfigModal from "./components/TemplateConfigModal";
|
||||
import { DOCS_URL } from "@/utils/config";
|
||||
|
||||
export default function AnalyzeRulesPage() {
|
||||
const router = useRouter();
|
||||
|
||||
const { user, getAccessToken } = useAuth();
|
||||
|
||||
const { successMessage, error, showSuccess, showError } = useNotificationMessages();
|
||||
@@ -563,7 +559,7 @@ export default function AnalyzeRulesPage() {
|
||||
</svg>
|
||||
<p className="text-sm text-blue-800">
|
||||
Missing a rule, found a bug, or have an improvement idea? Help make Autopilot Monitor better —{" "}
|
||||
<a href="https://github.com/okieselbach/Autopilot-Monitor/issues" target="_blank" rel="noopener noreferrer" className="font-medium underline hover:text-blue-900">
|
||||
<a href="https://github.com/okieselbach/AutopilotMonitor/issues" target="_blank" rel="noopener noreferrer" className="font-medium underline hover:text-blue-900">
|
||||
open a GitHub issue
|
||||
</a>.
|
||||
</p>
|
||||
@@ -749,7 +745,7 @@ export default function AnalyzeRulesPage() {
|
||||
<svg className="w-6 h-6 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" /></svg>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900">Create Custom Analyze Rule</h2>
|
||||
<p className="text-sm text-gray-500 mt-1">Define conditions and confidence scoring for issue detection — <a href={`${DOCS_URL}/rules/analyze-rules`} target="_blank" rel="noopener noreferrer" className="text-indigo-600 hover:text-indigo-800 underline">see documentation</a></p>
|
||||
<p className="text-sm text-gray-500 mt-1">Define conditions and confidence scoring for issue detection — <a href={`${DOCS_URL}/rules/analyze-rules`} target="_blank" rel="noopener noreferrer" className="text-green-700 hover:text-green-800 underline">see documentation</a></p>
|
||||
</div>
|
||||
</div>
|
||||
{/* JSON Mode Toggle */}
|
||||
@@ -804,7 +800,7 @@ export default function AnalyzeRulesPage() {
|
||||
} else {
|
||||
handleCreateRule();
|
||||
}
|
||||
}} disabled={creating} className="px-6 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center space-x-2 text-sm font-medium">
|
||||
}} disabled={creating} className="px-6 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center space-x-2 text-sm font-medium">
|
||||
{creating ? (<><div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div><span>Creating...</span></>) : (<span>Save Rule</span>)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 9.6 KiB |
@@ -254,7 +254,7 @@ export default function InstallsTab({ scope, timeRange }: InstallsTabProps) {
|
||||
placeholder="Search apps by name…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -138,7 +138,7 @@ export default function InventoryTab({ scope }: { scope: SoftwareTabScope }) {
|
||||
placeholder="Search software by name or publisher…"
|
||||
value={search}
|
||||
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -376,11 +376,6 @@ function AppDetailContent() {
|
||||
return `${d.getUTCMonth() + 1}/${d.getUTCDate()}`;
|
||||
}
|
||||
|
||||
const failureCodeBarColor = (row: Record<string, unknown>): string => {
|
||||
const c = Number(row.count);
|
||||
return c >= 5 ? chartColors.danger : c >= 2 ? chartColors.warning : chartColors.muted;
|
||||
};
|
||||
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
@@ -392,7 +387,7 @@ function AppDetailContent() {
|
||||
// Tenant scope is carried in sessionStorage, so the list restores it on its own.
|
||||
router.push("/apps");
|
||||
}}
|
||||
className="text-sm text-blue-600 hover:underline mb-2 inline-flex items-center"
|
||||
className="text-sm text-green-700 hover:underline mb-2 inline-flex items-center"
|
||||
>
|
||||
← Back to all apps
|
||||
</button>
|
||||
@@ -420,7 +415,7 @@ function AppDetailContent() {
|
||||
onClick={() => setDays(d)}
|
||||
className={`px-4 py-2 text-sm rounded-md transition-colors ${
|
||||
days === d
|
||||
? "bg-blue-600 text-white"
|
||||
? "bg-green-600 text-white"
|
||||
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
|
||||
}`}
|
||||
>
|
||||
@@ -693,7 +688,7 @@ function AppDetailContent() {
|
||||
}}
|
||||
className={`px-3 py-1 text-xs rounded-md ${
|
||||
statusFilter === s
|
||||
? "bg-blue-600 text-white"
|
||||
? "bg-green-600 text-white"
|
||||
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
|
||||
}`}
|
||||
>
|
||||
@@ -738,7 +733,7 @@ function AppDetailContent() {
|
||||
type="checkbox"
|
||||
checked={visibleSessionColumns.has(col.key)}
|
||||
onChange={() => toggleSessionColumn(col.key)}
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500 h-3.5 w-3.5"
|
||||
className="rounded border-gray-300 text-green-600 focus:ring-green-500 h-3.5 w-3.5"
|
||||
/>
|
||||
<span className="flex-1">{col.label}</span>
|
||||
{col.globalOnly && (
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ProtectedRoute } from "../../components/ProtectedRoute";
|
||||
import { useAggregatedAdminScope } from "@/hooks";
|
||||
import { GlobalAdminBanner, globalAdminSubtitle } from "@/components/GlobalAdminBanner";
|
||||
import { TenantScopeSelector } from "@/components/TenantScopeSelector";
|
||||
import { SegmentedControl, TIME_RANGE_OPTIONS } from "@/components/SegmentedControl";
|
||||
import InstallsTab from "./components/InstallsTab";
|
||||
import InventoryTab from "./components/InventoryTab";
|
||||
import VulnerabilitiesTab from "./components/VulnerabilitiesTab";
|
||||
@@ -39,26 +40,20 @@ function SoftwareHub() {
|
||||
<GlobalAdminBanner show={scope.isGlobalAdmin} delegated={scope.isDelegatedScope} subtitle={globalAdminSubtitle(scope)} />
|
||||
<header className="bg-white shadow">
|
||||
<div className="max-w-7xl mx-auto pt-6 px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-wrap items-center justify-between gap-y-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-normal text-gray-900">Software</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
App installs, the installed-software inventory, and vulnerability exposure across enrollments.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<TenantScopeSelector scope={scope} allowAggregated />
|
||||
{(["7d", "30d", "90d"] as const).map((range) => (
|
||||
<button
|
||||
key={range}
|
||||
onClick={() => setTimeRange(range)}
|
||||
className={`px-4 py-2 text-sm rounded-md transition-colors ${
|
||||
timeRange === range ? "bg-blue-600 text-white" : "bg-gray-100 text-gray-700 hover:bg-gray-200"
|
||||
}`}
|
||||
>
|
||||
{range === "7d" ? "7 Days" : range === "30d" ? "30 Days" : "90 Days"}
|
||||
</button>
|
||||
))}
|
||||
<SegmentedControl
|
||||
options={TIME_RANGE_OPTIONS}
|
||||
value={timeRange}
|
||||
onChange={(v) => setTimeRange(v as typeof timeRange)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -70,7 +65,7 @@ function SoftwareHub() {
|
||||
onClick={() => selectTab(t.id)}
|
||||
className={`-mb-px border-b-2 px-1 py-3 text-sm font-medium transition-colors ${
|
||||
activeTab === t.id
|
||||
? "border-blue-600 text-blue-700"
|
||||
? "border-green-600 text-green-700"
|
||||
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { Fragment, useCallback, useEffect, useState } from 'react';
|
||||
import { TableSkeleton } from '@/components/skeletons/TableSkeleton';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { useNotifications } from '../../contexts/NotificationContext';
|
||||
import { ProtectedRoute } from '../../components/ProtectedRoute';
|
||||
@@ -63,8 +62,6 @@ function dateInputToIsoEnd(value: string): string {
|
||||
}
|
||||
|
||||
export default function AuditPage() {
|
||||
const router = useRouter();
|
||||
|
||||
const { getAccessToken } = useAuth();
|
||||
const { addNotification } = useNotifications();
|
||||
|
||||
@@ -248,7 +245,7 @@ export default function AuditPage() {
|
||||
/>
|
||||
<header className="bg-white shadow">
|
||||
<div className="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-wrap items-center justify-between gap-y-3">
|
||||
<div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-normal text-gray-900">Audit Log</h1>
|
||||
@@ -262,7 +259,7 @@ export default function AuditPage() {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<TenantScopeSelector scope={scope} allowAggregated />
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
@@ -289,14 +286,14 @@ export default function AuditPage() {
|
||||
type="date"
|
||||
value={isoToDateInputValue(dateFromIso)}
|
||||
onChange={(e) => setDateFromIso(dateInputToIsoStart(e.target.value))}
|
||||
className="px-3 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
className="px-3 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||
/>
|
||||
<label className="text-sm text-gray-600 dark:text-gray-400">To:</label>
|
||||
<input
|
||||
type="date"
|
||||
value={isoToDateInputValue(dateToIso)}
|
||||
onChange={(e) => setDateToIso(dateInputToIsoEnd(e.target.value))}
|
||||
className="px-3 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
className="px-3 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -310,7 +307,7 @@ export default function AuditPage() {
|
||||
placeholder="Search by user, entity, action or details (this page only)..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
className="w-full pl-10 pr-4 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -318,7 +315,7 @@ export default function AuditPage() {
|
||||
<select
|
||||
value={actionFilter}
|
||||
onChange={(e) => setActionFilter(e.target.value as ActionFilter)}
|
||||
className="px-3 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
className="px-3 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||
>
|
||||
<option value="DEFAULT">All (excl. deletions)</option>
|
||||
<option value="ALL">All Actions</option>
|
||||
@@ -331,7 +328,7 @@ export default function AuditPage() {
|
||||
<select
|
||||
value={entityTypeFilter}
|
||||
onChange={(e) => setEntityTypeFilter(e.target.value)}
|
||||
className="px-3 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
className="px-3 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||
>
|
||||
{entityTypes.map(type => (
|
||||
<option key={type} value={type}>
|
||||
@@ -465,7 +462,7 @@ export default function AuditPage() {
|
||||
<button
|
||||
onClick={handleNextPage}
|
||||
disabled={!nextLink || refreshing}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Next →
|
||||
</button>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { sessionUrl } from "@/lib/routes";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { Route } from "next";
|
||||
import { useState, useEffect, useRef, useMemo, useDeferredValue } from "react";
|
||||
import { Session } from "../types";
|
||||
import { trackEvent } from "@/lib/appInsights";
|
||||
@@ -103,7 +104,7 @@ interface SessionTableProps {
|
||||
/** Builds the row's navigation target. Defaults to `/sessions/{id}`; a cross-tenant viewer overrides it to
|
||||
* append `?tenantId=` so a delegated viewer opens the session in the managed tenant's (read-only) context.
|
||||
* Receives the whole session because the target tenant is per-row (session.tenantId), not derivable from id. */
|
||||
sessionLinkTarget?: (session: Session) => string;
|
||||
sessionLinkTarget?: (session: Session) => Route;
|
||||
}
|
||||
|
||||
export function SessionTable({
|
||||
@@ -333,7 +334,7 @@ export function SessionTable({
|
||||
<select
|
||||
value={sessionsPerPage}
|
||||
onChange={(e) => onSessionsPerPageChange(Number(e.target.value))}
|
||||
className="px-2 py-1.5 rounded-lg text-xs font-medium text-gray-600 bg-gray-100 hover:bg-gray-200 border border-gray-200 transition-colors cursor-pointer focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
className="px-2 py-1.5 rounded-lg text-xs font-medium text-gray-600 bg-gray-100 hover:bg-gray-200 border border-gray-200 transition-colors cursor-pointer focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||
title="Sessions per page"
|
||||
>
|
||||
{[10, 15, 20, 50, 100].map((n) => (
|
||||
@@ -399,7 +400,7 @@ export function SessionTable({
|
||||
type="checkbox"
|
||||
checked={visibleColumns.has(col.key)}
|
||||
onChange={() => toggleColumn(col.key)}
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500 h-3.5 w-3.5"
|
||||
className="rounded border-gray-300 text-green-600 focus:ring-green-500 h-3.5 w-3.5"
|
||||
/>
|
||||
{col.label}
|
||||
</label>
|
||||
@@ -462,7 +463,7 @@ export function SessionTable({
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="w-full px-4 py-2 pr-10 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"
|
||||
className="w-full px-4 py-2 pr-10 border border-gray-300 rounded-lg text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
@@ -557,7 +558,7 @@ export function SessionTable({
|
||||
setShowSearchSuggestions(false);
|
||||
onLoadAll();
|
||||
}}
|
||||
className="text-xs font-medium text-blue-600 hover:text-blue-800 transition-colors"
|
||||
className="text-xs font-medium text-green-700 hover:text-green-800 transition-colors"
|
||||
>
|
||||
Search all sessions
|
||||
</button>
|
||||
@@ -574,7 +575,7 @@ export function SessionTable({
|
||||
{(["Succeeded", "InProgress", "Pending", "Stalled", "AwaitingUser", "Failed", "Incomplete"] as const).map((status) => {
|
||||
const config: Record<string, { bg: string; bgActive: string; text: string; label: string }> = {
|
||||
Succeeded: { bg: "bg-green-50 text-green-700 border-green-200 hover:bg-green-100", bgActive: "bg-green-600 text-white border-green-600", text: "text-green-600", label: "Succeeded" },
|
||||
InProgress: { bg: "bg-blue-50 text-blue-700 border-blue-200 hover:bg-blue-100", bgActive: "bg-blue-600 text-white border-blue-600", text: "text-blue-600", label: "In Progress" },
|
||||
InProgress: { bg: "bg-blue-50 text-blue-700 border-blue-200 hover:bg-blue-100", bgActive: "bg-green-600 text-white border-blue-600", text: "text-blue-600", label: "In Progress" },
|
||||
Pending: { bg: "bg-amber-50 text-amber-700 border-amber-200 hover:bg-amber-100", bgActive: "bg-amber-500 text-white border-amber-500", text: "text-amber-600", label: "Pending" },
|
||||
Stalled: { bg: "bg-orange-50 text-orange-700 border-orange-200 hover:bg-orange-100", bgActive: "bg-orange-600 text-white border-orange-600", text: "text-orange-600", label: "Stalled" },
|
||||
// Non-terminal reclassification state (Device Setup done, waiting on user) — sky, mirrors SessionStatusBadge.
|
||||
@@ -740,7 +741,7 @@ export function SessionTable({
|
||||
<button
|
||||
onClick={onNextPage}
|
||||
disabled={(currentPage >= totalPages && !hasMore) || loadingMore}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center gap-2"
|
||||
className="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center gap-2"
|
||||
>
|
||||
{loadingMore && (
|
||||
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
|
||||
@@ -760,7 +761,6 @@ export function SessionTable({
|
||||
function SessionCell({
|
||||
columnKey,
|
||||
session,
|
||||
adminMode,
|
||||
globalAdminMode,
|
||||
blockedDevicesSet,
|
||||
user,
|
||||
@@ -803,7 +803,7 @@ function SessionCell({
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.writeText(session.tenantId);
|
||||
}}
|
||||
className="group flex items-center gap-1 text-xs font-mono text-gray-600 hover:text-blue-600 transition-colors"
|
||||
className="group flex items-center gap-1 text-xs font-mono text-gray-600 hover:text-green-600 transition-colors"
|
||||
title={session.tenantId}
|
||||
>
|
||||
<span>{session.tenantId.split('-').slice(0, 2).join('-')}...</span>
|
||||
@@ -1098,7 +1098,7 @@ function SortableHeader({
|
||||
e.stopPropagation();
|
||||
onFilterToggle();
|
||||
}}
|
||||
className={`p-0.5 rounded transition-colors ${hasActiveFilter ? "text-blue-600 hover:text-blue-800" : "text-gray-300 hover:text-gray-500"}`}
|
||||
className={`p-0.5 rounded transition-colors ${hasActiveFilter ? "text-green-700 hover:text-green-800" : "text-gray-300 hover:text-gray-500"}`}
|
||||
title={hasActiveFilter ? `Filtered (${activeFilter!.size} selected)` : "Filter this column"}
|
||||
>
|
||||
<svg className="w-3 h-3" fill={hasActiveFilter ? "currentColor" : "none"} stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -1123,7 +1123,7 @@ function SortableHeader({
|
||||
placeholder="Search..."
|
||||
value={filterSearch}
|
||||
onChange={(e) => setFilterSearch(e.target.value)}
|
||||
className="w-full px-2 py-1 text-xs border border-gray-200 rounded text-gray-700 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
className="w-full px-2 py-1 text-xs border border-gray-200 rounded text-gray-700 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-green-500"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
@@ -1148,7 +1148,7 @@ function SortableHeader({
|
||||
}
|
||||
onFilterChange(filterKey, next);
|
||||
}}
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500 h-3 w-3"
|
||||
className="rounded border-gray-300 text-green-600 focus:ring-green-500 h-3 w-3"
|
||||
/>
|
||||
<span className="truncate">{value}</span>
|
||||
</label>
|
||||
|
||||
@@ -68,7 +68,7 @@ export function UserMenu({
|
||||
<div className="text-xs text-gray-500 truncate">
|
||||
{email}
|
||||
</div>
|
||||
<button className="text-xs text-blue-600 hover:text-blue-800 mt-1">
|
||||
<button className="text-xs text-green-700 hover:text-green-800 mt-1">
|
||||
View account
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { DOCS_URL } from "@/utils/config";
|
||||
|
||||
export function WelcomeMessage() {
|
||||
@@ -25,23 +24,23 @@ export function WelcomeMessage() {
|
||||
{/* Steps */}
|
||||
<ol className="space-y-3 mb-7">
|
||||
<li className="flex items-start gap-3">
|
||||
<span className="shrink-0 mt-0.5 w-5 h-5 rounded-full bg-blue-600 text-white text-[10px] font-bold flex items-center justify-center">1</span>
|
||||
<span className="shrink-0 mt-0.5 w-5 h-5 rounded-full bg-green-600 text-white text-[10px] font-bold flex items-center justify-center">1</span>
|
||||
<p className="text-sm text-gray-700">
|
||||
Follow the{" "}
|
||||
<a href={`${DOCS_URL}/getting-started/portal-setup`} target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline font-medium">setup guide</a>
|
||||
<a href={`${DOCS_URL}/getting-started/portal-setup`} target="_blank" rel="noopener noreferrer" className="text-green-700 hover:underline font-medium">setup guide</a>
|
||||
{" "}to configure your tenant and grant the required permissions.
|
||||
</p>
|
||||
</li>
|
||||
<li className="flex items-start gap-3">
|
||||
<span className="shrink-0 mt-0.5 w-5 h-5 rounded-full bg-blue-600 text-white text-[10px] font-bold flex items-center justify-center">2</span>
|
||||
<span className="shrink-0 mt-0.5 w-5 h-5 rounded-full bg-green-600 text-white text-[10px] font-bold flex items-center justify-center">2</span>
|
||||
<p className="text-sm text-gray-700">
|
||||
Deploy the{" "}
|
||||
<a href={`${DOCS_URL}/getting-started/deploy-the-agent`} target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline font-medium">Intune bootstrapper</a>
|
||||
<a href={`${DOCS_URL}/getting-started/deploy-the-agent`} target="_blank" rel="noopener noreferrer" className="text-green-700 hover:underline font-medium">Intune bootstrapper</a>
|
||||
{" "}to your Autopilot device groups — the agent installs automatically on first boot.
|
||||
</p>
|
||||
</li>
|
||||
<li className="flex items-start gap-3">
|
||||
<span className="shrink-0 mt-0.5 w-5 h-5 rounded-full bg-blue-600 text-white text-[10px] font-bold flex items-center justify-center">3</span>
|
||||
<span className="shrink-0 mt-0.5 w-5 h-5 rounded-full bg-green-600 text-white text-[10px] font-bold flex items-center justify-center">3</span>
|
||||
<p className="text-sm text-gray-700">Enrollment sessions will appear here in real time as devices go through the enrollment process.</p>
|
||||
</li>
|
||||
</ol>
|
||||
@@ -66,7 +65,7 @@ export function WelcomeMessage() {
|
||||
</svg>
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/okieselbach/Autopilot-Monitor"
|
||||
href="https://github.com/okieselbach/AutopilotMonitor"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group flex items-center gap-3 rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 hover:border-gray-400 hover:bg-gray-100 transition-colors"
|
||||
|
||||
@@ -90,9 +90,8 @@ function HomeContent() {
|
||||
const mainClassName = fullWidth
|
||||
? "w-full px-4 sm:px-6 lg:px-8 py-4"
|
||||
: "max-w-7xl mx-auto py-4 sm:px-6 lg:px-8";
|
||||
const { user, logout, getAccessToken, isPreviewBlocked, hasGlobalScope } = useAuth();
|
||||
const { user, getAccessToken, isPreviewBlocked, hasGlobalScope } = useAuth();
|
||||
const { addNotification } = useNotifications();
|
||||
const [apiStatus, setApiStatus] = useState<"unchecked" | "checking" | "healthy" | "error">("unchecked");
|
||||
// `?tenant=<id>` deep-links a cross-tenant view onto one tenant — used by the /fleet card grid to drill
|
||||
// a managed tenant into this dashboard. Ignored for non-cross-tenant users (the filter is unused there).
|
||||
const initialTenantFilter = searchParams?.get("tenant") ?? "";
|
||||
@@ -109,7 +108,7 @@ function HomeContent() {
|
||||
// Drives the stats refetch — server-side stats follow the submitted scope so
|
||||
// typing into the filter input doesn't trigger a backend round-trip per keystroke.
|
||||
const [submittedTenantIdFilter, setSubmittedTenantIdFilter] = useState(initialTenantFilter);
|
||||
const { adminMode, setAdminMode, globalAdminMode, setGlobalAdminMode } = useAdminMode();
|
||||
const { adminMode, globalAdminMode, setGlobalAdminMode } = useAdminMode();
|
||||
|
||||
const signalR = useSignalR();
|
||||
const { tenantId } = useTenant();
|
||||
@@ -232,7 +231,7 @@ function HomeContent() {
|
||||
|
||||
// Auto-load ALL remaining sessions when search is active and local results are insufficient.
|
||||
// Uses a 500ms debounce so rapid typing doesn't trigger unnecessary loads.
|
||||
const autoLoadTimerRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
const autoLoadTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
if (autoLoadTimerRef.current) clearTimeout(autoLoadTimerRef.current);
|
||||
|
||||
@@ -299,7 +298,7 @@ function HomeContent() {
|
||||
href={`${DOCS_URL}/changelog/platform-changelog`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline font-medium hover:text-blue-600 dark:hover:text-blue-200"
|
||||
className="underline font-medium hover:text-green-600 dark:hover:text-blue-200"
|
||||
>
|
||||
Private Preview Changelog
|
||||
</a>{" "}
|
||||
@@ -308,17 +307,17 @@ function HomeContent() {
|
||||
href={`${DOCS_URL}/troubleshooting/service-announcements`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline font-medium hover:text-blue-600 dark:hover:text-blue-200"
|
||||
className="underline font-medium hover:text-green-600 dark:hover:text-blue-200"
|
||||
>
|
||||
Service Announcements
|
||||
</a>
|
||||
.{" "}
|
||||
Feedback or bug report?{" "}
|
||||
<a
|
||||
href="https://github.com/okieselbach/Autopilot-Monitor/issues"
|
||||
href="https://github.com/okieselbach/AutopilotMonitor/issues"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline font-medium hover:text-blue-600 dark:hover:text-blue-200"
|
||||
className="underline font-medium hover:text-green-600 dark:hover:text-blue-200"
|
||||
>
|
||||
Open a GitHub issue
|
||||
</a>
|
||||
@@ -327,7 +326,7 @@ function HomeContent() {
|
||||
href="https://www.linkedin.com/in/oliver-kieselbach/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline font-medium hover:text-blue-600 dark:hover:text-blue-200"
|
||||
className="underline font-medium hover:text-green-600 dark:hover:text-blue-200"
|
||||
>
|
||||
LinkedIn
|
||||
</a>
|
||||
|
||||
@@ -64,87 +64,8 @@ function DiagnosisContent() {
|
||||
|
||||
const { globalAdminMode } = useAdminMode();
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId) return;
|
||||
if (!globalAdminMode && !tenantId) return; // wait for real tenant ID
|
||||
sessionIdRef.current = sessionId;
|
||||
if (lastFetchedSessionId.current !== sessionId) {
|
||||
hasInitialFetch.current = false;
|
||||
lastFetchedSessionId.current = sessionId;
|
||||
sessionFetchDone.current = false;
|
||||
analysisFetchDone.current = false;
|
||||
setSessionTenantId(null);
|
||||
setLoading(true);
|
||||
}
|
||||
if (hasInitialFetch.current) return;
|
||||
hasInitialFetch.current = true;
|
||||
|
||||
// Performance: eager-set sessionTenantId from TenantContext when known, so
|
||||
// the events/analysis effect fires in parallel with fetchSessionDetails
|
||||
// instead of waiting for its roundtrip — eliminates the fetch waterfall.
|
||||
// Global Admins in all-tenant view fall through with null (backend resolves
|
||||
// the tenant from the session itself).
|
||||
if (!globalAdminMode && isGuid(tenantId)) {
|
||||
setSessionTenantId(tenantId);
|
||||
}
|
||||
|
||||
fetchSessionDetails();
|
||||
}, [sessionId, tenantId, globalAdminMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (sessionTenantId && sessionId) {
|
||||
Promise.all([fetchEvents(), fetchAnalysisResults()]);
|
||||
}
|
||||
}, [sessionTenantId, sessionId]);
|
||||
|
||||
// SignalR groups - subscribe-then-fetch pattern
|
||||
useEffect(() => {
|
||||
const effectiveTenantId = sessionTenantId || tenantId;
|
||||
if (!sessionId || !isConnected || !effectiveTenantId) return;
|
||||
if (!hasJoinedGroups.current) {
|
||||
const joinAndCatchUp = async () => {
|
||||
await joinGroup(`session-${effectiveTenantId}-${sessionId}`);
|
||||
hasJoinedGroups.current = true;
|
||||
// Re-fetch after group join to catch any missed during join
|
||||
Promise.all([fetchEvents(), fetchAnalysisResults()]);
|
||||
};
|
||||
joinAndCatchUp();
|
||||
}
|
||||
return () => {
|
||||
if (hasJoinedGroups.current && effectiveTenantId) {
|
||||
leaveGroup(`session-${effectiveTenantId}-${sessionId}`);
|
||||
hasJoinedGroups.current = false;
|
||||
}
|
||||
};
|
||||
}, [sessionId, isConnected, sessionTenantId, tenantId]);
|
||||
|
||||
// Real-time analysis updates
|
||||
useEffect(() => {
|
||||
const handleEventStream = (data: {
|
||||
sessionId: string;
|
||||
session: Session;
|
||||
newRuleResults?: RuleResult[];
|
||||
}) => {
|
||||
if (data.sessionId !== sessionIdRef.current) return;
|
||||
if (data.session) setSession(data.session);
|
||||
if (data.newRuleResults?.length) {
|
||||
setAnalysisResults((prev) => {
|
||||
const existingIds = new Set(prev.map((r) => r.ruleId));
|
||||
const newResults = data.newRuleResults!.filter(
|
||||
(r) => !existingIds.has(r.ruleId)
|
||||
);
|
||||
return [...prev, ...newResults].sort(
|
||||
(a, b) => b.confidenceScore - a.confidenceScore
|
||||
);
|
||||
});
|
||||
}
|
||||
};
|
||||
on("eventStream", handleEventStream);
|
||||
return () => {
|
||||
off("eventStream", handleEventStream);
|
||||
};
|
||||
}, [on, off]);
|
||||
|
||||
// Declared ahead of the effects that call them (react-hooks/immutability:
|
||||
// a later declaration would freeze the effect's view of the binding).
|
||||
const fetchSessionDetails = async () => {
|
||||
try {
|
||||
// Fetch the single session directly instead of pulling the entire session
|
||||
@@ -234,6 +155,87 @@ function DiagnosisContent() {
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId) return;
|
||||
if (!globalAdminMode && !tenantId) return; // wait for real tenant ID
|
||||
sessionIdRef.current = sessionId;
|
||||
if (lastFetchedSessionId.current !== sessionId) {
|
||||
hasInitialFetch.current = false;
|
||||
lastFetchedSessionId.current = sessionId;
|
||||
sessionFetchDone.current = false;
|
||||
analysisFetchDone.current = false;
|
||||
setSessionTenantId(null);
|
||||
setLoading(true);
|
||||
}
|
||||
if (hasInitialFetch.current) return;
|
||||
hasInitialFetch.current = true;
|
||||
|
||||
// Performance: eager-set sessionTenantId from TenantContext when known, so
|
||||
// the events/analysis effect fires in parallel with fetchSessionDetails
|
||||
// instead of waiting for its roundtrip — eliminates the fetch waterfall.
|
||||
// Global Admins in all-tenant view fall through with null (backend resolves
|
||||
// the tenant from the session itself).
|
||||
if (!globalAdminMode && isGuid(tenantId)) {
|
||||
setSessionTenantId(tenantId);
|
||||
}
|
||||
|
||||
fetchSessionDetails();
|
||||
}, [sessionId, tenantId, globalAdminMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (sessionTenantId && sessionId) {
|
||||
Promise.all([fetchEvents(), fetchAnalysisResults()]);
|
||||
}
|
||||
}, [sessionTenantId, sessionId]);
|
||||
|
||||
// SignalR groups - subscribe-then-fetch pattern
|
||||
useEffect(() => {
|
||||
const effectiveTenantId = sessionTenantId || tenantId;
|
||||
if (!sessionId || !isConnected || !effectiveTenantId) return;
|
||||
if (!hasJoinedGroups.current) {
|
||||
const joinAndCatchUp = async () => {
|
||||
await joinGroup(`session-${effectiveTenantId}-${sessionId}`);
|
||||
hasJoinedGroups.current = true;
|
||||
// Re-fetch after group join to catch any missed during join
|
||||
Promise.all([fetchEvents(), fetchAnalysisResults()]);
|
||||
};
|
||||
joinAndCatchUp();
|
||||
}
|
||||
return () => {
|
||||
if (hasJoinedGroups.current && effectiveTenantId) {
|
||||
leaveGroup(`session-${effectiveTenantId}-${sessionId}`);
|
||||
hasJoinedGroups.current = false;
|
||||
}
|
||||
};
|
||||
}, [sessionId, isConnected, sessionTenantId, tenantId]);
|
||||
|
||||
// Real-time analysis updates
|
||||
useEffect(() => {
|
||||
const handleEventStream = (data: {
|
||||
sessionId: string;
|
||||
session: Session;
|
||||
newRuleResults?: RuleResult[];
|
||||
}) => {
|
||||
if (data.sessionId !== sessionIdRef.current) return;
|
||||
if (data.session) setSession(data.session);
|
||||
if (data.newRuleResults?.length) {
|
||||
setAnalysisResults((prev) => {
|
||||
const existingIds = new Set(prev.map((r) => r.ruleId));
|
||||
const newResults = data.newRuleResults!.filter(
|
||||
(r) => !existingIds.has(r.ruleId)
|
||||
);
|
||||
return [...prev, ...newResults].sort(
|
||||
(a, b) => b.confidenceScore - a.confidenceScore
|
||||
);
|
||||
});
|
||||
}
|
||||
};
|
||||
on("eventStream", handleEventStream);
|
||||
return () => {
|
||||
off("eventStream", handleEventStream);
|
||||
};
|
||||
}, [on, off]);
|
||||
|
||||
const copyRemediation = (result: RuleResult) => {
|
||||
const text = result.remediation
|
||||
.map((r) => {
|
||||
@@ -343,7 +345,7 @@ function DiagnosisContent() {
|
||||
onClick={() =>
|
||||
router.push(sessionUrl(sessionId))
|
||||
}
|
||||
className="px-4 py-2 text-sm bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
|
||||
className="px-4 py-2 text-sm bg-green-600 text-white rounded-md hover:bg-green-700 transition-colors"
|
||||
>
|
||||
Full Details
|
||||
</button>
|
||||
@@ -521,7 +523,7 @@ function DiagnosisContent() {
|
||||
href={primaryResult.relatedDocs[0].url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center space-x-1 text-sm text-blue-600 hover:text-blue-800"
|
||||
className="inline-flex items-center space-x-1 text-sm text-green-700 hover:text-green-800"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
@@ -654,7 +656,7 @@ function DiagnosisContent() {
|
||||
e.stopPropagation();
|
||||
copyRemediation(result);
|
||||
}}
|
||||
className="text-xs text-blue-600 hover:text-blue-800"
|
||||
className="text-xs text-green-700 hover:text-green-800"
|
||||
>
|
||||
{copiedId === result.ruleId
|
||||
? "Copied!"
|
||||
@@ -719,7 +721,7 @@ function DiagnosisContent() {
|
||||
href={doc.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-blue-600 hover:text-blue-800 underline"
|
||||
className="text-xs text-green-700 hover:text-green-800 underline"
|
||||
>
|
||||
{doc.title}
|
||||
</a>
|
||||
@@ -802,12 +804,14 @@ function DiagnosisContent() {
|
||||
);
|
||||
}
|
||||
|
||||
function EvidenceEventLinks({ matchedConditions, sessionId }: { matchedConditions: Record<string, any>; sessionId: string }) {
|
||||
function EvidenceEventLinks({ matchedConditions, sessionId }: { matchedConditions: Record<string, unknown>; sessionId: string }) {
|
||||
const eventLinks: { signal: string; eventId: string; eventType?: string }[] = [];
|
||||
const seenEventIds = new Set<string>();
|
||||
|
||||
for (const [signal, evidence] of Object.entries(matchedConditions)) {
|
||||
for (const [signal, evidenceRaw] of Object.entries(matchedConditions)) {
|
||||
if (signal.startsWith("factor_")) continue;
|
||||
// Evidence values are rule-engine JSON; only object-shaped entries carry event links.
|
||||
const evidence = evidenceRaw as { eventId?: string; eventType?: string } | string | null;
|
||||
if (evidence && typeof evidence === "object" && evidence.eventId) {
|
||||
// Multiple matched conditions can extract different fields from the
|
||||
// same event — one chip per distinct event, not per condition.
|
||||
|
||||
@@ -29,7 +29,7 @@ export default function Error({
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center p-4">
|
||||
<div className="min-h-screen bg-[var(--lp-bg)] flex items-center justify-center p-4">
|
||||
<div className="bg-white rounded-lg shadow-xl p-8 max-w-md w-full text-center">
|
||||
<div className="text-4xl mb-3">!</div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-2">Something went wrong</h2>
|
||||
@@ -39,7 +39,7 @@ export default function Error({
|
||||
<div className="flex gap-3 justify-center flex-wrap">
|
||||
<button
|
||||
onClick={() => router.push("/")}
|
||||
className="px-5 py-2.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium text-sm"
|
||||
className="px-5 py-2.5 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors font-medium text-sm"
|
||||
>
|
||||
Back to Home
|
||||
</button>
|
||||
|
||||
@@ -217,7 +217,7 @@ export default function DeviceJourneySection({
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Link
|
||||
href={sessionHref(d.lastSessionId)}
|
||||
className="text-gray-700 hover:text-blue-600 font-mono text-xs truncate"
|
||||
className="text-gray-700 hover:text-green-600 font-mono text-xs truncate"
|
||||
title={`Open the latest enrollment of ${d.serialNumber}`}
|
||||
>
|
||||
{d.serialNumber}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useState, useRef, useMemo } from "react";
|
||||
import Link from "next/link";
|
||||
import type { Route } from "next";
|
||||
import { ProtectedRoute } from "../../components/ProtectedRoute";
|
||||
import { useSignalR } from "../../contexts/SignalRContext";
|
||||
import { useTenant } from "../../contexts/TenantContext";
|
||||
@@ -17,6 +18,7 @@ import { useFleetHealth } from "./hooks/useFleetHealth";
|
||||
import { useAggregatedAdminScope } from "@/hooks";
|
||||
import { GlobalAdminBanner, globalAdminSubtitle } from "@/components/GlobalAdminBanner";
|
||||
import { TenantScopeSelector } from "@/components/TenantScopeSelector";
|
||||
import { SegmentedControl, TIME_RANGE_OPTIONS } from "@/components/SegmentedControl";
|
||||
import { CardSkeleton } from "@/components/skeletons/PageSkeleton";
|
||||
import { TableSkeleton } from "@/components/skeletons/TableSkeleton";
|
||||
|
||||
@@ -75,6 +77,29 @@ export default function FleetHealthPage() {
|
||||
signalR: { on, off, isConnected },
|
||||
});
|
||||
|
||||
const fetchAppMetrics = async (range: "7d" | "30d" | "90d" = timeRange) => {
|
||||
try {
|
||||
const d = range === "7d" ? 7 : range === "30d" ? 30 : 90;
|
||||
const endpoint = routeGlobal
|
||||
? api.metrics.globalApp(d, selectedTenantId || undefined)
|
||||
: api.metrics.app(tenantId, d);
|
||||
const response = await authenticatedFetch(endpoint, getAccessToken);
|
||||
if (response.ok) {
|
||||
const json = await response.json();
|
||||
setAppMetrics(json);
|
||||
} else {
|
||||
addNotification('error', 'Backend Error', `Failed to load app metrics: ${response.statusText}`, 'fleet-health-metrics-error');
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof TokenExpiredError) {
|
||||
addNotification('error', 'Session Expired', error.message, 'session-expired-error');
|
||||
} else {
|
||||
console.error("Failed to fetch app metrics:", error);
|
||||
addNotification('error', 'Backend Not Reachable', 'Unable to load app metrics. Please check your connection.', 'fleet-health-metrics-error');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// App install metrics are already backend-aggregated; fetch alongside fleet data.
|
||||
useEffect(() => {
|
||||
if (!scopeInitialized) return;
|
||||
@@ -145,29 +170,6 @@ export default function FleetHealthPage() {
|
||||
};
|
||||
}, [isConnected, effectiveTenantId]);
|
||||
|
||||
const fetchAppMetrics = async (range: "7d" | "30d" | "90d" = timeRange) => {
|
||||
try {
|
||||
const d = range === "7d" ? 7 : range === "30d" ? 30 : 90;
|
||||
const endpoint = routeGlobal
|
||||
? api.metrics.globalApp(d, selectedTenantId || undefined)
|
||||
: api.metrics.app(tenantId, d);
|
||||
const response = await authenticatedFetch(endpoint, getAccessToken);
|
||||
if (response.ok) {
|
||||
const json = await response.json();
|
||||
setAppMetrics(json);
|
||||
} else {
|
||||
addNotification('error', 'Backend Error', `Failed to load app metrics: ${response.statusText}`, 'fleet-health-metrics-error');
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof TokenExpiredError) {
|
||||
addNotification('error', 'Session Expired', error.message, 'session-expired-error');
|
||||
} else {
|
||||
console.error("Failed to fetch app metrics:", error);
|
||||
addNotification('error', 'Backend Not Reachable', 'Unable to load app metrics. Please check your connection.', 'fleet-health-metrics-error');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Server payload, with presentation-friendly defaults so the JSX renders
|
||||
// without null guards once loading clears. `avgDuration` keeps the card's
|
||||
// original field name (server sends avgDurationMinutes).
|
||||
@@ -191,7 +193,7 @@ export default function FleetHealthPage() {
|
||||
// model. The model key is "{Manufacturer} {Model}", which the dashboard search matches
|
||||
// against its combined manufacturer+model text. Carry the selected tenant so a global
|
||||
// admin scoped to one tenant lands on that tenant's list rather than their default scope.
|
||||
const dashboardModelHref = (model: string) => {
|
||||
const dashboardModelHref = (model: string): Route => {
|
||||
const params = new URLSearchParams({ status: "Failed", search: model });
|
||||
if (isGlobalAdmin && selectedTenantId) params.set("tenant", selectedTenantId);
|
||||
return `/dashboard?${params.toString()}`;
|
||||
@@ -238,11 +240,11 @@ export default function FleetHealthPage() {
|
||||
<GlobalAdminBanner show={scope.isGlobalAdmin} delegated={scope.isDelegatedScope} subtitle={globalAdminSubtitle(scope)} />
|
||||
<header className="bg-white shadow">
|
||||
<div className="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-wrap items-center justify-between gap-y-3">
|
||||
<div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<svg
|
||||
className="w-8 h-8 text-blue-600"
|
||||
className="w-8 h-8 text-green-600"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
@@ -259,25 +261,13 @@ export default function FleetHealthPage() {
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<TenantScopeSelector scope={scope} allowAggregated />
|
||||
{(["7d", "30d", "90d"] as const).map((range) => (
|
||||
<button
|
||||
key={range}
|
||||
onClick={() => setTimeRange(range)}
|
||||
className={`px-4 py-2 text-sm rounded-md transition-colors ${
|
||||
timeRange === range
|
||||
? "bg-blue-600 text-white"
|
||||
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
|
||||
}`}
|
||||
>
|
||||
{range === "7d"
|
||||
? "Last 7 Days"
|
||||
: range === "30d"
|
||||
? "Last 30 Days"
|
||||
: "Last 90 Days"}
|
||||
</button>
|
||||
))}
|
||||
<SegmentedControl
|
||||
options={TIME_RANGE_OPTIONS}
|
||||
value={timeRange}
|
||||
onChange={(v) => setTimeRange(v as typeof timeRange)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -733,7 +723,7 @@ export default function FleetHealthPage() {
|
||||
title={`Show failed enrollments for ${m.model}`}
|
||||
>
|
||||
<div className="flex items-baseline justify-between mb-1">
|
||||
<span className="text-sm text-gray-700 group-hover:text-blue-600 break-words leading-snug">{m.model}</span>
|
||||
<span className="text-sm text-gray-700 group-hover:text-green-600 break-words leading-snug">{m.model}</span>
|
||||
<span className="ml-3 flex-shrink-0 text-sm font-medium text-gray-900">
|
||||
{successRate !== null ? `${successRate}%` : "—"} <span className="text-xs font-normal text-gray-400">({m.total} devices)</span>
|
||||
</span>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { GatherRule, NewRuleForm, CATEGORY_COLORS, COLLECTOR_TYPE_LABELS, EMPTY_FORM, formatTrigger, formatGatherPhase, withDerivedScopeMode } from "../types";
|
||||
import { GatherRule, NewRuleForm, CATEGORY_COLORS, COLLECTOR_TYPE_LABELS, formatTrigger, formatGatherPhase, withDerivedScopeMode } from "../types";
|
||||
import { GatherRuleFormFields } from "./GatherRuleFormFields";
|
||||
import { FormJsonToggle, JsonModeToggleButtons, ReadOnlyJsonView } from "@/components/rules/FormJsonToggle";
|
||||
import { validateGatherRuleTarget } from "@/utils/guardValidation";
|
||||
@@ -373,7 +373,7 @@ export function GatherRuleCard({
|
||||
{canEdit && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onStartEditing(); }}
|
||||
className="px-4 py-2 text-sm bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors flex items-center space-x-2"
|
||||
className="px-4 py-2 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors flex items-center space-x-2"
|
||||
title="Edit rule"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -455,7 +455,7 @@ export function GatherRuleCard({
|
||||
try {
|
||||
const parsed = JSON.parse(jsonText) as NewRuleForm;
|
||||
onSaveEdit(rule, withDerivedScopeMode({ ...editForm, ...parsed }));
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// jsonError is handled by parent
|
||||
}
|
||||
} else {
|
||||
@@ -463,7 +463,7 @@ export function GatherRuleCard({
|
||||
}
|
||||
}}
|
||||
disabled={saving}
|
||||
className="px-6 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center space-x-2 text-sm font-medium"
|
||||
className="px-6 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center space-x-2 text-sm font-medium"
|
||||
>
|
||||
{saving ? (
|
||||
<>
|
||||
|
||||
+33
-33
@@ -55,7 +55,7 @@ export function GatherRuleFormFields({ form, setForm, showRuleId, unrestrictedMo
|
||||
onChange={(e) => setForm({ ...form, ruleId: e.target.value })}
|
||||
placeholder="e.g., custom-network-check"
|
||||
autoComplete="off"
|
||||
className={`w-full px-4 py-2 border rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 transition-colors ${isDuplicate ? "border-red-400 focus:ring-red-300 focus:border-red-400" : "border-gray-300 focus:ring-indigo-500 focus:border-indigo-500"}`}
|
||||
className={`w-full px-4 py-2 border rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 transition-colors ${isDuplicate ? "border-red-400 focus:ring-red-300 focus:border-red-400" : "border-gray-300 focus:ring-green-500 focus:border-green-500"}`}
|
||||
/>
|
||||
{isDuplicate && (
|
||||
<p className="mt-1 text-xs text-red-600">A rule with this ID already exists. Please choose a unique Rule ID.</p>
|
||||
@@ -72,7 +72,7 @@ export function GatherRuleFormFields({ form, setForm, showRuleId, unrestrictedMo
|
||||
onChange={(e) => setForm({ ...form, title: e.target.value })}
|
||||
placeholder="e.g., Custom Network Check"
|
||||
autoComplete="off"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -85,7 +85,7 @@ export function GatherRuleFormFields({ form, setForm, showRuleId, unrestrictedMo
|
||||
onChange={(e) => setForm({ ...form, description: e.target.value })}
|
||||
placeholder="Describe what this rule collects and why..."
|
||||
rows={2}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors resize-none"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -96,7 +96,7 @@ export function GatherRuleFormFields({ form, setForm, showRuleId, unrestrictedMo
|
||||
<select
|
||||
value={form.category}
|
||||
onChange={(e) => setForm({ ...form, category: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 bg-white focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 bg-white focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
|
||||
>
|
||||
{CATEGORIES.map((cat) => (
|
||||
<option key={cat} value={cat}>
|
||||
@@ -110,7 +110,7 @@ export function GatherRuleFormFields({ form, setForm, showRuleId, unrestrictedMo
|
||||
<select
|
||||
value={form.collectorType}
|
||||
onChange={(e) => setForm({ ...form, collectorType: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 bg-white focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 bg-white focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
|
||||
>
|
||||
{COLLECTOR_TYPES.map((ct) => (
|
||||
<option key={ct} value={ct}>
|
||||
@@ -132,7 +132,7 @@ export function GatherRuleFormFields({ form, setForm, showRuleId, unrestrictedMo
|
||||
onChange={(e) => setForm({ ...form, target: e.target.value })}
|
||||
placeholder={TARGET_PLACEHOLDERS[form.collectorType] || "Target for data collection"}
|
||||
autoComplete="off"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
|
||||
/>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<p className="text-xs text-gray-400">{TARGET_HINTS[form.collectorType] || "Registry path, WMI class, event log name, file path, or command depending on collector type"}</p>
|
||||
@@ -152,7 +152,7 @@ export function GatherRuleFormFields({ form, setForm, showRuleId, unrestrictedMo
|
||||
placeholder="e.g., IsRecoveryAllowed (leave empty to read all values)"
|
||||
autoComplete="off"
|
||||
disabled={form.listSubkeys}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors disabled:opacity-50 disabled:bg-gray-50"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors disabled:opacity-50 disabled:bg-gray-50"
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">Specific registry value to read. Leave empty to read all values in the key.</p>
|
||||
</div>
|
||||
@@ -161,7 +161,7 @@ export function GatherRuleFormFields({ form, setForm, showRuleId, unrestrictedMo
|
||||
type="checkbox"
|
||||
checked={form.listSubkeys}
|
||||
onChange={(e) => setForm({ ...form, listSubkeys: e.target.checked, valueName: e.target.checked ? "" : form.valueName })}
|
||||
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||
className="rounded border-gray-300 text-green-600 focus:ring-green-500"
|
||||
/>
|
||||
<span>List subkeys instead of values</span>
|
||||
</label>
|
||||
@@ -180,7 +180,7 @@ export function GatherRuleFormFields({ form, setForm, showRuleId, unrestrictedMo
|
||||
onChange={(e) => setForm({ ...form, eventId: e.target.value })}
|
||||
placeholder="e.g., 62407 (leave empty for all events)"
|
||||
autoComplete="off"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">Filter by specific Event ID. Leave empty to collect all events.</p>
|
||||
</div>
|
||||
@@ -193,7 +193,7 @@ export function GatherRuleFormFields({ form, setForm, showRuleId, unrestrictedMo
|
||||
value={form.maxEntries}
|
||||
onChange={(e) => setForm({ ...form, maxEntries: e.target.value })}
|
||||
placeholder="10"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">Maximum number of events to return (1-50, default: 10).</p>
|
||||
</div>
|
||||
@@ -207,7 +207,7 @@ export function GatherRuleFormFields({ form, setForm, showRuleId, unrestrictedMo
|
||||
onChange={(e) => setForm({ ...form, source: e.target.value })}
|
||||
placeholder="e.g., Microsoft-Windows-Kernel-General"
|
||||
autoComplete="off"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">Filter by event provider/source name. Leave empty for all sources.</p>
|
||||
</div>
|
||||
@@ -219,7 +219,7 @@ export function GatherRuleFormFields({ form, setForm, showRuleId, unrestrictedMo
|
||||
onChange={(e) => setForm({ ...form, messageFilter: e.target.value })}
|
||||
placeholder="e.g., *ESPProgress* (leave empty for no filter)"
|
||||
autoComplete="off"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">Filter by message text. Use * as wildcard prefix/suffix.</p>
|
||||
</div>
|
||||
@@ -235,7 +235,7 @@ export function GatherRuleFormFields({ form, setForm, showRuleId, unrestrictedMo
|
||||
type="checkbox"
|
||||
checked={form.readContent}
|
||||
onChange={(e) => setForm({ ...form, readContent: e.target.checked })}
|
||||
className="w-4 h-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||
className="w-4 h-4 rounded border-gray-300 text-green-600 focus:ring-green-500"
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700">Read file content</span>
|
||||
</label>
|
||||
@@ -251,7 +251,7 @@ export function GatherRuleFormFields({ form, setForm, showRuleId, unrestrictedMo
|
||||
<select
|
||||
value={form.logFormat}
|
||||
onChange={(e) => setForm({ ...form, logFormat: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 bg-white focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 bg-white focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
|
||||
>
|
||||
<option value="cmtrace">CMTrace (default)</option>
|
||||
<option value="text">Plain Text</option>
|
||||
@@ -274,7 +274,7 @@ export function GatherRuleFormFields({ form, setForm, showRuleId, unrestrictedMo
|
||||
? `e.g., (?<timestamp>\\d{4}-\\d{2}-\\d{2}).*(?<level>ERROR|WARN|INFO).*(?<message>.*)`
|
||||
: `e.g., (?<action>Install|Uninstall).*(?<appName>[A-Za-z0-9_-]+)`}
|
||||
autoComplete="off"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 font-mono focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 font-mono focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">Regex with named capture groups. Each match emits a separate event. Named groups become event data fields.</p>
|
||||
</div>
|
||||
@@ -288,7 +288,7 @@ export function GatherRuleFormFields({ form, setForm, showRuleId, unrestrictedMo
|
||||
value={form.maxLines}
|
||||
onChange={(e) => setForm({ ...form, maxLines: e.target.value })}
|
||||
placeholder="1000"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">Max lines to parse per file per execution (default: 1000).</p>
|
||||
</div>
|
||||
@@ -298,7 +298,7 @@ export function GatherRuleFormFields({ form, setForm, showRuleId, unrestrictedMo
|
||||
type="checkbox"
|
||||
checked={form.trackPosition}
|
||||
onChange={(e) => setForm({ ...form, trackPosition: e.target.checked })}
|
||||
className="w-4 h-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||
className="w-4 h-4 rounded border-gray-300 text-green-600 focus:ring-green-500"
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700">Track position</span>
|
||||
</label>
|
||||
@@ -321,7 +321,7 @@ export function GatherRuleFormFields({ form, setForm, showRuleId, unrestrictedMo
|
||||
onChange={(e) => setForm({ ...form, jsonPath: e.target.value })}
|
||||
placeholder="e.g., $.settings.tenantId or $..errorCode"
|
||||
autoComplete="off"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 font-mono focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 font-mono focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
JSONPath query to extract values. Examples: <code className="bg-gray-100 px-1 rounded">$.key</code> (root property),{" "}
|
||||
@@ -339,7 +339,7 @@ export function GatherRuleFormFields({ form, setForm, showRuleId, unrestrictedMo
|
||||
value={form.maxResults}
|
||||
onChange={(e) => setForm({ ...form, maxResults: e.target.value })}
|
||||
placeholder="20"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">Maximum number of matches to return (1-100, default: 20).</p>
|
||||
</div>
|
||||
@@ -359,11 +359,11 @@ export function GatherRuleFormFields({ form, setForm, showRuleId, unrestrictedMo
|
||||
onChange={(e) => setForm({ ...form, xpath: e.target.value })}
|
||||
placeholder="e.g., /configuration/appSettings/add[@key='Setting1']/@value"
|
||||
autoComplete="off"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 font-mono focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 font-mono focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
XPath query to extract values. Examples: <code className="bg-gray-100 px-1 rounded">/root/element</code> (path),{" "}
|
||||
<code className="bg-gray-100 px-1 rounded">//element</code> (anywhere),{" "}
|
||||
<code className="bg-gray-100 px-1 rounded">{"//element"}</code> (anywhere),{" "}
|
||||
<code className="bg-gray-100 px-1 rounded">/root/item[@attr='value']</code> (filter),{" "}
|
||||
<code className="bg-gray-100 px-1 rounded">/root/element/text()</code> (text content).
|
||||
</p>
|
||||
@@ -377,7 +377,7 @@ export function GatherRuleFormFields({ form, setForm, showRuleId, unrestrictedMo
|
||||
onChange={(e) => setForm({ ...form, xmlNamespaces: e.target.value })}
|
||||
placeholder="e.g., ns=http://schemas.example.com/config"
|
||||
autoComplete="off"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
Optional. Format: <code className="bg-gray-100 px-1 rounded">prefix=uri;prefix2=uri2</code>
|
||||
@@ -392,7 +392,7 @@ export function GatherRuleFormFields({ form, setForm, showRuleId, unrestrictedMo
|
||||
value={form.maxResults}
|
||||
onChange={(e) => setForm({ ...form, maxResults: e.target.value })}
|
||||
placeholder="20"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">Max matches to return (1-100, default: 20).</p>
|
||||
</div>
|
||||
@@ -406,7 +406,7 @@ export function GatherRuleFormFields({ form, setForm, showRuleId, unrestrictedMo
|
||||
<select
|
||||
value={form.trigger}
|
||||
onChange={(e) => setForm({ ...form, trigger: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 bg-white focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 bg-white focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
|
||||
>
|
||||
{TRIGGERS.map((t) => (
|
||||
<option key={t} value={t}>{formatTrigger(t)}</option>
|
||||
@@ -424,7 +424,7 @@ export function GatherRuleFormFields({ form, setForm, showRuleId, unrestrictedMo
|
||||
max={3600}
|
||||
value={form.intervalSeconds}
|
||||
onChange={(e) => setForm({ ...form, intervalSeconds: parseInt(e.target.value) || 60 })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">How often to run this rule (5 - 3600 seconds)</p>
|
||||
</div>
|
||||
@@ -436,7 +436,7 @@ export function GatherRuleFormFields({ form, setForm, showRuleId, unrestrictedMo
|
||||
<select
|
||||
value={form.triggerPhase}
|
||||
onChange={(e) => setForm({ ...form, triggerPhase: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 bg-white focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 bg-white focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
|
||||
>
|
||||
<option value="">Any phase</option>
|
||||
{form.triggerPhase && !GATHER_PHASES.some((p) => p.value === form.triggerPhase) && (
|
||||
@@ -469,7 +469,7 @@ export function GatherRuleFormFields({ form, setForm, showRuleId, unrestrictedMo
|
||||
onChange={(e) => setForm({ ...form, triggerEventType: e.target.value })}
|
||||
placeholder="e.g., session_stalled, enrollment_failed, modern_deployment_error"
|
||||
autoComplete="off"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
|
||||
/>
|
||||
<datalist id="gather-rule-event-types">
|
||||
{KNOWN_EVENT_TYPES.map((et) => (
|
||||
@@ -528,7 +528,7 @@ export function GatherRuleFormFields({ form, setForm, showRuleId, unrestrictedMo
|
||||
activeFromPhase: scopeMode === "from" ? form.activeFromPhase : "",
|
||||
});
|
||||
}}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 bg-white focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 bg-white focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
|
||||
>
|
||||
<option value="always">All phases (always)</option>
|
||||
<option value="during">Only during specific phases</option>
|
||||
@@ -547,7 +547,7 @@ export function GatherRuleFormFields({ form, setForm, showRuleId, unrestrictedMo
|
||||
<select
|
||||
value={form.emitMode}
|
||||
onChange={(e) => setForm({ ...form, emitMode: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 bg-white focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 bg-white focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
|
||||
>
|
||||
{EMIT_MODES.map((m) => (
|
||||
<option key={m.value} value={m.value}>{m.label}</option>
|
||||
@@ -576,7 +576,7 @@ export function GatherRuleFormFields({ form, setForm, showRuleId, unrestrictedMo
|
||||
: form.activePhases.filter((x) => x !== p.value),
|
||||
})
|
||||
}
|
||||
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||
className="rounded border-gray-300 text-green-600 focus:ring-green-500"
|
||||
/>
|
||||
<span>{p.label}</span>
|
||||
</label>
|
||||
@@ -596,7 +596,7 @@ export function GatherRuleFormFields({ form, setForm, showRuleId, unrestrictedMo
|
||||
<select
|
||||
value={form.activeFromPhase}
|
||||
onChange={(e) => setForm({ ...form, activeFromPhase: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 bg-white focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 bg-white focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
|
||||
>
|
||||
<option value="">Select a phase…</option>
|
||||
{GATHER_PHASES.map((p) => (
|
||||
@@ -623,7 +623,7 @@ export function GatherRuleFormFields({ form, setForm, showRuleId, unrestrictedMo
|
||||
onChange={(e) => setForm({ ...form, outputEventType: e.target.value })}
|
||||
placeholder="e.g., CustomNetworkStatus"
|
||||
autoComplete="off"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -631,7 +631,7 @@ export function GatherRuleFormFields({ form, setForm, showRuleId, unrestrictedMo
|
||||
<select
|
||||
value={form.outputSeverity}
|
||||
onChange={(e) => setForm({ ...form, outputSeverity: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 bg-white focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 bg-white focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
|
||||
>
|
||||
{SEVERITIES.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ProtectedRoute } from "../../components/ProtectedRoute";
|
||||
import { useAuth } from "../../contexts/AuthContext";
|
||||
import { api } from "@/lib/api";
|
||||
@@ -16,14 +14,12 @@ import { FormJsonToggle, JsonModeToggleButtons } from "@/components/rules/FormJs
|
||||
import { useAuthenticatedFetch, useNotificationMessages, useGlobalAdminScope } from "@/hooks";
|
||||
import { GlobalAdminBanner, globalAdminSubtitle } from "@/components/GlobalAdminBanner";
|
||||
import { TenantScopeSelector } from "@/components/TenantScopeSelector";
|
||||
import { GatherRule, NewRuleForm, EMPTY_FORM, CATEGORY_COLORS, PHASE_TRIGGERS, buildScopeFields, validateScopeSelection, withDerivedScopeMode } from "./types";
|
||||
import { GatherRule, NewRuleForm, EMPTY_FORM, PHASE_TRIGGERS, buildScopeFields, validateScopeSelection, withDerivedScopeMode } from "./types";
|
||||
import { GatherRuleFormFields } from "./components/GatherRuleFormFields";
|
||||
import { GatherRuleCard } from "./components/GatherRuleCard";
|
||||
import { DOCS_URL } from "@/utils/config";
|
||||
|
||||
export default function GatherRulesPage() {
|
||||
const router = useRouter();
|
||||
|
||||
const { user, getAccessToken } = useAuth();
|
||||
|
||||
const { successMessage, error, showSuccess, showError } = useNotificationMessages();
|
||||
@@ -449,7 +445,7 @@ export default function GatherRulesPage() {
|
||||
</svg>
|
||||
<p className="text-sm text-blue-800">
|
||||
Missing a rule, found a bug, or have an improvement idea? Help make Autopilot Monitor better —{" "}
|
||||
<a href="https://github.com/okieselbach/Autopilot-Monitor/issues" target="_blank" rel="noopener noreferrer" className="font-medium underline hover:text-blue-900">
|
||||
<a href="https://github.com/okieselbach/AutopilotMonitor/issues" target="_blank" rel="noopener noreferrer" className="font-medium underline hover:text-blue-900">
|
||||
open a GitHub issue
|
||||
</a>.
|
||||
</p>
|
||||
@@ -512,7 +508,7 @@ export default function GatherRulesPage() {
|
||||
</svg>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900">Create Custom Rule</h2>
|
||||
<p className="text-sm text-gray-500 mt-1">Define a new data collection rule for enrolled devices — <a href={`${DOCS_URL}/rules/gather-rules`} target="_blank" rel="noopener noreferrer" className="text-indigo-600 hover:text-indigo-800 underline">see documentation</a></p>
|
||||
<p className="text-sm text-gray-500 mt-1">Define a new data collection rule for enrolled devices — <a href={`${DOCS_URL}/rules/gather-rules`} target="_blank" rel="noopener noreferrer" className="text-green-700 hover:text-green-800 underline">see documentation</a></p>
|
||||
</div>
|
||||
</div>
|
||||
<JsonModeToggleButtons
|
||||
@@ -584,7 +580,7 @@ export default function GatherRulesPage() {
|
||||
}
|
||||
}}
|
||||
disabled={creating}
|
||||
className="px-6 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center space-x-2 text-sm font-medium"
|
||||
className="px-6 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center space-x-2 text-sm font-medium"
|
||||
>
|
||||
{creating ? (
|
||||
<>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { MapContainer, TileLayer, CircleMarker, Tooltip, useMap } from "react-leaflet";
|
||||
import "leaflet/dist/leaflet.css";
|
||||
|
||||
@@ -61,22 +61,6 @@ function formatThroughput(bytesPerSec: number): string {
|
||||
return `${bytesPerSec.toFixed(0)} B/s`;
|
||||
}
|
||||
|
||||
// Clean up Leaflet map on unmount to prevent _leaflet_pos errors during client-side navigation.
|
||||
// map.remove() fully destroys the instance, preventing stale transitionend callbacks.
|
||||
function MapCleanup() {
|
||||
const map = useMap();
|
||||
const mapRef = useRef(map);
|
||||
mapRef.current = map;
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
try { mapRef.current.remove(); } catch { /* already removed by react-leaflet */ }
|
||||
};
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Component to auto-fit bounds when locations change
|
||||
function FitBounds({ locations }: { locations: { coords: [number, number] }[] }) {
|
||||
const map = useMap();
|
||||
@@ -131,7 +115,6 @@ export default function GeoMap({ locations, globalAvgDuration, selectedLocation,
|
||||
style={{ height: "100%", width: "100%", borderRadius: "0.5rem", zIndex: 0 }}
|
||||
scrollWheelZoom={true}
|
||||
>
|
||||
<MapCleanup />
|
||||
<TileLayer
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useAggregatedAdminScope } from "@/hooks";
|
||||
import { useFetchProgress } from "@/hooks/useFetchProgress";
|
||||
import { GlobalAdminBanner, globalAdminSubtitle } from "@/components/GlobalAdminBanner";
|
||||
import { TenantScopeSelector } from "@/components/TenantScopeSelector";
|
||||
import { SegmentedControl, TIME_RANGE_OPTIONS } from "@/components/SegmentedControl";
|
||||
import { CalculatingCard } from "@/components/CalculatingCard";
|
||||
|
||||
// A cross-tenant geo aggregation can take tens of seconds server-side; the default 30s fetch
|
||||
@@ -81,8 +82,16 @@ interface GeographicMetricsResponse {
|
||||
|
||||
type GroupBy = "city" | "region" | "country";
|
||||
type SortBy = "sessionCount" | "avgDurationMinutes" | "appLoadScore" | "avgThroughputBytesPerSec" | "avgApiLatencyMs" | "avgDoPercentPeerCaching";
|
||||
|
||||
type TimeRange = "7d" | "30d" | "90d";
|
||||
|
||||
// Module-level so React keeps a stable component identity across renders
|
||||
// (react-hooks/static-components: an inline component type remounts on every render).
|
||||
function SortIcon({ col, sortBy, sortDesc }: { col: SortBy; sortBy: SortBy; sortDesc: boolean }) {
|
||||
if (sortBy !== col) return <span className="text-gray-300 ml-1">↕</span>;
|
||||
return <span className="text-blue-600 ml-1">{sortDesc ? "▼" : "▲"}</span>;
|
||||
}
|
||||
|
||||
const durationColor = (value: number, globalAvg: number) => {
|
||||
if (globalAvg <= 0) return "text-gray-700";
|
||||
const ratio = value / globalAvg;
|
||||
@@ -143,7 +152,7 @@ export default function GeographicPerformancePage() {
|
||||
// Global admin tenant scope (aggregated-capable): tenant list, selection ("" = all tenants),
|
||||
// and scope flags. Default selection is the GA's own tenant.
|
||||
const scope = useAggregatedAdminScope();
|
||||
const { isGlobalAdmin, routeGlobal, selectedTenantId, isAggregatedGlobalView, scopeInitialized, scopeKey } = scope;
|
||||
const { routeGlobal, selectedTenantId, isAggregatedGlobalView, scopeInitialized, scopeKey } = scope;
|
||||
|
||||
const progress = useFetchProgress("geoPerf.lastFetchMs");
|
||||
const { begin: progressBegin, finish: progressFinish } = progress;
|
||||
@@ -219,11 +228,6 @@ export default function GeographicPerformancePage() {
|
||||
}
|
||||
};
|
||||
|
||||
const SortIcon = ({ col }: { col: SortBy }) => {
|
||||
if (sortBy !== col) return <span className="text-gray-300 ml-1">↕</span>;
|
||||
return <span className="text-blue-600 ml-1">{sortDesc ? "▼" : "▲"}</span>;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<CalculatingCard
|
||||
@@ -245,49 +249,33 @@ export default function GeographicPerformancePage() {
|
||||
/>
|
||||
<header className="bg-white shadow">
|
||||
<div className="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-wrap items-center justify-between gap-y-3">
|
||||
<div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<svg className="w-8 h-8 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<svg className="w-8 h-8 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<h1 className="text-2xl font-normal text-gray-900">Geographic Performance</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<TenantScopeSelector scope={scope} allowAggregated />
|
||||
{/* Time Range Toggle */}
|
||||
<div className="flex bg-gray-100 rounded-lg p-1">
|
||||
{(["7d", "30d", "90d"] as TimeRange[]).map((range) => (
|
||||
<button
|
||||
key={range}
|
||||
onClick={() => setTimeRange(range)}
|
||||
className={`px-3 py-1.5 text-sm font-medium rounded-md transition-colors ${
|
||||
timeRange === range
|
||||
? "bg-white text-gray-900 shadow-sm"
|
||||
: "text-gray-500 hover:text-gray-700"
|
||||
}`}
|
||||
>
|
||||
{range === "7d" ? "7 Days" : range === "30d" ? "30 Days" : "90 Days"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<SegmentedControl
|
||||
options={TIME_RANGE_OPTIONS}
|
||||
value={timeRange}
|
||||
onChange={(v) => setTimeRange(v as TimeRange)}
|
||||
/>
|
||||
{/* Group By Toggle */}
|
||||
<div className="flex bg-gray-100 rounded-lg p-1">
|
||||
{(["city", "region", "country"] as GroupBy[]).map((g) => (
|
||||
<button
|
||||
key={g}
|
||||
onClick={() => setGroupBy(g)}
|
||||
className={`px-3 py-1.5 text-sm font-medium rounded-md transition-colors capitalize ${
|
||||
groupBy === g
|
||||
? "bg-white text-gray-900 shadow-sm"
|
||||
: "text-gray-500 hover:text-gray-700"
|
||||
}`}
|
||||
>
|
||||
{g}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<SegmentedControl
|
||||
options={[
|
||||
{ value: "city", label: "City" },
|
||||
{ value: "region", label: "Region" },
|
||||
{ value: "country", label: "Country" },
|
||||
]}
|
||||
value={groupBy}
|
||||
onChange={(v) => setGroupBy(v as GroupBy)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -449,7 +437,7 @@ export default function GeographicPerformancePage() {
|
||||
className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:text-gray-700"
|
||||
onClick={() => handleSort("sessionCount")}
|
||||
>
|
||||
Sessions <SortIcon col="sessionCount" />
|
||||
Sessions <SortIcon col="sessionCount" sortBy={sortBy} sortDesc={sortDesc} />
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Success
|
||||
@@ -458,7 +446,7 @@ export default function GeographicPerformancePage() {
|
||||
className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:text-gray-700"
|
||||
onClick={() => handleSort("avgDurationMinutes")}
|
||||
>
|
||||
Avg Duration <SortIcon col="avgDurationMinutes" />
|
||||
Avg Duration <SortIcon col="avgDurationMinutes" sortBy={sortBy} sortDesc={sortDesc} />
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
P95
|
||||
@@ -467,26 +455,26 @@ export default function GeographicPerformancePage() {
|
||||
className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:text-gray-700"
|
||||
onClick={() => handleSort("appLoadScore")}
|
||||
>
|
||||
App-Load-Score <SortIcon col="appLoadScore" />
|
||||
App-Load-Score <SortIcon col="appLoadScore" sortBy={sortBy} sortDesc={sortDesc} />
|
||||
</th>
|
||||
<th
|
||||
className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:text-gray-700"
|
||||
onClick={() => handleSort("avgThroughputBytesPerSec")}
|
||||
>
|
||||
Throughput <SortIcon col="avgThroughputBytesPerSec" />
|
||||
Throughput <SortIcon col="avgThroughputBytesPerSec" sortBy={sortBy} sortDesc={sortDesc} />
|
||||
</th>
|
||||
<th
|
||||
className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:text-gray-700"
|
||||
onClick={() => handleSort("avgApiLatencyMs")}
|
||||
title="Agent→backend HTTP round-trip measured on the devices — high values indicate network distance to the backend region"
|
||||
>
|
||||
API Latency <SortIcon col="avgApiLatencyMs" />
|
||||
API Latency <SortIcon col="avgApiLatencyMs" sortBy={sortBy} sortDesc={sortDesc} />
|
||||
</th>
|
||||
<th
|
||||
className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:text-gray-700"
|
||||
onClick={() => handleSort("avgDoPercentPeerCaching")}
|
||||
>
|
||||
P2P % <SortIcon col="avgDoPercentPeerCaching" />
|
||||
P2P % <SortIcon col="avgDoPercentPeerCaching" sortBy={sortBy} sortDesc={sortDesc} />
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
vs Global
|
||||
|
||||
@@ -223,7 +223,6 @@ function LocationSessionsContent() {
|
||||
<div className="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
|
||||
<button
|
||||
onClick={() => {
|
||||
const tr = days === "7" ? "7d" : days === "30" ? "30d" : "90d";
|
||||
router.push(`/geographic-performance`);
|
||||
}}
|
||||
className="text-sm text-gray-600 hover:text-gray-900 mb-2 flex items-center"
|
||||
@@ -231,7 +230,7 @@ function LocationSessionsContent() {
|
||||
← Back to Geographic Performance
|
||||
</button>
|
||||
<div className="flex items-center space-x-3">
|
||||
<svg className="w-8 h-8 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<svg className="w-8 h-8 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import type { Metadata } from "next";
|
||||
import { LandingNavbar } from "../../components/landing/LandingNavbar";
|
||||
import { LoginButton } from "../../components/landing/LoginButton";
|
||||
import { SiteFooter } from "../../components/SiteFooter";
|
||||
import { DOCS_URL, SITE_URL } from "@/utils/config";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Get started",
|
||||
description:
|
||||
"From sign-in to your first live Autopilot enrollment in five steps: sign in, request access, grant consent, deploy the bootstrapper via Intune, watch live.",
|
||||
alternates: {
|
||||
canonical: `${SITE_URL}/get-started`,
|
||||
},
|
||||
};
|
||||
|
||||
const STEPS = [
|
||||
{
|
||||
title: "Sign in with Microsoft",
|
||||
description:
|
||||
"Use your work account — there is no signup form. The first sign-in creates nothing in your tenant.",
|
||||
note: "30 seconds",
|
||||
},
|
||||
{
|
||||
title: "Request access",
|
||||
description:
|
||||
"Autopilot Monitor is in preview: your tenant is queued and approved — you'll see the status right after signing in.",
|
||||
note: "Preview approval",
|
||||
},
|
||||
{
|
||||
title: "Grant consent once",
|
||||
description:
|
||||
"Approve the Entra ID application for your tenant so the portal can operate. One admin consent, fully revocable.",
|
||||
note: "One-time admin consent",
|
||||
},
|
||||
{
|
||||
title: "Deploy the bootstrapper via Intune",
|
||||
description:
|
||||
"Assign one PowerShell script to your Autopilot scope. That is the whole rollout — no servers, no gateways, no infrastructure on your side.",
|
||||
note: "One script assignment",
|
||||
},
|
||||
{
|
||||
title: "Watch your first enrollment live",
|
||||
description:
|
||||
"The next device that enrolls streams in minutes later: phases, app installs, analysis results, diagnostics on demand.",
|
||||
note: "Minutes later",
|
||||
},
|
||||
];
|
||||
|
||||
export default function GetStartedPage() {
|
||||
return (
|
||||
<div className="landing-v2 min-h-screen bg-[var(--lp-bg)] flex flex-col">
|
||||
<LandingNavbar />
|
||||
|
||||
<main className="flex-1 px-6 pt-16 sm:pt-20 pb-20">
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-[var(--lp-ink-faint)]">
|
||||
Get started
|
||||
</p>
|
||||
<h1 className="mt-4 text-4xl sm:text-5xl font-bold tracking-tight text-[var(--lp-ink)] leading-[1.08] text-balance">
|
||||
From sign-in to your first live enrollment.
|
||||
</h1>
|
||||
<p className="mt-4 text-lg text-[var(--lp-ink-soft)] leading-relaxed max-w-xl">
|
||||
Five steps, no infrastructure on your side. Most teams see their first live session
|
||||
the same day.
|
||||
</p>
|
||||
|
||||
{/* Step sequence */}
|
||||
<div className="relative mt-12">
|
||||
<div className="absolute left-[15px] top-3 bottom-3 w-px bg-[var(--lp-line)]" aria-hidden="true" />
|
||||
<ol className="space-y-8">
|
||||
{STEPS.map((step, i) => (
|
||||
<li key={step.title} className="relative pl-14">
|
||||
<span className="absolute left-0 top-0 w-8 h-8 rounded-full bg-[var(--lp-accent)] text-white text-sm font-bold flex items-center justify-center">
|
||||
{i + 1}
|
||||
</span>
|
||||
<div className="flex items-baseline gap-3 flex-wrap">
|
||||
<h2 className="text-lg font-semibold text-[var(--lp-ink)]">{step.title}</h2>
|
||||
<span className="text-[11px] font-medium px-2 py-0.5 rounded-full bg-[var(--lp-accent-soft)] text-[var(--lp-accent-ink)]">
|
||||
{step.note}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1.5 text-[15px] text-[var(--lp-ink-soft)] leading-relaxed">{step.description}</p>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
{/* CTA */}
|
||||
<div className="mt-14 flex flex-wrap items-center gap-3">
|
||||
<LoginButton className="px-7 py-3 rounded-lg bg-[var(--lp-accent-ink)] hover:brightness-110 hover:shadow-lg text-white font-semibold shadow-md transition-all">
|
||||
Sign in to get started
|
||||
</LoginButton>
|
||||
<a
|
||||
href={DOCS_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="px-7 py-3 rounded-lg border border-[var(--lp-line)] bg-[var(--lp-surface)] text-[var(--lp-ink)] font-semibold hover:border-[var(--lp-ink-faint)] transition-colors"
|
||||
>
|
||||
Read the docs
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p className="mt-6 text-sm text-[var(--lp-ink-faint)] leading-relaxed max-w-xl">
|
||||
When you need more later: the{" "}
|
||||
<a
|
||||
href={`${DOCS_URL}/plans`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[var(--lp-accent-ink)] hover:opacity-80 underline"
|
||||
>
|
||||
Enterprise plan
|
||||
</a>{" "}
|
||||
adds SLAs, support commitments, MSP delegation, higher operating limits and more —
|
||||
same service, nothing to migrate.
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<SiteFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,6 @@
|
||||
* it replaces the entire root layout when triggered.
|
||||
*/
|
||||
export default function GlobalError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
|
||||
@@ -27,12 +27,71 @@ html {
|
||||
:root {
|
||||
--background: #f9fafb;
|
||||
--foreground: #171717;
|
||||
|
||||
/* ===== Landing v2 design tokens (lp-*) =====
|
||||
Semantic tokens for the marketing surface (landing + shared footer).
|
||||
Components reference these via arbitrary-value utilities
|
||||
(bg-[var(--lp-surface)]) so the global `.dark .bg-white` override
|
||||
block never fights them — dark mode only redefines the variables. */
|
||||
--lp-bg: #fafafa;
|
||||
--lp-surface: #ffffff;
|
||||
--lp-surface-2: #f4f4f5;
|
||||
--lp-nav: rgba(255, 255, 255, 0.72);
|
||||
--lp-ink: #18181b;
|
||||
--lp-ink-soft: #52525b;
|
||||
--lp-ink-faint: #9ca3af;
|
||||
--lp-line: #e4e4e7;
|
||||
--lp-line-soft: #ededf0;
|
||||
--lp-accent: #33b161;
|
||||
--lp-accent-ink: #1e8a4c;
|
||||
--lp-accent-soft: rgba(51, 177, 97, 0.1);
|
||||
--lp-accent-line: rgba(51, 177, 97, 0.35);
|
||||
--lp-warn: #d97706;
|
||||
--lp-warn-soft: rgba(217, 119, 6, 0.1);
|
||||
--lp-danger: #dc2626;
|
||||
/* Terminal is dark in both themes */
|
||||
--lp-term-bg: #0d1424;
|
||||
--lp-term-line: #22304a;
|
||||
--lp-term-ink: #d6e1f0;
|
||||
--lp-term-faint: #7b8ba3;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--lp-bg: #0d1424;
|
||||
--lp-surface: #131d31;
|
||||
--lp-surface-2: #1a2740;
|
||||
--lp-nav: rgba(13, 20, 36, 0.72);
|
||||
--lp-ink: #e7edf6;
|
||||
--lp-ink-soft: #9fb0c5;
|
||||
--lp-ink-faint: #64748b;
|
||||
--lp-line: #23304a;
|
||||
--lp-line-soft: #1c2740;
|
||||
--lp-accent: #3bc06c;
|
||||
--lp-accent-ink: #55c57f;
|
||||
--lp-accent-soft: rgba(59, 192, 108, 0.12);
|
||||
--lp-accent-line: rgba(59, 192, 108, 0.4);
|
||||
--lp-warn: #f5a623;
|
||||
--lp-warn-soft: rgba(245, 166, 35, 0.12);
|
||||
--lp-danger: #f87171;
|
||||
}
|
||||
|
||||
body {
|
||||
color: var(--foreground);
|
||||
background: var(--background);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
/* Native radios/checkboxes: without @tailwindcss/forms the text-* utilities
|
||||
do nothing — the check color follows the OS accent color. Pin it to the
|
||||
brand green; semantic exceptions (danger red, global-admin purple)
|
||||
override per control via Tailwind accent-* utilities. */
|
||||
input[type="checkbox"],
|
||||
input[type="radio"] {
|
||||
accent-color: #16a34a; /* green-600 */
|
||||
}
|
||||
.dark input[type="checkbox"],
|
||||
.dark input[type="radio"] {
|
||||
accent-color: #22c55e; /* green-500 */
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
@@ -80,10 +139,13 @@ body {
|
||||
color: #94a3b8 !important;
|
||||
}
|
||||
.dark .text-gray-500 {
|
||||
color: #64748b !important;
|
||||
/* was #64748b — too dark on raised dark surfaces (notification items,
|
||||
hover rows); lightened for readable muted text, hierarchy kept. */
|
||||
color: #8896ad !important;
|
||||
}
|
||||
.dark .text-gray-400 {
|
||||
color: #64748b !important;
|
||||
/* was #64748b — see text-gray-500 note; one step more muted than 500. */
|
||||
color: #7b8ba3 !important;
|
||||
}
|
||||
|
||||
/* -- Borders -- */
|
||||
@@ -118,11 +180,14 @@ body {
|
||||
}
|
||||
|
||||
/* -- Hover states -- */
|
||||
/* Subtle, DARK hover surfaces: the former #334155 washed out muted text
|
||||
(notification timestamps, table metadata) on hovered rows. One step
|
||||
above the card surface (#1e293b) is enough as a hover signal. */
|
||||
.dark .hover\:bg-gray-50:hover {
|
||||
background-color: #334155 !important;
|
||||
background-color: #283548 !important;
|
||||
}
|
||||
.dark .hover\:bg-gray-100:hover {
|
||||
background-color: #334155 !important;
|
||||
background-color: #2e3c52 !important;
|
||||
}
|
||||
.dark .hover\:bg-gray-200:hover {
|
||||
background-color: #334155 !important;
|
||||
@@ -559,6 +624,9 @@ html:hover ::-webkit-scrollbar-thumb {
|
||||
.dark .text-blue-700 {
|
||||
color: #60a5fa !important; /* blue-400 — step icons etc. on dark surfaces */
|
||||
}
|
||||
.dark .text-green-700 {
|
||||
color: #4ade80 !important; /* green-400 — brand links/accents on dark surfaces */
|
||||
}
|
||||
.dark .text-indigo-700 {
|
||||
color: #a5b4fc !important; /* indigo-300 — category badge, correlation labels, doc links */
|
||||
}
|
||||
@@ -729,6 +797,127 @@ html:hover ::-webkit-scrollbar-thumb {
|
||||
animation: dotPulse 12.6s linear infinite;
|
||||
}
|
||||
|
||||
/* ===== LANDING V2 (scroll reveals, event stream, terminal) ===== */
|
||||
|
||||
/* The marketing surface is light-only for now: product screenshots are
|
||||
light, and a half-dark landing reads unfinished. Re-declare the light
|
||||
tokens inside .landing-v2 so a dark OS/portal preference does not
|
||||
flip the landing. Remove this block to re-enable dark marketing. */
|
||||
.dark .landing-v2 {
|
||||
--lp-bg: #fafafa;
|
||||
--lp-surface: #ffffff;
|
||||
--lp-surface-2: #f4f4f5;
|
||||
--lp-nav: rgba(255, 255, 255, 0.72);
|
||||
--lp-ink: #18181b;
|
||||
--lp-ink-soft: #52525b;
|
||||
--lp-ink-faint: #9ca3af;
|
||||
--lp-line: #e4e4e7;
|
||||
--lp-line-soft: #ededf0;
|
||||
--lp-accent: #33b161;
|
||||
--lp-accent-ink: #1e8a4c;
|
||||
--lp-accent-soft: rgba(51, 177, 97, 0.1);
|
||||
--lp-accent-line: rgba(51, 177, 97, 0.35);
|
||||
--lp-warn: #d97706;
|
||||
--lp-warn-soft: rgba(217, 119, 6, 0.1);
|
||||
--lp-danger: #dc2626;
|
||||
}
|
||||
|
||||
/* Neutralize the global .dark utility overrides inside the light-only
|
||||
marketing surface: about/terms/privacy still use plain bg-white /
|
||||
text-gray-* cards, which the app-wide dark overrides would otherwise
|
||||
flip and leave those pages half dark. Dies together with the
|
||||
light-only decision above. */
|
||||
.dark .landing-v2 .bg-white { background-color: #ffffff !important; }
|
||||
.dark .landing-v2 .bg-gray-50 { background-color: #f9fafb !important; }
|
||||
.dark .landing-v2 .bg-gray-100 { background-color: #f3f4f6 !important; }
|
||||
.dark .landing-v2 .text-gray-900 { color: #111827 !important; }
|
||||
.dark .landing-v2 .text-gray-800 { color: #1f2937 !important; }
|
||||
.dark .landing-v2 .text-gray-700 { color: #374151 !important; }
|
||||
.dark .landing-v2 .text-gray-600 { color: #4b5563 !important; }
|
||||
.dark .landing-v2 .text-gray-500 { color: #6b7280 !important; }
|
||||
.dark .landing-v2 .text-gray-400 { color: #9ca3af !important; }
|
||||
.dark .landing-v2 .border-gray-200 { border-color: #e5e7eb !important; }
|
||||
.dark .landing-v2 .border-gray-300 { border-color: #d1d5db !important; }
|
||||
.dark .landing-v2 .hover\:bg-gray-50:hover { background-color: #f9fafb !important; }
|
||||
.dark .landing-v2 .hover\:bg-gray-100:hover { background-color: #f3f4f6 !important; }
|
||||
|
||||
/* Scroll-triggered reveal: Reveal.tsx adds .lp-in when the element
|
||||
enters the viewport. Reduced motion renders everything visible. */
|
||||
.lp-reveal {
|
||||
opacity: 0;
|
||||
transform: translateY(18px);
|
||||
transition: opacity 0.6s ease, transform 0.6s ease;
|
||||
}
|
||||
.lp-reveal.lp-in {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
@keyframes lpFadeUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
.lp-event-in {
|
||||
animation: lpFadeUp 0.4s ease-out both;
|
||||
}
|
||||
|
||||
@keyframes lpBlink {
|
||||
0%,
|
||||
49% {
|
||||
opacity: 1;
|
||||
}
|
||||
50%,
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
.lp-cursor::after {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 1.05em;
|
||||
margin-left: 2px;
|
||||
vertical-align: text-bottom;
|
||||
background: var(--lp-accent);
|
||||
animation: lpBlink 1s steps(1) infinite;
|
||||
}
|
||||
|
||||
@keyframes lpPulse {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(51, 177, 97, 0.35);
|
||||
}
|
||||
60% {
|
||||
box-shadow: 0 0 0 6px rgba(51, 177, 97, 0);
|
||||
}
|
||||
}
|
||||
.lp-live-dot {
|
||||
animation: lpPulse 2s ease-out infinite;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.lp-reveal {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
transition: none;
|
||||
}
|
||||
.lp-event-in {
|
||||
animation: none;
|
||||
}
|
||||
.lp-cursor::after {
|
||||
animation: none;
|
||||
}
|
||||
.lp-live-dot {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== FEEDBACK BUBBLE ANIMATION ===== */
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
|
||||
@@ -14,7 +14,7 @@ interface HealthCheck {
|
||||
description: string;
|
||||
status: string;
|
||||
message: string;
|
||||
details?: Record<string, any>;
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface HealthCheckResult {
|
||||
@@ -183,7 +183,7 @@ export default function HealthCheckPage() {
|
||||
<button
|
||||
onClick={() => { performHealthCheck(); performMcpCheck(); }}
|
||||
disabled={loading}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors text-sm font-medium flex items-center gap-2"
|
||||
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors text-sm font-medium flex items-center gap-2"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
@@ -250,7 +250,7 @@ export default function HealthCheckPage() {
|
||||
<dd className="mt-1 font-mono text-gray-900 dark:text-gray-100">
|
||||
{healthResult.commitHash ? (
|
||||
<a
|
||||
href={`https://github.com/okieselbach/Autopilot-Monitor/commit/${healthResult.commitHash}`}
|
||||
href={`https://github.com/okieselbach/AutopilotMonitor/commit/${healthResult.commitHash}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-indigo-600 dark:text-indigo-400 hover:underline"
|
||||
@@ -334,7 +334,7 @@ export default function HealthCheckPage() {
|
||||
<button
|
||||
onClick={testGroupJoin}
|
||||
disabled={groupJoinTest === 'testing'}
|
||||
className="mt-3 px-3 py-1.5 bg-blue-600 text-white rounded text-xs font-medium hover:bg-blue-700 disabled:opacity-50 transition-colors"
|
||||
className="mt-3 px-3 py-1.5 bg-green-600 text-white rounded text-xs font-medium hover:bg-green-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{groupJoinTest === 'testing' ? 'Testing...' : groupJoinTest === 'failed' ? 'Retry Join Test' : 'Test Group Join'}
|
||||
</button>
|
||||
@@ -427,8 +427,8 @@ export default function HealthCheckPage() {
|
||||
updates in place without blocking any of the cards above. */}
|
||||
{(() => {
|
||||
const display = mcpLoading
|
||||
? { name: 'MCP Server', description: 'AI query interface availability', status: 'checking', message: 'Probing MCP server — waking it from idle if needed…', details: undefined as Record<string, any> | undefined }
|
||||
: (mcpCheck ?? { name: 'MCP Server', description: 'AI query interface availability', status: 'unknown', message: 'Not checked yet', details: undefined as Record<string, any> | undefined });
|
||||
? { name: 'MCP Server', description: 'AI query interface availability', status: 'checking', message: 'Probing MCP server — waking it from idle if needed…', details: undefined as Record<string, unknown> | undefined }
|
||||
: (mcpCheck ?? { name: 'MCP Server', description: 'AI query interface availability', status: 'unknown', message: 'Not checked yet', details: undefined as Record<string, unknown> | undefined });
|
||||
const colors = getStatusColor(display.status);
|
||||
return (
|
||||
<div className={`bg-white dark:bg-gray-800 rounded-lg shadow border-l-4 ${colors.accent}`}>
|
||||
|
||||
@@ -251,7 +251,7 @@ export default function ImePatternCard({
|
||||
{canEdit && (
|
||||
<button
|
||||
onClick={() => onStartEditing(pattern)}
|
||||
className="px-4 py-2 text-sm bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors flex items-center space-x-2"
|
||||
className="px-4 py-2 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors flex items-center space-x-2"
|
||||
title="Edit pattern (Global Admin)"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -330,7 +330,7 @@ export default function ImePatternCard({
|
||||
<select
|
||||
value={editForm.category}
|
||||
onChange={(e) => setEditForm({ ...editForm, category: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 bg-white focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 bg-white focus:ring-2 focus:ring-green-500 focus:border-green-500"
|
||||
>
|
||||
{CATEGORIES.map((c) => (
|
||||
<option key={c} value={c}>{CATEGORY_LABELS[c]}</option>
|
||||
@@ -344,7 +344,7 @@ export default function ImePatternCard({
|
||||
value={editForm.pattern}
|
||||
onChange={(e) => setEditForm({ ...editForm, pattern: e.target.value })}
|
||||
rows={3}
|
||||
className="w-full font-mono text-sm px-3 py-2 border border-gray-300 rounded-lg text-gray-900 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
|
||||
className="w-full font-mono text-sm px-3 py-2 border border-gray-300 rounded-lg text-gray-900 focus:ring-2 focus:ring-green-500 focus:border-green-500"
|
||||
spellCheck={false}
|
||||
placeholder="e.g., EMS Agent Started"
|
||||
/>
|
||||
@@ -356,7 +356,7 @@ export default function ImePatternCard({
|
||||
<select
|
||||
value={editForm.action}
|
||||
onChange={(e) => setEditForm({ ...editForm, action: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 bg-white focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 bg-white focus:ring-2 focus:ring-green-500 focus:border-green-500"
|
||||
>
|
||||
{ACTIONS.map((a) => (
|
||||
<option key={a} value={a}>{ACTION_LABELS[a]} ({a})</option>
|
||||
@@ -371,7 +371,7 @@ export default function ImePatternCard({
|
||||
value={editForm.description}
|
||||
onChange={(e) => setEditForm({ ...editForm, description: e.target.value })}
|
||||
rows={2}
|
||||
className="w-full text-sm px-3 py-2 border border-gray-300 rounded-lg text-gray-900 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
|
||||
className="w-full text-sm px-3 py-2 border border-gray-300 rounded-lg text-gray-900 focus:ring-2 focus:ring-green-500 focus:border-green-500"
|
||||
placeholder="What this pattern detects and why it matters..."
|
||||
/>
|
||||
</div>
|
||||
@@ -382,7 +382,7 @@ export default function ImePatternCard({
|
||||
id="edit-enabled"
|
||||
checked={editForm.enabled}
|
||||
onChange={(e) => setEditForm({ ...editForm, enabled: e.target.checked })}
|
||||
className="h-4 w-4 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500"
|
||||
className="h-4 w-4 text-indigo-600 border-gray-300 rounded focus:ring-green-500"
|
||||
/>
|
||||
<label htmlFor="edit-enabled" className="text-sm font-medium text-gray-700">Enabled</label>
|
||||
</div>
|
||||
@@ -403,7 +403,7 @@ export default function ImePatternCard({
|
||||
updated[key] = e.target.value;
|
||||
setEditForm({ ...editForm, parameters: updated });
|
||||
}}
|
||||
className="flex-1 text-sm px-2 py-1 border border-gray-300 rounded text-gray-900 focus:ring-1 focus:ring-indigo-500"
|
||||
className="flex-1 text-sm px-2 py-1 border border-gray-300 rounded text-gray-900 focus:ring-1 focus:ring-green-500"
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
@@ -428,14 +428,14 @@ export default function ImePatternCard({
|
||||
value={newParamKey}
|
||||
onChange={(e) => onSetNewParamKey(e.target.value)}
|
||||
placeholder="Key"
|
||||
className="w-32 text-sm px-2 py-1 border border-gray-300 rounded text-gray-900 focus:ring-1 focus:ring-indigo-500"
|
||||
className="w-32 text-sm px-2 py-1 border border-gray-300 rounded text-gray-900 focus:ring-1 focus:ring-green-500"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={newParamValue}
|
||||
onChange={(e) => onSetNewParamValue(e.target.value)}
|
||||
placeholder="Value"
|
||||
className="flex-1 text-sm px-2 py-1 border border-gray-300 rounded text-gray-900 focus:ring-1 focus:ring-indigo-500"
|
||||
className="flex-1 text-sm px-2 py-1 border border-gray-300 rounded text-gray-900 focus:ring-1 focus:ring-green-500"
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
@@ -449,7 +449,7 @@ export default function ImePatternCard({
|
||||
}
|
||||
}}
|
||||
disabled={!newParamKey.trim()}
|
||||
className="px-3 py-1 text-xs bg-indigo-600 text-white rounded hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="px-3 py-1 text-xs bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
@@ -468,7 +468,7 @@ export default function ImePatternCard({
|
||||
<button
|
||||
onClick={() => onSaveEdit(pattern.patternId)}
|
||||
disabled={saving}
|
||||
className="px-4 py-2 text-sm bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center space-x-2"
|
||||
className="px-4 py-2 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center space-x-2"
|
||||
>
|
||||
{saving ? (
|
||||
<>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ProtectedRoute } from "../../components/ProtectedRoute";
|
||||
import { useTenant } from "../../contexts/TenantContext";
|
||||
import { useAuth } from "../../contexts/AuthContext";
|
||||
@@ -18,8 +17,6 @@ import ImePatternCard from "./components/ImePatternCard";
|
||||
import { trackEvent } from "@/lib/appInsights";
|
||||
|
||||
export default function ImeLogPatternsPage() {
|
||||
const router = useRouter();
|
||||
|
||||
const { tenantId } = useTenant();
|
||||
const { user } = useAuth();
|
||||
const isGlobalAdmin = user?.isGlobalAdmin ?? false;
|
||||
@@ -256,7 +253,7 @@ export default function ImeLogPatternsPage() {
|
||||
</svg>
|
||||
<p className="text-sm text-blue-800">
|
||||
Missing a pattern, found a bug, or have an improvement idea? Help make Autopilot Monitor better —{" "}
|
||||
<a href="https://github.com/okieselbach/Autopilot-Monitor/issues" target="_blank" rel="noopener noreferrer" className="font-medium underline hover:text-blue-900">
|
||||
<a href="https://github.com/okieselbach/AutopilotMonitor/issues" target="_blank" rel="noopener noreferrer" className="font-medium underline hover:text-blue-900">
|
||||
open a GitHub issue
|
||||
</a>.
|
||||
</p>
|
||||
|
||||
@@ -24,9 +24,9 @@ const inter = Inter({ subsets: ["latin"] });
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL(SITE_URL),
|
||||
title: {
|
||||
absolute: "AutopilotMonitor – Real-Time Windows Enrollment Monitoring",
|
||||
default: "AutopilotMonitor",
|
||||
template: "%s | AutopilotMonitor",
|
||||
absolute: "Autopilot Monitor – Real-Time Windows Enrollment Monitoring",
|
||||
default: "Autopilot Monitor",
|
||||
template: "%s | Autopilot Monitor",
|
||||
},
|
||||
description:
|
||||
"Real-time monitoring and troubleshooting for Windows deployments. Track every enrollment phase, detect issues automatically with Analyze Rules, and resolve failures faster.",
|
||||
@@ -52,14 +52,14 @@ export const metadata: Metadata = {
|
||||
type: "website",
|
||||
locale: "en_US",
|
||||
url: SITE_URL,
|
||||
siteName: "AutopilotMonitor",
|
||||
title: "AutopilotMonitor – Real-Time Windows Enrollment Monitoring",
|
||||
siteName: "Autopilot Monitor",
|
||||
title: "Autopilot Monitor – Real-Time Windows Enrollment Monitoring",
|
||||
description:
|
||||
"Real-time monitoring and troubleshooting for Windows Autopilot deployments. Track every enrollment phase, detect issues automatically, and resolve failures faster.",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: "AutopilotMonitor – Real-Time Windows Enrollment Monitoring",
|
||||
title: "Autopilot Monitor – Real-Time Windows Enrollment Monitoring",
|
||||
description:
|
||||
"Real-time monitoring and troubleshooting for Windows Autopilot deployments. Track every enrollment phase, detect issues automatically, and resolve failures faster.",
|
||||
},
|
||||
@@ -87,7 +87,7 @@ export const metadata: Metadata = {
|
||||
const jsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "SoftwareApplication",
|
||||
name: "AutopilotMonitor",
|
||||
name: "Autopilot Monitor",
|
||||
description:
|
||||
"Real-time monitoring and troubleshooting platform for Windows deployments. Gives IT teams full visibility into enrollment phases, app progress, errors, and timelines.",
|
||||
applicationCategory: "BusinessApplication",
|
||||
@@ -103,7 +103,7 @@ const jsonLd = {
|
||||
url: "https://www.linkedin.com/in/oliver-kieselbach/",
|
||||
},
|
||||
url: SITE_URL,
|
||||
codeRepository: "https://github.com/okieselbach/Autopilot-Monitor",
|
||||
codeRepository: "https://github.com/okieselbach/AutopilotMonitor",
|
||||
keywords:
|
||||
"Windows Autopilot, Intune, enrollment monitoring, autopilot troubleshooting, Windows deployment",
|
||||
};
|
||||
@@ -113,8 +113,11 @@ export default function RootLayout({
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
// data-scroll-behavior: Next 16 no longer auto-suppresses CSS smooth-scroll
|
||||
// during route navigation; without the opt-in, globals.css's
|
||||
// `scroll-behavior: smooth` would animate every route change to top.
|
||||
return (
|
||||
<html lang="en">
|
||||
<html lang="en" data-scroll-behavior="smooth">
|
||||
<body className={inter.className}>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 163 KiB After Width: | Height: | Size: 150 KiB |
@@ -1,863 +1,36 @@
|
||||
import { PublicSiteNavbar } from "../components/PublicSiteNavbar";
|
||||
import { AuthGate } from "../components/landing/AuthGate";
|
||||
import { LoginButton } from "../components/landing/LoginButton";
|
||||
import { PlatformStats } from "../components/landing/PlatformStats";
|
||||
import { DOCS_URL } from "@/utils/config";
|
||||
|
||||
const QUICK_START = [
|
||||
{
|
||||
title: "Sign in and grant access",
|
||||
description: "Authenticate with Microsoft and approve tenant access once.",
|
||||
},
|
||||
{
|
||||
title: "Deploy bootstrapper in Intune",
|
||||
description: "Assign the bootstrap script to your Autopilot scope.",
|
||||
},
|
||||
{
|
||||
title: "Watch live telemetry",
|
||||
description: "Track phases, apps, failures, and actions in real time.",
|
||||
},
|
||||
];
|
||||
|
||||
const FLOW_STEPS = [
|
||||
{
|
||||
title: "Intune assignment",
|
||||
description: "Target the bootstrapper script to your selected Autopilot device groups.",
|
||||
icon: "users",
|
||||
},
|
||||
{
|
||||
title: "Bootstrapper execution",
|
||||
description: "Device runs the bootstrapper script and installs Autopilot Monitor Agent.",
|
||||
icon: "code",
|
||||
},
|
||||
{
|
||||
title: "Live monitoring",
|
||||
description: "Phase transitions, app installs, and progress are captured continuously.",
|
||||
icon: "monitor",
|
||||
},
|
||||
{
|
||||
title: "Event upload",
|
||||
description: "Predefined and custom events are uploaded to the backend pipeline.",
|
||||
icon: "cloud",
|
||||
},
|
||||
{
|
||||
title: "Rule analysis",
|
||||
description: "Backend correlates phases and runs analyze rules for instant insights.",
|
||||
icon: "rules",
|
||||
},
|
||||
{
|
||||
title: "Completion and notifications",
|
||||
description: "Session status is finalized and Teams notifications are triggered.",
|
||||
icon: "bell",
|
||||
},
|
||||
{
|
||||
title: "Diagnostics download",
|
||||
description: "Grab diagnostic bundles quickly for fast root-cause validation.",
|
||||
icon: "download",
|
||||
},
|
||||
];
|
||||
|
||||
function StepIcon({ icon }: { icon: string }) {
|
||||
switch (icon) {
|
||||
case "users":
|
||||
return (
|
||||
<svg className="w-4 h-4 text-blue-700" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M17 20h5v-1a4 4 0 00-5-3.87M9 20H2v-1a4 4 0 015-3.87m9-5.13a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</svg>
|
||||
);
|
||||
case "code":
|
||||
return (
|
||||
<svg className="w-4 h-4 text-blue-700" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m8 9-3 3 3 3m8-6 3 3-3 3M13 7l-2 10" />
|
||||
</svg>
|
||||
);
|
||||
case "monitor":
|
||||
return (
|
||||
<svg className="w-4 h-4 text-blue-700" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 4h18v12H3zM8 20h8m-4-4v4m-3-8 2-2 2 3 3-4 2 3" />
|
||||
</svg>
|
||||
);
|
||||
case "cloud":
|
||||
return (
|
||||
<svg className="w-4 h-4 text-blue-700" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M7 18a4 4 0 01-.3-8A5 5 0 1117 8h1a4 4 0 010 8h-3m-3 0v-7m0 7-3-3m3 3 3-3" />
|
||||
</svg>
|
||||
);
|
||||
case "rules":
|
||||
return (
|
||||
<svg className="w-4 h-4 text-blue-700" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5h10M9 9h10M9 13h10M9 17h10M4 5h.01M4 9h.01M4 13h.01M4 17h.01" />
|
||||
</svg>
|
||||
);
|
||||
case "bell":
|
||||
return (
|
||||
<svg className="w-4 h-4 text-blue-700" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 17h5l-1.4-1.4A2 2 0 0118 14.2V11a6 6 0 10-12 0v3.2c0 .5-.2 1-.6 1.4L4 17h5m6 0a3 3 0 11-6 0h6z" />
|
||||
</svg>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<svg className="w-4 h-4 text-blue-700" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 5v10m0 0-3-3m3 3 3-3M5 19h14" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
}
|
||||
import { LandingNavbar } from "../components/landing/LandingNavbar";
|
||||
import { Hero } from "../components/landing/Hero";
|
||||
import { StatsBand } from "../components/landing/StatsBand";
|
||||
import { JustAsk } from "../components/landing/JustAsk";
|
||||
import { Story } from "../components/landing/Story";
|
||||
import { CapabilitiesStrip } from "../components/landing/CapabilitiesStrip";
|
||||
import { Comparison } from "../components/landing/Comparison";
|
||||
import { HowItWorks } from "../components/landing/HowItWorks";
|
||||
import { FinalCta } from "../components/landing/FinalCta";
|
||||
import { SiteFooter } from "../components/SiteFooter";
|
||||
|
||||
/**
|
||||
* Landing page v2 — a scroll story of one enrollment.
|
||||
* All theming goes through the lp-* tokens (globals.css); sections live
|
||||
* in components/landing/. Auth behavior (AuthGate redirect, LoginButton
|
||||
* portal handoff) is unchanged from v1.
|
||||
*/
|
||||
export default function LandingPage() {
|
||||
return (
|
||||
<div className="landing-page min-h-screen bg-gradient-to-br from-blue-50 via-indigo-50 to-purple-50">
|
||||
<div className="landing-v2 min-h-screen bg-[var(--lp-bg)]">
|
||||
{/* Client component: handles auth redirect + loading overlay */}
|
||||
<AuthGate />
|
||||
|
||||
<PublicSiteNavbar showSectionLinks={true} />
|
||||
|
||||
{/* Hero Section */}
|
||||
<div className="pt-20 pb-20 px-6">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="text-center max-w-4xl mx-auto">
|
||||
<div className="mb-7 inline-flex items-center gap-3 rounded-2xl border border-blue-300/70 bg-gradient-to-r from-blue-50 via-indigo-50 to-blue-50 px-4 py-2.5 shadow-md ring-1 ring-blue-200/60">
|
||||
<span className="inline-flex h-6 w-6 items-center justify-center rounded-full bg-blue-600 text-white shadow-sm">
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M17 20h5v-1a4 4 0 00-5-3.87M9 20H2v-1a4 4 0 015-3.87m9-5.13a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</svg>
|
||||
</span>
|
||||
<p className="text-sm font-semibold text-blue-900">
|
||||
Community-driven Analyze Rules support
|
||||
<span className="font-normal text-blue-800">: discover, build, and share rules for everyone.</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="relative inline-block mb-6">
|
||||
<h1 className="text-6xl font-bold text-gray-900 leading-tight">
|
||||
Advanced Monitoring for
|
||||
<span className="bg-gradient-to-r from-blue-600 to-indigo-600 bg-clip-text text-transparent"> Windows Enrollments</span>
|
||||
</h1>
|
||||
<div className="pointer-events-none absolute -right-10 -top-1 rotate-[13deg] inline-flex items-center rounded-md border border-amber-300/80 bg-gradient-to-r from-amber-500 to-orange-500 px-3 py-1 shadow-md">
|
||||
<span className="text-[10px] font-bold uppercase tracking-[0.2em] text-white whitespace-nowrap">
|
||||
Private Preview Running
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xl text-gray-600 mb-8 leading-relaxed">
|
||||
Real-time insights, intelligent troubleshooting, and comprehensive analytics for your Autopilot deployments.
|
||||
Monitor every phase, run customizable analyze rules, and resolve issues faster than ever before.
|
||||
</p>
|
||||
<div className="flex items-center justify-center space-x-4">
|
||||
<LoginButton
|
||||
className="px-8 py-4 bg-gradient-to-r from-blue-600 to-indigo-600 text-white rounded-lg font-semibold text-lg shadow-xl hover:shadow-2xl transform hover:-translate-y-0.5 transition-all flex items-center space-x-2"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M10 12a2 2 0 100-4 2 2 0 000 4z" />
|
||||
<path fillRule="evenodd" d="M.458 10C1.732 5.943 5.522 3 10 3s8.268 2.943 9.542 7c-1.274 4.057-5.064 7-9.542 7S1.732 14.057.458 10zM14 10a4 4 0 11-8 0 4 4 0 018 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span>Get Started</span>
|
||||
</LoginButton>
|
||||
</div>
|
||||
<p className="mt-4 text-sm text-gray-500">
|
||||
Free to use • Open-Source • Sign in to request early access
|
||||
</p>
|
||||
|
||||
{/* Platform Stats - client component fetches live data */}
|
||||
<PlatformStats />
|
||||
|
||||
{/* Product Preview Showcase */}
|
||||
<div className="mt-14 relative max-w-5xl mx-auto">
|
||||
{/* Glow effects */}
|
||||
<div className="absolute -top-10 left-1/2 -translate-x-1/2 w-[600px] h-[300px] bg-gradient-to-br from-blue-400/20 via-indigo-400/15 to-purple-400/10 blur-3xl rounded-full pointer-events-none" />
|
||||
|
||||
{/* Main screenshot — Fleet Health Dashboard */}
|
||||
<div className="relative z-10 rounded-xl border border-gray-200/80 bg-white shadow-2xl overflow-hidden">
|
||||
{/* Browser chrome */}
|
||||
<div className="flex items-center gap-1.5 px-4 py-2.5 bg-gray-50 border-b border-gray-200">
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-red-400" />
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-amber-400" />
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-green-400" />
|
||||
<div className="ml-3 flex-1 bg-gray-200/80 rounded-md px-3 py-1 text-[10px] text-gray-400 font-mono">autopilotmonitor.com/fleet-health</div>
|
||||
</div>
|
||||
{/* Fleet Health mock content */}
|
||||
<div className="p-5 md:p-7">
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-6 h-6 rounded bg-blue-100 flex items-center justify-center">
|
||||
<svg className="w-3.5 h-3.5 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6m6 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0h6m4-10v10" /></svg>
|
||||
</div>
|
||||
<h4 className="text-lg font-bold text-gray-900">Fleet Health</h4>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<span className="px-3 py-1 text-[10px] font-semibold bg-blue-600 text-white rounded-md">Last 7 Days</span>
|
||||
<span className="px-3 py-1 text-[10px] font-medium text-gray-500 bg-gray-100 rounded-md">Last 30 Days</span>
|
||||
<span className="px-3 py-1 text-[10px] font-medium text-gray-500 bg-gray-100 rounded-md hidden sm:block">Last 90 Days</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* KPI cards */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-6">
|
||||
<div className="rounded-lg border-l-4 border-l-green-500 border border-gray-100 bg-green-50/50 p-3">
|
||||
<p className="text-[10px] text-gray-500 mb-0.5">Success Rate</p>
|
||||
<p className="text-2xl font-bold text-green-600">80.0%</p>
|
||||
<p className="text-[9px] text-gray-400">8 of 10 enrollments</p>
|
||||
</div>
|
||||
<div className="rounded-lg border-l-4 border-l-blue-500 border border-gray-100 bg-blue-50/50 p-3">
|
||||
<p className="text-[10px] text-gray-500 mb-0.5">Avg. Enrollment Time</p>
|
||||
<p className="text-2xl font-bold text-blue-600">18 min</p>
|
||||
<p className="text-[9px] text-gray-400">Completed enrollments</p>
|
||||
</div>
|
||||
<div className="rounded-lg border-l-4 border-l-red-500 border border-gray-100 bg-red-50/50 p-3">
|
||||
<p className="text-[10px] text-gray-500 mb-0.5">Failed</p>
|
||||
<p className="text-2xl font-bold text-red-500">2</p>
|
||||
<p className="text-[9px] text-gray-400">Needs attention</p>
|
||||
</div>
|
||||
<div className="rounded-lg border-l-4 border-l-indigo-500 border border-gray-100 bg-indigo-50/50 p-3">
|
||||
<p className="text-[10px] text-gray-500 mb-0.5">Active Now</p>
|
||||
<p className="text-2xl font-bold text-indigo-600">0</p>
|
||||
<p className="text-[9px] text-gray-400">Currently enrolling</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* Chart area */}
|
||||
<div className="rounded-lg border border-gray-100 bg-gray-50/60 p-4">
|
||||
<p className="text-sm font-semibold text-gray-800 mb-3">Enrollments Timeline</p>
|
||||
<div className="flex items-end gap-3 h-28 px-2">
|
||||
{[0, 0, 0, { g: 65, r: 25 }, { g: 50, r: 30 }, 0, { g: 40, r: 20 }, 0].map((bar, i) => (
|
||||
<div key={i} className="flex-1 flex flex-col items-center gap-0.5">
|
||||
{typeof bar === 'object' ? (
|
||||
<div className="w-full flex flex-col gap-px">
|
||||
<div className="w-full rounded-t bg-green-400" style={{ height: `${bar.g}px` }} />
|
||||
<div className="w-full rounded-b bg-red-300" style={{ height: `${bar.r}px` }} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full rounded bg-gray-200/60" style={{ height: '2px' }} />
|
||||
)}
|
||||
<span className="text-[8px] text-gray-400 mt-1">{['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun', 'Mon'][i]}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 mt-2 ml-2">
|
||||
<span className="flex items-center gap-1 text-[9px] text-gray-500"><span className="w-2 h-2 rounded-sm bg-green-400" />Success (8)</span>
|
||||
<span className="flex items-center gap-1 text-[9px] text-gray-500"><span className="w-2 h-2 rounded-sm bg-red-300" />Failed (2)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Floating card — Session Details (overlapping bottom-left) */}
|
||||
<div className="absolute -bottom-8 -left-4 md:-left-8 z-20 w-64 md:w-80 rounded-xl border border-gray-200/80 bg-white shadow-xl overflow-hidden transform rotate-[-2deg] hover:rotate-0 transition-transform duration-300">
|
||||
<div className="flex items-center gap-1.5 px-3 py-1.5 bg-gray-50 border-b border-gray-100">
|
||||
<div className="w-2 h-2 rounded-full bg-red-400" />
|
||||
<div className="w-2 h-2 rounded-full bg-amber-400" />
|
||||
<div className="w-2 h-2 rounded-full bg-green-400" />
|
||||
<span className="ml-2 text-[8px] text-gray-400 font-mono">Session Details</span>
|
||||
</div>
|
||||
<div className="p-3.5">
|
||||
<p className="text-xs font-bold text-gray-900 mb-2">Enrollment Progress</p>
|
||||
{/* Progress steps */}
|
||||
<div className="flex items-center gap-0">
|
||||
{['Start', 'Prep', 'Setup', 'Apps', 'Account', 'Apps', 'Final', 'Done'].map((step, i) => (
|
||||
<div key={i} className="flex items-center">
|
||||
<div className="w-5 h-5 rounded-full bg-green-500 flex items-center justify-center shrink-0">
|
||||
<svg className="w-2.5 h-2.5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}><path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" /></svg>
|
||||
</div>
|
||||
{i < 7 && <div className="w-2 md:w-3 h-0.5 bg-green-400" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-between mt-1 px-0.5">
|
||||
{['Start', '', '', '', '', '', '', 'Done'].map((label, i) => (
|
||||
<span key={i} className="text-[7px] text-gray-400">{label}</span>
|
||||
))}
|
||||
</div>
|
||||
{/* Analysis result */}
|
||||
<div className="mt-3 rounded-lg border-l-3 border-l-orange-400 bg-orange-50 p-2">
|
||||
<div className="flex items-center gap-1.5 mb-0.5">
|
||||
<span className="px-1.5 py-0.5 text-[7px] font-bold bg-orange-500 text-white rounded">HIGH</span>
|
||||
<span className="text-[8px] text-gray-500 font-mono">ANALYZE-APP-006</span>
|
||||
</div>
|
||||
<p className="text-[10px] font-semibold text-gray-800">Reboot Required Loop</p>
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
<span className="text-[8px] text-gray-400">Confidence:</span>
|
||||
<div className="w-12 h-1.5 bg-gray-200 rounded-full overflow-hidden"><div className="w-[70%] h-full bg-orange-500 rounded-full" /></div>
|
||||
<span className="text-[8px] text-gray-500">70%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Floating card — Performance Metrics (overlapping bottom-right) */}
|
||||
<div className="absolute -bottom-6 -right-4 md:-right-6 z-20 w-60 md:w-72 rounded-xl border border-gray-200/80 bg-white shadow-xl overflow-hidden transform rotate-[2deg] hover:rotate-0 transition-transform duration-300">
|
||||
<div className="flex items-center gap-1.5 px-3 py-1.5 bg-gray-50 border-b border-gray-100">
|
||||
<div className="w-2 h-2 rounded-full bg-red-400" />
|
||||
<div className="w-2 h-2 rounded-full bg-amber-400" />
|
||||
<div className="w-2 h-2 rounded-full bg-green-400" />
|
||||
<span className="ml-2 text-[8px] text-gray-400 font-mono">Performance Metrics</span>
|
||||
</div>
|
||||
<div className="p-3.5">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="rounded-lg border border-gray-100 p-2">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-[9px] text-gray-500">CPU</span>
|
||||
<span className="text-[10px] font-bold text-red-500">92%</span>
|
||||
</div>
|
||||
<div className="h-6 flex items-end gap-px">
|
||||
{[40, 55, 70, 85, 92, 88, 75, 60, 80, 92].map((v, i) => (
|
||||
<div key={i} className="flex-1 rounded-t bg-red-400/70" style={{ height: `${v * 0.24}px` }} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-gray-100 p-2">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-[9px] text-gray-500">Memory</span>
|
||||
<span className="text-[10px] font-bold text-amber-600">77%</span>
|
||||
</div>
|
||||
<div className="h-6 flex items-end gap-px">
|
||||
{[60, 65, 68, 70, 72, 75, 74, 76, 77, 77].map((v, i) => (
|
||||
<div key={i} className="flex-1 rounded-t bg-amber-400/70" style={{ height: `${v * 0.24}px` }} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Download progress */}
|
||||
<div className="mt-2.5">
|
||||
<p className="text-[9px] font-semibold text-gray-700 mb-1.5">Download Progress</p>
|
||||
{['RealmJoin Setup Launcher', 'BGInfo46', 'CMTrace.exe'].map((app, i) => (
|
||||
<div key={i} className="mb-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[8px] text-gray-600 flex items-center gap-1">
|
||||
<svg className="w-2.5 h-2.5 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}><path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" /></svg>
|
||||
{app}
|
||||
</span>
|
||||
<span className="text-[8px] text-green-600 font-medium">100%</span>
|
||||
</div>
|
||||
<div className="w-full h-1 bg-gray-100 rounded-full mt-0.5"><div className="w-full h-full bg-green-500 rounded-full" /></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* spacer for floating cards */}
|
||||
<div className="h-14" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Eye-Catcher Workflow Section */}
|
||||
<section id="how-it-works" className="py-20 px-6 scroll-mt-14">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="text-center max-w-4xl mx-auto">
|
||||
<p className="text-sm font-semibold text-blue-600 uppercase tracking-[0.22em] mb-3">
|
||||
How It Works
|
||||
</p>
|
||||
<h2 className="text-4xl md:text-5xl font-bold text-gray-900 leading-tight">
|
||||
From Intune rollout to deep insights in minutes
|
||||
</h2>
|
||||
<p className="mt-5 text-lg text-gray-600 leading-relaxed">
|
||||
Simple onboarding, clear phase visibility, and automated analysis in one workflow.
|
||||
Deploy once, monitor everything, and react faster when issues appear.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{QUICK_START.map((item, index) => (
|
||||
<div
|
||||
key={item.title}
|
||||
className="rounded-2xl border border-blue-100 bg-white/90 backdrop-blur-sm p-5 shadow-md"
|
||||
>
|
||||
<div className="inline-flex h-7 min-w-7 items-center justify-center rounded-full bg-blue-600 text-white text-xs font-bold px-2">
|
||||
{index + 1}
|
||||
</div>
|
||||
<h3 className="mt-3 text-lg font-semibold text-gray-900">{item.title}</h3>
|
||||
<p className="mt-2 text-sm text-gray-600 leading-relaxed">{item.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-8 md:mt-10 text-center">
|
||||
<p className="text-sm md:text-base text-gray-600 max-w-3xl mx-auto leading-relaxed">
|
||||
This is what happens after rollout: each phase is captured, correlated, and translated into clear, actionable insights.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 md:mt-7 rounded-3xl border border-blue-100 bg-gradient-to-br from-white via-blue-50/60 to-indigo-50/60 p-5 md:p-6 shadow-xl overflow-hidden relative">
|
||||
<div className="absolute -top-16 -left-16 w-48 h-48 bg-blue-200/30 blur-3xl rounded-full pointer-events-none" />
|
||||
<div className="absolute -bottom-20 -right-16 w-56 h-56 bg-indigo-200/25 blur-3xl rounded-full pointer-events-none" />
|
||||
|
||||
<div className="relative z-10">
|
||||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<h3 className="text-gray-900 text-2xl md:text-3xl font-bold">
|
||||
One pipeline from deployment to action
|
||||
</h3>
|
||||
</div>
|
||||
<LoginButton
|
||||
className="px-5 py-2.5 rounded-lg bg-gradient-to-r from-blue-600 to-indigo-600 text-white font-semibold shadow-lg hover:shadow-xl transition-all"
|
||||
>
|
||||
Start Now
|
||||
</LoginButton>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 relative">
|
||||
{/* Desktop: independent left/right stacks for true vertical overlap */}
|
||||
<div className="hidden md:grid grid-cols-[minmax(0,1fr)_40px_minmax(0,1fr)] gap-4 items-start relative isolate">
|
||||
<div className="space-y-20 relative z-30">
|
||||
{FLOW_STEPS.filter((_, i) => i % 2 === 0).map((step, leftIndex) => {
|
||||
const index = leftIndex * 2;
|
||||
return (
|
||||
<div
|
||||
key={step.title}
|
||||
className="step-card relative rounded-xl border border-blue-100 bg-white/90 backdrop-blur-sm p-3 md:p-3.5 shadow-md"
|
||||
style={{ animationDelay: `${index * 0.08}s` }}
|
||||
>
|
||||
<div className="absolute right-[-36px] top-[calc(50%+1px)] -translate-y-1/2 h-px w-[36px] bg-blue-200 z-10" />
|
||||
<span
|
||||
className="dot-pulse absolute right-[-42px] top-[calc(50%-4px)] -translate-y-1/2 h-3 w-3 rounded-full bg-blue-500 ring-4 ring-white z-50"
|
||||
style={{ animationDelay: `${index * 1.8}s` }}
|
||||
/>
|
||||
<p className="text-blue-600 text-xs font-semibold uppercase tracking-[0.18em]">
|
||||
Step {index + 1}
|
||||
</p>
|
||||
<div className="mt-1.5 flex items-start justify-between gap-3">
|
||||
<h4 className="text-gray-900 font-semibold">{step.title}</h4>
|
||||
<span className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border border-blue-200 bg-blue-50">
|
||||
<StepIcon icon={step.icon} />
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-gray-600 leading-relaxed">{step.description}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="relative self-stretch z-0">
|
||||
<div className="absolute left-1/2 -translate-x-1/2 top-2 bottom-2 w-px bg-blue-200 z-0" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-20 pt-24 relative z-30">
|
||||
{FLOW_STEPS.filter((_, i) => i % 2 === 1).map((step, rightIndex) => {
|
||||
const index = rightIndex * 2 + 1;
|
||||
return (
|
||||
<div
|
||||
key={step.title}
|
||||
className="step-card relative rounded-xl border border-blue-100 bg-white/90 backdrop-blur-sm p-3 md:p-3.5 shadow-md"
|
||||
style={{ animationDelay: `${index * 0.08}s` }}
|
||||
>
|
||||
<div className="absolute left-[-36px] top-[calc(50%+1px)] -translate-y-1/2 h-px w-[36px] bg-blue-200 z-10" />
|
||||
<span
|
||||
className="dot-pulse absolute left-[-42px] top-[calc(50%-4px)] -translate-y-1/2 h-3 w-3 rounded-full bg-blue-500 ring-4 ring-white z-50"
|
||||
style={{ animationDelay: `${index * 1.8}s` }}
|
||||
/>
|
||||
<p className="text-blue-600 text-xs font-semibold uppercase tracking-[0.18em]">
|
||||
Step {index + 1}
|
||||
</p>
|
||||
<div className="mt-1.5 flex items-start justify-between gap-3">
|
||||
<h4 className="text-gray-900 font-semibold">{step.title}</h4>
|
||||
<span className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border border-blue-200 bg-blue-50">
|
||||
<StepIcon icon={step.icon} />
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-gray-600 leading-relaxed">{step.description}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile: linear timeline */}
|
||||
<div className="md:hidden relative isolate">
|
||||
<div className="absolute left-4 top-3 bottom-3 w-px bg-blue-200" />
|
||||
<div className="space-y-5">
|
||||
{FLOW_STEPS.map((step, index) => (
|
||||
<div key={step.title} className="relative">
|
||||
<div className="absolute left-4 top-[calc(2rem+1px)] h-px w-6 bg-blue-200 z-10" />
|
||||
<span
|
||||
className="dot-pulse absolute left-4 top-[calc(1.75rem-4px)] h-3 w-3 rounded-full bg-blue-500 ring-4 ring-white z-50"
|
||||
style={{ animationDelay: `${index * 1.8}s` }}
|
||||
/>
|
||||
<div
|
||||
className="step-card ml-10 rounded-xl border border-blue-100 bg-white/90 backdrop-blur-sm p-3 shadow-md"
|
||||
style={{ animationDelay: `${index * 0.08}s` }}
|
||||
>
|
||||
<p className="text-blue-600 text-xs font-semibold uppercase tracking-[0.18em]">
|
||||
Step {index + 1}
|
||||
</p>
|
||||
<div className="mt-1.5 flex items-start justify-between gap-3">
|
||||
<h4 className="text-gray-900 font-semibold">{step.title}</h4>
|
||||
<span className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border border-blue-200 bg-blue-50">
|
||||
<StepIcon icon={step.icon} />
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-gray-600 leading-relaxed">{step.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
{[
|
||||
"Know exactly what happens at each enrollment phase",
|
||||
"Detect app bottlenecks and policy issues immediately",
|
||||
"Move from alert to diagnostics with minimal friction",
|
||||
].map(item => (
|
||||
<div key={item} className="rounded-xl border border-blue-200 bg-blue-50/80 p-3.5 text-sm text-blue-900">
|
||||
{item}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Features Grid */}
|
||||
<div id="features" className="py-20 px-6 bg-white/50 backdrop-blur-sm scroll-mt-14">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<p className="text-sm font-semibold text-center text-blue-600 uppercase tracking-[0.22em] mb-3">
|
||||
Features
|
||||
</p>
|
||||
<h2 className="text-3xl font-bold text-center text-gray-900 mb-12">
|
||||
Everything you need for full Autopilot visibility
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
{/* Feature 1 - Real-Time Monitoring */}
|
||||
<div className="bg-white rounded-2xl p-8 shadow-lg hover:shadow-xl transition-shadow">
|
||||
<div className="w-12 h-12 bg-blue-100 rounded-xl flex items-center justify-center mb-4">
|
||||
<svg className="w-6 h-6 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-3">Real-Time Monitoring</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
Watch Autopilot deployments in near real-time with live event streaming. Track every phase from device registration to user login.
|
||||
</p>
|
||||
<ul className="space-y-2">
|
||||
{["Live phase tracking", "Near realtime push updates", "Per-device event stream"].map(item => (
|
||||
<li key={item} className="flex items-center text-sm text-gray-500">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-blue-400 mr-2 shrink-0" />
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Feature 2 - Rich Analytics */}
|
||||
<div className="bg-white rounded-2xl p-8 shadow-lg hover:shadow-xl transition-shadow">
|
||||
<div className="w-12 h-12 bg-indigo-100 rounded-xl flex items-center justify-center mb-4">
|
||||
<svg className="w-6 h-6 text-indigo-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-3">Rich Analytics</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
Comprehensive metrics on deployment success rates, performance trends, and hardware insights — powered by customizable analyze rules.
|
||||
</p>
|
||||
<ul className="space-y-2">
|
||||
{["Customizable analyze rules", "Success & failure rates", "Hardware model insights"].map(item => (
|
||||
<li key={item} className="flex items-center text-sm text-gray-500">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-indigo-400 mr-2 shrink-0" />
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Feature 3 - Fleet Health */}
|
||||
<div className="bg-white rounded-2xl p-8 shadow-lg hover:shadow-xl transition-shadow">
|
||||
<div className="w-12 h-12 bg-purple-100 rounded-xl flex items-center justify-center mb-4">
|
||||
<svg className="w-6 h-6 text-purple-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-3">Fleet Health</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
Live overview of your entire device fleet. Spot unhealthy devices, track blocked enrollments, and monitor per-tenant health at a glance.
|
||||
</p>
|
||||
<ul className="space-y-2">
|
||||
{["Device health scoring", "Blocked device detection", "Tenant-level overview"].map(item => (
|
||||
<li key={item} className="flex items-center text-sm text-gray-500">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-purple-400 mr-2 shrink-0" />
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Feature 4 - Diagnostics Collection */}
|
||||
<div className="bg-white rounded-2xl p-8 shadow-lg hover:shadow-xl transition-shadow">
|
||||
<div className="w-12 h-12 bg-green-100 rounded-xl flex items-center justify-center mb-4">
|
||||
<svg className="w-6 h-6 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-3">Diagnostics Collection</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
Trigger on-demand diagnostic uploads directly from the portal. Collect ETL logs, event logs, and system info from any enrolled device.
|
||||
</p>
|
||||
<ul className="space-y-2">
|
||||
{["On-demand ZIP upload", "ETL & event log collection", "Per-device retrieval"].map(item => (
|
||||
<li key={item} className="flex items-center text-sm text-gray-500">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-green-400 mr-2 shrink-0" />
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Feature 5 - Event Timeline */}
|
||||
<div className="bg-white rounded-2xl p-8 shadow-lg hover:shadow-xl transition-shadow">
|
||||
<div className="w-12 h-12 bg-orange-100 rounded-xl flex items-center justify-center mb-4">
|
||||
<svg className="w-6 h-6 text-orange-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-3">Event Timeline</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
Detailed event timeline for every deployment session. Drill down into events, errors, and warnings to troubleshoot efficiently.
|
||||
</p>
|
||||
<ul className="space-y-2">
|
||||
{["Phase-by-phase breakdown", "App install details", "Error & warning highlights"].map(item => (
|
||||
<li key={item} className="flex items-center text-sm text-gray-500">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-orange-400 mr-2 shrink-0" />
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Feature 6 - Audit Logging */}
|
||||
<div className="bg-white rounded-2xl p-8 shadow-lg hover:shadow-xl transition-shadow">
|
||||
<div className="w-12 h-12 bg-red-100 rounded-xl flex items-center justify-center mb-4">
|
||||
<svg className="w-6 h-6 text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-3">Audit Logging</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
Complete audit trail of all actions and changes. Meet compliance requirements with detailed logging and data retention policies.
|
||||
</p>
|
||||
<ul className="space-y-2">
|
||||
{["Admin action history", "Configurable retention", "Tamper-evident records"].map(item => (
|
||||
<li key={item} className="flex items-center text-sm text-gray-500">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-red-400 mr-2 shrink-0" />
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Comparison Table */}
|
||||
<div id="comparison" className="py-20 px-6 bg-white/50 backdrop-blur-sm scroll-mt-14">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<p className="text-sm font-semibold text-center text-blue-600 uppercase tracking-widest mb-3">Comparison</p>
|
||||
<h2 className="text-4xl font-bold text-center text-gray-900 mb-4">
|
||||
Standard Autopilot vs. Monitored Autopilot
|
||||
</h2>
|
||||
<p className="text-center text-gray-500 mb-12 max-w-2xl mx-auto">
|
||||
See what you're missing without Autopilot Monitor — and what you gain the moment you deploy it.
|
||||
</p>
|
||||
|
||||
{/* Header */}
|
||||
<div className="grid grid-cols-[1fr_1fr_1fr] gap-0 mb-1">
|
||||
<div />
|
||||
<div className="bg-gradient-to-br from-blue-600 to-indigo-600 text-white text-center py-4 px-6 rounded-t-2xl mx-1">
|
||||
<div className="font-bold text-lg">Autopilot Monitor</div>
|
||||
<div className="text-blue-200 text-sm mt-0.5">Fully Monitored</div>
|
||||
</div>
|
||||
<div className="bg-gray-100 text-center py-4 px-6 rounded-t-2xl mx-1">
|
||||
<div className="font-semibold text-gray-700 text-lg">Standard Autopilot</div>
|
||||
<div className="text-gray-400 text-sm mt-0.5">Out of the Box</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rows */}
|
||||
{[
|
||||
{
|
||||
label: "Deployment Visibility",
|
||||
monitor: "Real-time phase tracking with live push updates",
|
||||
standard: "None — black box until it finishes or fails",
|
||||
},
|
||||
{
|
||||
label: "Download Progress",
|
||||
monitor: "Per-app download speed, bytes transferred, % complete",
|
||||
standard: "No visibility into what's downloading or how long it takes",
|
||||
},
|
||||
{
|
||||
label: "User-Facing Progress Page",
|
||||
monitor: "Progress view with live app status & download info",
|
||||
standard: "Generic ESP screen — no details for the end user",
|
||||
},
|
||||
{
|
||||
label: "Fleet Health Dashboard",
|
||||
monitor: "Success rates, failure trends, avg. duration across all devices",
|
||||
standard: "Limited manual report extraction from Intune",
|
||||
},
|
||||
{
|
||||
label: "Analyze Rules",
|
||||
monitor: "Built-in + fully customizable rules for automated issue detection",
|
||||
standard: "Manual log review required after every failed deployment",
|
||||
},
|
||||
{
|
||||
label: "Extended Data Gathering",
|
||||
monitor: "Custom gather rules to capture registry, files, or WMI on any event",
|
||||
standard: "No automated data collection during enrollment",
|
||||
},
|
||||
{
|
||||
label: "Geo & Network Context",
|
||||
monitor: "Device location, and network info captured at enrollment start",
|
||||
standard: "No location or network context in deployment records",
|
||||
},
|
||||
{
|
||||
label: "Performance Monitoring",
|
||||
monitor: "CPU, memory, disk, and network snapshots during deployment",
|
||||
standard: "Not captured — no way to detect resource bottlenecks",
|
||||
},
|
||||
{
|
||||
label: "Troubleshooting Speed",
|
||||
monitor: "Drill into per-event timeline, IME log patterns, and analyze results",
|
||||
standard: "Manual IME log hunting — slow and error-prone",
|
||||
},
|
||||
].map((row, i) => (
|
||||
<div
|
||||
key={row.label}
|
||||
className={`grid grid-cols-[1fr_1fr_1fr] gap-0 border-b border-gray-100 ${i % 2 === 0 ? "bg-white" : "bg-gray-50/60"}`}
|
||||
>
|
||||
<div className="py-4 px-5 font-semibold text-gray-800 text-sm flex items-center">{row.label}</div>
|
||||
<div className="py-4 px-5 mx-1 bg-blue-50/50 text-sm text-blue-900 flex items-start gap-2">
|
||||
<svg className="w-4 h-4 text-blue-500 mt-0.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
{row.monitor}
|
||||
</div>
|
||||
<div className="py-4 px-5 mx-1 text-sm text-gray-400 flex items-start gap-2">
|
||||
<svg className="w-4 h-4 text-gray-300 mt-0.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
{row.standard}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Bottom cap */}
|
||||
<div className="grid grid-cols-[1fr_1fr_1fr] gap-0">
|
||||
<div />
|
||||
<div className="bg-gradient-to-br from-blue-600 to-indigo-600 rounded-b-2xl mx-1 py-3 text-center text-blue-100 text-xs font-medium">
|
||||
Full observability from day one
|
||||
</div>
|
||||
<div className="bg-gray-100 rounded-b-2xl mx-1 py-3 text-center text-gray-400 text-xs">
|
||||
Limited to what Intune reports after the fact
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CTA Section */}
|
||||
<div className="py-20 px-6">
|
||||
<div className="max-w-4xl mx-auto text-center">
|
||||
<h2 className="text-4xl font-bold text-gray-900 mb-6">
|
||||
Ready to transform your Autopilot monitoring?
|
||||
</h2>
|
||||
<p className="text-xl text-gray-600 mb-8">
|
||||
Join organizations using Autopilot Monitor to react faster and monitor more reliably.
|
||||
</p>
|
||||
<LoginButton
|
||||
className="px-8 py-4 bg-gradient-to-r from-blue-600 to-indigo-600 text-white rounded-lg font-semibold text-lg shadow-xl hover:shadow-2xl transform hover:-translate-y-0.5 transition-all"
|
||||
>
|
||||
Start Monitoring Now
|
||||
</LoginButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="border-t border-gray-200/80 bg-white/60 backdrop-blur-sm">
|
||||
<div className="max-w-7xl mx-auto px-6 py-10">
|
||||
<div className="flex flex-col md:flex-row md:items-start gap-10">
|
||||
{/* Brand — left, fixed width with extra right margin */}
|
||||
<div className="shrink-0 md:w-52 md:mr-32">
|
||||
<div className="flex items-center space-x-2.5 mb-3">
|
||||
<div className="w-7 h-7 bg-gradient-to-br from-blue-600 to-indigo-600 rounded-lg flex items-center justify-center">
|
||||
<svg className="w-4 h-4 text-white" viewBox="0 0 24 24" fill="none">
|
||||
<rect x="5.0" y="12.2" width="2.8" height="7.8" rx="0.9" fill="currentColor" />
|
||||
<rect x="10.6" y="10.9" width="2.8" height="9.1" rx="0.9" fill="currentColor" />
|
||||
<rect x="16.2" y="8.6" width="2.8" height="11.4" rx="0.9" fill="currentColor" />
|
||||
<path d="M4.4 8.9L8.6 6.8L12.0 7.4L15.4 5.5L18.8 4.9" stroke="currentColor" strokeWidth="1" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M17.8 4.2L19.1 4.9L17.9 5.9" stroke="currentColor" strokeWidth="1" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-sm font-bold text-gray-900">Autopilot Monitor</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 leading-relaxed mb-4">
|
||||
Real-time monitoring and analytics for Windows enrollments.
|
||||
</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<a href="https://www.linkedin.com/in/oliver-kieselbach/" target="_blank" rel="noopener noreferrer" className="text-gray-400 hover:text-blue-600 transition-colors" title="LinkedIn">
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 01-2.063-2.065 2.064 2.064 0 112.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z" />
|
||||
</svg>
|
||||
</a>
|
||||
<a href="https://github.com/okieselbach/Autopilot-Monitor" target="_blank" rel="noopener noreferrer" className="text-gray-400 hover:text-gray-900 transition-colors" title="GitHub">
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Link columns — spread across remaining space */}
|
||||
<div className="flex-1 grid grid-cols-2 sm:grid-cols-4 gap-8">
|
||||
{/* Product */}
|
||||
<div>
|
||||
<h4 className="text-[11px] font-semibold text-gray-900 uppercase tracking-wider mb-2.5">Product</h4>
|
||||
<ul className="space-y-1.5">
|
||||
<li><a href="#features" className="text-xs text-gray-500 hover:text-blue-600 transition-colors">Features</a></li>
|
||||
<li><a href="#how-it-works" className="text-xs text-gray-500 hover:text-blue-600 transition-colors">How It Works</a></li>
|
||||
<li><a href="#comparison" className="text-xs text-gray-500 hover:text-blue-600 transition-colors">Comparison</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Resources */}
|
||||
<div>
|
||||
<h4 className="text-[11px] font-semibold text-gray-900 uppercase tracking-wider mb-2.5">Resources</h4>
|
||||
<ul className="space-y-1.5">
|
||||
<li><a href={DOCS_URL} target="_blank" rel="noopener noreferrer" className="text-xs text-gray-500 hover:text-blue-600 transition-colors">Documentation</a></li>
|
||||
<li><a href="https://github.com/okieselbach/Autopilot-Monitor/issues" target="_blank" rel="noopener noreferrer" className="text-xs text-gray-500 hover:text-blue-600 transition-colors">Feedback</a></li>
|
||||
<li><a href="https://github.com/okieselbach/Autopilot-Monitor" target="_blank" rel="noopener noreferrer" className="text-xs text-gray-500 hover:text-blue-600 transition-colors">GitHub</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* About */}
|
||||
<div>
|
||||
<h4 className="text-[11px] font-semibold text-gray-900 uppercase tracking-wider mb-2.5">About</h4>
|
||||
<ul className="space-y-1.5">
|
||||
<li><a href="/about" className="text-xs text-gray-500 hover:text-blue-600 transition-colors">Overview</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Legal */}
|
||||
<div>
|
||||
<h4 className="text-[11px] font-semibold text-gray-900 uppercase tracking-wider mb-2.5">Legal</h4>
|
||||
<ul className="space-y-1.5">
|
||||
<li><a href="/privacy" className="text-xs text-gray-500 hover:text-blue-600 transition-colors">Privacy Policy</a></li>
|
||||
<li><a href="/terms" className="text-xs text-gray-500 hover:text-blue-600 transition-colors">Terms of Use</a></li>
|
||||
<li><a href="https://www.glueckkanja.com/en/imprint" target="_blank" rel="noopener noreferrer" className="text-xs text-gray-500 hover:text-blue-600 transition-colors">Imprint</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom bar */}
|
||||
<div className="mt-8 pt-5 border-t border-gray-200 flex flex-col sm:flex-row items-center justify-between gap-2">
|
||||
<p className="text-[11px] text-gray-400">
|
||||
© 2026 Autopilot Monitor. Built with ❤️ by Oliver Kieselbach.{" "}
|
||||
<span className="inline-block">Hosted on Azure by glueckkanja AG.</span>
|
||||
</p>
|
||||
<p className="text-[11px] text-gray-400">
|
||||
Open source. Star us on{' '}
|
||||
<a href="https://github.com/okieselbach/Autopilot-Monitor" target="_blank" rel="noopener noreferrer" className="text-gray-500 hover:text-blue-600 transition-colors">GitHub</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
<LandingNavbar />
|
||||
<Hero />
|
||||
<StatsBand />
|
||||
<JustAsk />
|
||||
<Story />
|
||||
<CapabilitiesStrip />
|
||||
<Comparison />
|
||||
<HowItWorks />
|
||||
<FinalCta />
|
||||
<SiteFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { DOCS_URL } from "@/utils/config";
|
||||
import { BrandMark } from "../../components/BrandMark";
|
||||
|
||||
export default function PreviewPage() {
|
||||
const { isAuthenticated, isLoading, user, isPreviewBlocked, previewMessage, logout, getAccessToken } = useAuth();
|
||||
@@ -14,8 +15,16 @@ export default function PreviewPage() {
|
||||
const [emailStatus, setEmailStatus] = useState<"idle" | "saving" | "saved" | "error">("idle");
|
||||
const [emailError, setEmailError] = useState("");
|
||||
|
||||
// ?demo=1 renders the page without auth/redirects (placeholder data) so
|
||||
// the design can be reviewed locally and on SWA preview environments.
|
||||
const [demo, setDemo] = useState<boolean | null>(null);
|
||||
useEffect(() => {
|
||||
setDemo(new URLSearchParams(window.location.search).has("demo"));
|
||||
}, []);
|
||||
|
||||
// If not preview-blocked (e.g. approved tenant navigates here), redirect away
|
||||
useEffect(() => {
|
||||
if (demo === null || demo) return;
|
||||
if (!isLoading && isAuthenticated && user && !isPreviewBlocked) {
|
||||
if (user.isTenantAdmin || user.isGlobalAdmin) {
|
||||
router.push("/dashboard");
|
||||
@@ -26,7 +35,7 @@ export default function PreviewPage() {
|
||||
if (!isLoading && !isAuthenticated) {
|
||||
router.push("/");
|
||||
}
|
||||
}, [isAuthenticated, isLoading, user, isPreviewBlocked, router]);
|
||||
}, [demo, isAuthenticated, isLoading, user, isPreviewBlocked, router]);
|
||||
|
||||
const handleSaveEmail = async () => {
|
||||
const email = notificationEmail.trim();
|
||||
@@ -67,63 +76,64 @@ export default function PreviewPage() {
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
if (!demo && isLoading) {
|
||||
return (
|
||||
<div className="landing-page min-h-screen bg-gradient-to-br from-blue-50 via-indigo-50 to-purple-50 flex items-center justify-center">
|
||||
<div className="landing-v2 min-h-screen bg-[var(--lp-bg)] flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600">Loading...</p>
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-[var(--lp-accent)] mx-auto"></div>
|
||||
<p className="mt-4 text-[var(--lp-ink-soft)]">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const upn = user?.upn ?? "you@contoso.com";
|
||||
const tenantId = user?.tenantId ?? "00000000-0000-0000-0000-000000000000";
|
||||
|
||||
return (
|
||||
<div className="landing-page min-h-screen bg-gradient-to-br from-blue-50 via-indigo-50 to-purple-50 flex items-center justify-center px-6">
|
||||
<div className="landing-v2 min-h-screen bg-[var(--lp-bg)] flex items-center justify-center px-6 py-12">
|
||||
<div className="max-w-lg w-full text-center">
|
||||
{/* Logo */}
|
||||
<div className="flex items-center justify-center space-x-3 mb-8">
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-blue-600 to-indigo-600 rounded-lg flex items-center justify-center">
|
||||
<svg className="w-7 h-7 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-2xl font-bold bg-gradient-to-r from-blue-600 to-indigo-600 bg-clip-text text-transparent">
|
||||
Autopilot Monitor
|
||||
</span>
|
||||
{/* Brand */}
|
||||
<div className="flex items-center justify-center gap-2.5 mb-8">
|
||||
<BrandMark className="w-7 h-7" />
|
||||
<span className="text-xl font-bold tracking-tight text-[var(--lp-ink)]">Autopilot Monitor</span>
|
||||
</div>
|
||||
|
||||
{/* Card */}
|
||||
<div className="bg-white rounded-2xl shadow-xl p-10">
|
||||
{/* Clock icon */}
|
||||
<div className="w-16 h-16 bg-amber-100 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<svg className="w-8 h-8 text-amber-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
<div className="bg-[var(--lp-surface)] border border-[var(--lp-line)] rounded-2xl shadow-xl shadow-black/[0.06] p-8 sm:p-10">
|
||||
<div className="w-14 h-14 bg-[var(--lp-warn-soft)] rounded-full flex items-center justify-center mx-auto mb-5">
|
||||
<svg className="w-7 h-7 text-[var(--lp-warn)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h1 className="text-2xl font-normal text-gray-900 mb-3">
|
||||
Private Preview
|
||||
<div className="mb-3">
|
||||
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-[var(--lp-warn-soft)] text-[var(--lp-warn)] text-xs font-bold uppercase tracking-wide">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-[var(--lp-warn)]" />
|
||||
Approval pending
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h1 className="text-2xl font-bold tracking-tight text-[var(--lp-ink)] mb-2">
|
||||
Almost there — you're on the list
|
||||
</h1>
|
||||
|
||||
<p className="text-gray-600 mb-6 leading-relaxed">
|
||||
{previewMessage || "Autopilot Monitor is currently in private preview. Your organization has been added to the waitlist."}
|
||||
<p className="text-[15px] text-[var(--lp-ink-soft)] mb-6 leading-relaxed">
|
||||
{previewMessage || "Autopilot Monitor is currently in preview. Your organization has been added to the waitlist."}
|
||||
</p>
|
||||
|
||||
<div className="bg-blue-50 rounded-lg p-4 mb-6">
|
||||
<p className="text-sm text-blue-700">
|
||||
Signed in as <span className="font-semibold">{user?.upn}</span>
|
||||
</p>
|
||||
<p className="text-xs text-blue-500 mt-1">
|
||||
Tenant: {user?.tenantId}
|
||||
<div className="bg-[var(--lp-surface-2)] border border-[var(--lp-line-soft)] rounded-xl p-3.5 mb-5">
|
||||
<p className="text-sm text-[var(--lp-ink-soft)]">
|
||||
Signed in as <span className="font-semibold text-[var(--lp-ink)]">{upn}</span>
|
||||
</p>
|
||||
<p className="text-xs text-[var(--lp-ink-faint)] mt-0.5 font-mono">Tenant: {tenantId}</p>
|
||||
</div>
|
||||
|
||||
{/* Notification Email */}
|
||||
<div className="text-left bg-indigo-50 border border-indigo-200 rounded-lg p-4 mb-6">
|
||||
<p className="text-sm font-semibold text-indigo-900 mb-1">Get notified when approved</p>
|
||||
<p className="text-sm text-indigo-700 mb-3">
|
||||
Enter your email and we'll notify you as soon as your Private Preview access is granted.
|
||||
<div className="text-left bg-[var(--lp-accent-soft)] border border-[var(--lp-accent-line)] rounded-xl p-4 mb-5">
|
||||
<p className="text-sm font-semibold text-[var(--lp-ink)] mb-1">Get notified when approved</p>
|
||||
<p className="text-sm text-[var(--lp-ink-soft)] mb-3">
|
||||
Enter your email and we'll notify you as soon as your preview access is granted.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
@@ -131,73 +141,72 @@ export default function PreviewPage() {
|
||||
value={notificationEmail}
|
||||
onChange={(e) => { setNotificationEmail(e.target.value); setEmailStatus("idle"); setEmailError(""); }}
|
||||
placeholder="your@email.com"
|
||||
className="flex-1 px-3 py-2 border border-indigo-300 rounded-lg text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
|
||||
className="flex-1 px-3 py-2 border border-[var(--lp-line)] bg-[var(--lp-surface)] rounded-lg text-sm text-[var(--lp-ink)] placeholder-[var(--lp-ink-faint)] focus:outline-none focus:ring-2 focus:ring-[var(--lp-accent)] focus:border-[var(--lp-accent)] transition-colors"
|
||||
onKeyDown={(e) => { if (e.key === "Enter") handleSaveEmail(); }}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSaveEmail}
|
||||
disabled={emailStatus === "saving" || !notificationEmail.trim()}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors whitespace-nowrap"
|
||||
className="px-4 py-2 text-sm font-semibold text-white bg-[var(--lp-accent-ink)] rounded-lg hover:brightness-110 disabled:opacity-50 disabled:cursor-not-allowed transition-all whitespace-nowrap"
|
||||
>
|
||||
{emailStatus === "saving" ? "Saving..." : "Notify me"}
|
||||
</button>
|
||||
</div>
|
||||
{emailStatus === "saved" && (
|
||||
<p className="text-xs text-green-600 mt-2 font-medium">
|
||||
Email saved! We'll send you a notification when your access is approved.
|
||||
<p className="text-xs text-[var(--lp-accent-ink)] mt-2 font-medium">
|
||||
Email saved! We'll send you a notification when your access is approved.
|
||||
</p>
|
||||
)}
|
||||
{emailStatus === "error" && emailError && (
|
||||
<p className="text-xs text-red-600 mt-2 font-medium">{emailError}</p>
|
||||
<p className="text-xs text-[var(--lp-danger)] mt-2 font-medium">{emailError}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-left bg-amber-50 border border-amber-200 rounded-lg p-4 mb-6">
|
||||
<p className="text-sm font-semibold text-amber-900 mb-1">Next steps</p>
|
||||
<p className="text-sm text-amber-800">
|
||||
Please sign out and contact me on LinkedIn or open a GitHub issue to request access to the Private Preview. I check incoming requests regularly and will approve them as quickly as possible if I have enough capacity left.
|
||||
<div className="text-left bg-[var(--lp-surface-2)] border border-[var(--lp-line-soft)] rounded-xl p-4 mb-6">
|
||||
<p className="text-sm font-semibold text-[var(--lp-ink)] mb-1">Next steps</p>
|
||||
<p className="text-sm text-[var(--lp-ink-soft)]">
|
||||
Please sign out and wait a bit or contact me on LinkedIn or open a GitHub issue to request
|
||||
access to the preview. I check incoming requests regularly and will approve them as quickly
|
||||
as possible.
|
||||
</p>
|
||||
<p className="text-sm text-amber-800 mt-2">
|
||||
In the meantime, you can already review the setup and configuration in the <a href={DOCS_URL} target="_blank" rel="noopener noreferrer" className="underline hover:no-underline">documentation</a>.
|
||||
<p className="text-sm text-[var(--lp-ink-soft)] mt-2">
|
||||
In the meantime, you can already review the setup and configuration in the{" "}
|
||||
<a href={DOCS_URL} target="_blank" rel="noopener noreferrer" className="text-[var(--lp-accent-ink)] hover:opacity-80 underline">documentation</a>.
|
||||
</p>
|
||||
<p className="text-sm text-amber-800 mt-2">
|
||||
When you signed-up, sign in again later to view the updated approval status on your dashboard.
|
||||
<p className="text-sm text-[var(--lp-ink-soft)] mt-2">
|
||||
Sign in again later to check: while your approval is pending you'll land back on this
|
||||
page — once approved, you'll go straight into the portal.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2 mt-3">
|
||||
<a
|
||||
href="https://www.linkedin.com/in/oliver-kieselbach/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-blue-700 bg-blue-100 rounded-md hover:bg-blue-200 transition-colors"
|
||||
>
|
||||
LinkedIn
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
|
||||
</svg>
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/okieselbach/Autopilot-Monitor/issues"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-blue-700 bg-blue-100 rounded-md hover:bg-blue-200 transition-colors"
|
||||
>
|
||||
GitHub Issues
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
|
||||
</svg>
|
||||
</a>
|
||||
{[
|
||||
{ label: "LinkedIn", href: "https://www.linkedin.com/in/oliver-kieselbach/" },
|
||||
{ label: "GitHub Issues", href: "https://github.com/okieselbach/AutopilotMonitor/issues" },
|
||||
].map(link => (
|
||||
<a
|
||||
key={link.label}
|
||||
href={link.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-[var(--lp-ink)] border border-[var(--lp-line)] bg-[var(--lp-surface)] rounded-lg hover:border-[var(--lp-ink-faint)] transition-colors"
|
||||
>
|
||||
{link.label}
|
||||
<svg className="w-3.5 h-3.5 text-[var(--lp-ink-faint)]" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
|
||||
</svg>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={logout}
|
||||
className="px-6 py-3 bg-gray-100 text-gray-700 rounded-lg font-semibold hover:bg-gray-200 transition-colors"
|
||||
className="px-6 py-2.5 border border-[var(--lp-line)] bg-[var(--lp-surface)] text-[var(--lp-ink)] rounded-lg font-semibold hover:border-[var(--lp-ink-faint)] transition-colors"
|
||||
>
|
||||
Sign Out
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="mt-6 text-sm text-gray-400">
|
||||
<p className="mt-6 text-sm text-[var(--lp-ink-faint)]">
|
||||
© 2026 Autopilot Monitor. Powered by Azure and Microsoft Identity.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,7 @@ export const metadata: Metadata = {
|
||||
description:
|
||||
"Privacy Policy for Autopilot Monitor. Learn how we collect, store, and protect your data when using the Windows Autopilot monitoring platform.",
|
||||
openGraph: {
|
||||
title: "Privacy Policy | AutopilotMonitor",
|
||||
title: "Privacy Policy | Autopilot Monitor",
|
||||
description:
|
||||
"Privacy Policy for Autopilot Monitor. Learn how we collect, store, and protect your data.",
|
||||
url: `${SITE_URL}/privacy`,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { PublicPageHeader } from "../../components/PublicPageHeader";
|
||||
import { LandingNavbar } from "../../components/landing/LandingNavbar";
|
||||
import { SiteFooter } from "../../components/SiteFooter";
|
||||
import { DOCS_URL } from "@/utils/config";
|
||||
|
||||
const LAST_UPDATED = "29 July 2026";
|
||||
@@ -11,7 +12,7 @@ function DocsLink({ href, children }: { href: string; children: React.ReactNode
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-indigo-600 hover:text-indigo-800 underline"
|
||||
className="text-[var(--lp-accent-ink)] hover:opacity-80 underline"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
@@ -20,10 +21,18 @@ function DocsLink({ href, children }: { href: string; children: React.ReactNode
|
||||
|
||||
export default function PrivacyPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<PublicPageHeader title="Privacy Policy" />
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-6">
|
||||
<div className="bg-white rounded-lg shadow p-6 space-y-4">
|
||||
<div className="landing-v2 min-h-screen bg-[var(--lp-bg)]">
|
||||
<LandingNavbar />
|
||||
<header className="px-4 sm:px-6 lg:px-8 pt-14 sm:pt-16">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-[var(--lp-ink-faint)]">Legal</p>
|
||||
<h1 className="mt-3 text-3xl sm:text-4xl font-bold tracking-tight text-[var(--lp-ink)]">
|
||||
Privacy Policy
|
||||
</h1>
|
||||
</div>
|
||||
</header>
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-6 text-[15px]">
|
||||
<div className="bg-[var(--lp-surface)] border border-[var(--lp-line-soft)] rounded-xl p-6 space-y-4">
|
||||
<p className="text-sm text-gray-500">Last updated: {LAST_UPDATED}</p>
|
||||
<p className="text-gray-700">
|
||||
Autopilot Monitor collects technical telemetry about Windows Autopilot enrollments so that IT teams can see
|
||||
@@ -32,7 +41,7 @@ export default function PrivacyPage() {
|
||||
published in the <DocsLink href={DOCS_SECURITY_FAQ}>Security & Privacy FAQ</DocsLink>.
|
||||
</p>
|
||||
|
||||
<h2 className="text-xl font-semibold text-gray-900 mt-6">Roles: Who Is Responsible for What</h2>
|
||||
<h2 className="text-lg font-semibold text-gray-900 mt-6">Roles: Who Is Responsible for What</h2>
|
||||
<p className="text-gray-700">
|
||||
For enrollment telemetry, <strong>your organization is the controller and Autopilot Monitor acts as your
|
||||
processor</strong>. You decide which devices are monitored, what additional data your gather rules collect,
|
||||
@@ -45,7 +54,7 @@ export default function PrivacyPage() {
|
||||
<p className="text-gray-700">
|
||||
glueckkanja AG, a German company certified to ISO/IEC 27001, operates Autopilot Monitor for{" "}
|
||||
<strong>both plans</strong> — the same service, the same infrastructure, the same protection measures. Company
|
||||
details are in the <a href="https://www.glueckkanja.com/en/imprint" target="_blank" rel="noopener noreferrer" className="text-indigo-600 hover:text-indigo-800 underline">Imprint</a>.
|
||||
details are in the <a href="https://www.glueckkanja.com/en/imprint" target="_blank" rel="noopener noreferrer" className="text-[var(--lp-accent-ink)] hover:opacity-80 underline">Imprint</a>.
|
||||
The project is maintained by Oliver Kieselbach, who acts in that role on behalf of glueckkanja AG and is the
|
||||
contact for the open-source project and the Community edition.
|
||||
</p>
|
||||
@@ -54,7 +63,7 @@ export default function PrivacyPage() {
|
||||
glueckkanja AG. On the Enterprise plan it forms part of the written agreement.
|
||||
</p>
|
||||
|
||||
<h2 className="text-xl font-semibold text-gray-900 mt-6">What We Collect</h2>
|
||||
<h2 className="text-lg font-semibold text-gray-900 mt-6">What We Collect</h2>
|
||||
<h3 className="text-lg font-medium text-gray-800 mt-4">From enrolling devices</h3>
|
||||
<ul className="list-disc list-inside space-y-2 text-gray-700 ml-4">
|
||||
<li><strong>Device identity</strong> — serial number, device name, manufacturer, model, and the Entra ID tenant the device enrolls into</li>
|
||||
@@ -78,7 +87,7 @@ export default function PrivacyPage() {
|
||||
notification address, that address is copied once as the initial contact and is yours to change from then on.
|
||||
</p>
|
||||
|
||||
<h2 className="text-xl font-semibold text-gray-900 mt-6">What We Do Not Collect</h2>
|
||||
<h2 className="text-lg font-semibold text-gray-900 mt-6">What We Do Not Collect</h2>
|
||||
<p className="text-gray-700">
|
||||
During Autopilot provisioning the user signs in once to start the process and then does not interact with the
|
||||
device while it provisions. The agent captures <strong>no browsing history, no file or document content, no
|
||||
@@ -91,7 +100,7 @@ export default function PrivacyPage() {
|
||||
and establishing persistence are hard-blocked and cannot be enabled by configuration.
|
||||
</p>
|
||||
|
||||
<h2 className="text-xl font-semibold text-gray-900 mt-6">IP Addresses and Geolocation</h2>
|
||||
<h2 className="text-lg font-semibold text-gray-900 mt-6">IP Addresses and Geolocation</h2>
|
||||
<p className="text-gray-700">
|
||||
Geolocation is a tenant setting and is <strong>enabled by default</strong>. While it is enabled:
|
||||
</p>
|
||||
@@ -106,7 +115,7 @@ export default function PrivacyPage() {
|
||||
tenant setting and also an agent command-line switch. Only the Geographic Performance view is affected.
|
||||
</p>
|
||||
|
||||
<h2 className="text-xl font-semibold text-gray-900 mt-6">Where Your Data Is Stored</h2>
|
||||
<h2 className="text-lg font-semibold text-gray-900 mt-6">Where Your Data Is Stored</h2>
|
||||
<p className="text-gray-700">
|
||||
All customer data — sessions, events, configuration, audit logs, diagnostics, and backups — is stored in
|
||||
Microsoft Azure in <strong>Germany West Central</strong>. The only component outside that region is the portal
|
||||
@@ -114,7 +123,7 @@ export default function PrivacyPage() {
|
||||
replication and no transfer of customer data outside the EU by the platform.
|
||||
</p>
|
||||
|
||||
<h2 className="text-xl font-semibold text-gray-900 mt-6">Who Can Access Your Data</h2>
|
||||
<h2 className="text-lg font-semibold text-gray-900 mt-6">Who Can Access Your Data</h2>
|
||||
<p className="text-gray-700">
|
||||
Your data is not sold, and it is not shared with third parties for their own purposes. Access is limited to:
|
||||
</p>
|
||||
@@ -128,7 +137,7 @@ export default function PrivacyPage() {
|
||||
isolation model is in the <DocsLink href={DOCS_SECURITY_FAQ}>Security & Privacy FAQ</DocsLink>.
|
||||
</p>
|
||||
|
||||
<h2 className="text-xl font-semibold text-gray-900 mt-6">External Services</h2>
|
||||
<h2 className="text-lg font-semibold text-gray-900 mt-6">External Services</h2>
|
||||
<p className="text-gray-700">
|
||||
<strong>Microsoft Azure is the only place your data is stored.</strong> Resend delivers the onboarding
|
||||
approval email when a tenant is activated — it receives an administrator's email address and the tenant
|
||||
@@ -144,7 +153,7 @@ export default function PrivacyPage() {
|
||||
the parties engaged and the terms of their engagement.
|
||||
</p>
|
||||
|
||||
<h2 className="text-xl font-semibold text-gray-900 mt-6">Diagnostics Uploads</h2>
|
||||
<h2 className="text-lg font-semibold text-gray-900 mt-6">Diagnostics Uploads</h2>
|
||||
<p className="text-gray-700">
|
||||
Diagnostics upload is <strong>off by default</strong>. When enabled, the default destination is{" "}
|
||||
<strong>your own Azure storage account</strong> — the package never reaches our infrastructure. Hosted upload
|
||||
@@ -163,7 +172,7 @@ export default function PrivacyPage() {
|
||||
tenant that has no upload destination configured.
|
||||
</p>
|
||||
|
||||
<h2 className="text-xl font-semibold text-gray-900 mt-6">How Long We Keep Data</h2>
|
||||
<h2 className="text-lg font-semibold text-gray-900 mt-6">How Long We Keep Data</h2>
|
||||
<p className="text-gray-700">
|
||||
Retention is <strong>configured by you per tenant, defaulting to 90 days</strong> — 7 to 90 days on the
|
||||
Community plan and 7 to 365 days on Enterprise. Expired sessions are purged automatically. You additionally
|
||||
@@ -185,7 +194,7 @@ export default function PrivacyPage() {
|
||||
Either can be removed on request.
|
||||
</p>
|
||||
|
||||
<h2 className="text-xl font-semibold text-gray-900 mt-6">How Your Data Is Protected</h2>
|
||||
<h2 className="text-lg font-semibold text-gray-900 mt-6">How Your Data Is Protected</h2>
|
||||
<ul className="list-disc list-inside space-y-2 text-gray-700 ml-4">
|
||||
<li><strong>Device authentication by mutual TLS</strong> using the Intune MDM client certificate, validated against pinned Intune root CAs rather than the operating system trust store — and rejected if no trust anchor loads</li>
|
||||
<li><strong>Autopilot device validation via Microsoft Graph</strong> — only devices registered in your tenant are accepted, with an optional hardware allow-list on top</li>
|
||||
@@ -199,7 +208,7 @@ export default function PrivacyPage() {
|
||||
<li><strong>Manifest-first deletion</strong> — what will be deleted is captured, restorably, before anything is removed</li>
|
||||
</ul>
|
||||
|
||||
<h2 className="text-xl font-semibold text-gray-900 mt-6">Your Rights</h2>
|
||||
<h2 className="text-lg font-semibold text-gray-900 mt-6">Your Rights</h2>
|
||||
<p className="text-gray-700">
|
||||
Where the GDPR applies, you have the right to access, correction, deletion, restriction of processing,
|
||||
portability, and to object to processing. We act on such requests within the statutory time limits. In
|
||||
@@ -211,24 +220,25 @@ export default function PrivacyPage() {
|
||||
organization; we support you in fulfilling them.
|
||||
</p>
|
||||
|
||||
<h2 className="text-xl font-semibold text-gray-900 mt-6">Changes to This Policy</h2>
|
||||
<h2 className="text-lg font-semibold text-gray-900 mt-6">Changes to This Policy</h2>
|
||||
<p className="text-gray-700">
|
||||
Material changes are reflected in the "last updated" date above and announced through the service
|
||||
announcements in the portal. If you hold a signed data processing agreement, notification follows the terms of
|
||||
that agreement.
|
||||
</p>
|
||||
|
||||
<h2 className="text-xl font-semibold text-gray-900 mt-6">Contact</h2>
|
||||
<h2 className="text-lg font-semibold text-gray-900 mt-6">Contact</h2>
|
||||
<p className="text-gray-700">
|
||||
For privacy questions, a data processing agreement, or a data subject request, contact glueckkanja AG using
|
||||
the details in the <a href="https://www.glueckkanja.com/en/imprint" target="_blank" rel="noopener noreferrer" className="text-indigo-600 hover:text-indigo-800 underline">Imprint</a>.
|
||||
the details in the <a href="https://www.glueckkanja.com/en/imprint" target="_blank" rel="noopener noreferrer" className="text-[var(--lp-accent-ink)] hover:opacity-80 underline">Imprint</a>.
|
||||
For the open-source project and the Community edition you can also reach the maintainer via{" "}
|
||||
<a href="https://www.linkedin.com/in/oliver-kieselbach" target="_blank" rel="noopener noreferrer" className="text-indigo-600 hover:text-indigo-800 underline">LinkedIn</a>{" "}
|
||||
<a href="https://www.linkedin.com/in/oliver-kieselbach" target="_blank" rel="noopener noreferrer" className="text-[var(--lp-accent-ink)] hover:opacity-80 underline">LinkedIn</a>{" "}
|
||||
or open a{" "}
|
||||
<a href="https://github.com/okieselbach/Autopilot-Monitor/issues" target="_blank" rel="noopener noreferrer" className="text-indigo-600 hover:text-indigo-800 underline">GitHub issue</a>.
|
||||
<a href="https://github.com/okieselbach/AutopilotMonitor/issues" target="_blank" rel="noopener noreferrer" className="text-[var(--lp-accent-ink)] hover:opacity-80 underline">GitHub issue</a>.
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,30 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { EnrollmentEvent, Session } from "@/types";
|
||||
|
||||
// Minimal structural view of the event payloads this hook reads — only fields used in a
|
||||
// typed position are declared (the agent serializes them as strings); everything else
|
||||
// stays `unknown` via the index signature.
|
||||
interface ProgressEventData {
|
||||
[key: string]: unknown;
|
||||
totalApps?: string;
|
||||
total_apps?: string;
|
||||
installed?: string;
|
||||
failed?: string;
|
||||
installing?: string;
|
||||
downloading?: string;
|
||||
blocking_apps_total?: string;
|
||||
blockingAppsTotal?: string;
|
||||
blocking_apps_completed?: string;
|
||||
blockingAppsCompleted?: string;
|
||||
app_name?: string;
|
||||
appName?: string;
|
||||
file_name?: string;
|
||||
fileName?: string;
|
||||
appId?: string;
|
||||
app_id?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface AppSummary {
|
||||
total: number;
|
||||
installed: number;
|
||||
@@ -54,7 +78,7 @@ export function useProgressDerivedData(
|
||||
const summaryEvents = events.filter((e) => e.eventType === "app_tracking_summary");
|
||||
if (summaryEvents.length > 0) {
|
||||
const latest = summaryEvents[summaryEvents.length - 1];
|
||||
const d = latest.data;
|
||||
const d = latest.data as ProgressEventData | undefined;
|
||||
if (d) {
|
||||
const total = parseInt(d.totalApps ?? d.total_apps ?? "0", 10);
|
||||
if (total > 0) {
|
||||
@@ -73,7 +97,7 @@ export function useProgressDerivedData(
|
||||
const espEvents = events.filter((e) => e.eventType === "esp_ui_state");
|
||||
if (espEvents.length > 0) {
|
||||
const latest = espEvents[espEvents.length - 1];
|
||||
const d = latest.data;
|
||||
const d = latest.data as ProgressEventData | undefined;
|
||||
if (d) {
|
||||
const total = parseInt(d.blocking_apps_total ?? d.blockingAppsTotal ?? "0", 10);
|
||||
const installed = parseInt(d.blocking_apps_completed ?? d.blockingAppsCompleted ?? "0", 10);
|
||||
@@ -95,7 +119,7 @@ export function useProgressDerivedData(
|
||||
{ bytesDownloaded: number; bytesTotal: number; downloadRateBps: number; isComplete: boolean }
|
||||
>();
|
||||
for (const evt of downloadEvents) {
|
||||
const d = evt.data;
|
||||
const d = evt.data as ProgressEventData | undefined;
|
||||
if (!d) continue;
|
||||
const appName = d.app_name ?? d.appName ?? d.file_name ?? d.fileName ?? null;
|
||||
if (!appName) continue;
|
||||
@@ -119,7 +143,7 @@ export function useProgressDerivedData(
|
||||
const completedCount = Array.from(appLatest.values()).filter((v) => v.isComplete).length;
|
||||
|
||||
for (let i = downloadEvents.length - 1; i >= 0; i--) {
|
||||
const d = downloadEvents[i].data;
|
||||
const d = downloadEvents[i].data as ProgressEventData | undefined;
|
||||
if (!d) continue;
|
||||
const appName = d.app_name ?? d.appName ?? d.file_name ?? d.fileName ?? null;
|
||||
if (!appName) continue;
|
||||
@@ -157,7 +181,7 @@ export function useProgressDerivedData(
|
||||
|
||||
const appState = new Map<string, { state: string; startedAt?: string }>();
|
||||
for (const evt of sorted) {
|
||||
const d = evt.data;
|
||||
const d = evt.data as ProgressEventData | undefined;
|
||||
if (!d) continue;
|
||||
const appName = d.appName ?? d.app_name ?? d.appId ?? d.app_id ?? null;
|
||||
if (!appName) continue;
|
||||
|
||||
@@ -25,7 +25,7 @@ interface UseProgressEventsParams {
|
||||
export interface UseProgressEventsReturn {
|
||||
events: EnrollmentEvent[];
|
||||
setEvents: React.Dispatch<React.SetStateAction<EnrollmentEvent[]>>;
|
||||
sessionRef: React.MutableRefObject<Session | null>;
|
||||
sessionRef: React.RefObject<Session | null>;
|
||||
scheduleFetchEvents: (delayMs?: number) => void;
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ interface SignalRApi {
|
||||
|
||||
interface UseProgressSignalRParams {
|
||||
session: Session | null;
|
||||
sessionRef: React.MutableRefObject<Session | null>;
|
||||
sessionRef: React.RefObject<Session | null>;
|
||||
signalR: SignalRApi;
|
||||
scheduleFetchEvents: (delayMs?: number) => void;
|
||||
}
|
||||
|
||||
@@ -89,7 +89,7 @@ export default function ProgressPortalPage() {
|
||||
>
|
||||
<div className="flex items-center space-x-2 min-w-0">
|
||||
<svg
|
||||
className="w-4 h-4 text-blue-600 flex-shrink-0"
|
||||
className="w-4 h-4 text-green-600 flex-shrink-0"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
@@ -106,9 +106,9 @@ export default function ProgressPortalPage() {
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1.5 flex-shrink-0 ml-3">
|
||||
<span className="text-xs text-blue-600 font-medium">Change device</span>
|
||||
<span className="text-xs text-green-700 font-medium">Change device</span>
|
||||
<svg
|
||||
className="w-3.5 h-3.5 text-blue-600"
|
||||
className="w-3.5 h-3.5 text-green-700"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
@@ -147,9 +147,9 @@ export default function ProgressPortalPage() {
|
||||
<span>Collapse</span>
|
||||
</button>
|
||||
)}
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 bg-blue-100 rounded-full mb-4">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 bg-green-100 rounded-full mb-4">
|
||||
<svg
|
||||
className="w-8 h-8 text-blue-600"
|
||||
className="w-8 h-8 text-green-600"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
@@ -179,13 +179,13 @@ export default function ProgressPortalPage() {
|
||||
onChange={(e) => setSerialInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Enter serial number or device name..."
|
||||
className="w-full px-4 py-3 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 text-lg"
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 text-lg"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={searchBySerial}
|
||||
disabled={searching || !serialInput.trim()}
|
||||
className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed font-medium"
|
||||
className="px-6 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed font-medium"
|
||||
>
|
||||
{searching ? "Searching..." : "Check Status"}
|
||||
</button>
|
||||
|
||||
@@ -9,7 +9,7 @@ export default function robots(): MetadataRoute.Robots {
|
||||
rules: [
|
||||
{
|
||||
userAgent: "*",
|
||||
allow: ["/", "/about", "/privacy", "/terms"],
|
||||
allow: ["/", "/about", "/get-started", "/privacy", "/terms"],
|
||||
disallow: [
|
||||
"/dashboard",
|
||||
"/fleet-health",
|
||||
|
||||
@@ -271,7 +271,7 @@ function AnalysisResultCard({ result, hitRate }: { result: RuleResult; hitRate?:
|
||||
href={doc.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-blue-600 hover:text-blue-800 underline"
|
||||
className="text-sm text-green-700 hover:text-green-800 underline"
|
||||
>
|
||||
{doc.title}
|
||||
</a>
|
||||
@@ -304,12 +304,14 @@ function scrollToEvent(eventId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function EvidenceEventLinks({ matchedConditions }: { matchedConditions: Record<string, any> }) {
|
||||
function EvidenceEventLinks({ matchedConditions }: { matchedConditions: Record<string, unknown> }) {
|
||||
const eventLinks: { signal: string; eventId: string; eventType?: string }[] = [];
|
||||
const seenEventIds = new Set<string>();
|
||||
|
||||
for (const [signal, evidence] of Object.entries(matchedConditions)) {
|
||||
for (const [signal, evidenceRaw] of Object.entries(matchedConditions)) {
|
||||
if (signal.startsWith("factor_")) continue;
|
||||
// Evidence values are rule-engine JSON; only object-shaped entries carry event links.
|
||||
const evidence = evidenceRaw as { eventId?: string; eventType?: string } | string | null;
|
||||
if (evidence && typeof evidence === "object" && evidence.eventId) {
|
||||
// Multiple matched conditions can extract different fields from the
|
||||
// same event — one chip per distinct event, not per condition.
|
||||
|
||||
@@ -267,7 +267,7 @@ export default function CollectLogsButton({
|
||||
</p>
|
||||
<p className="text-sm text-gray-700">
|
||||
<span className="font-semibold">Enable & Collect</span> switches the upload destination to
|
||||
{" "}<span className="font-semibold">hosted storage</span> (managed by AutopilotMonitor) with mode
|
||||
{" "}<span className="font-semibold">hosted storage</span> (managed by Autopilot Monitor) with mode
|
||||
{" "}<span className="font-semibold">On failure</span>, then collects the logs from this device right away.
|
||||
</p>
|
||||
{/* Data-boundary disclosure. This dialog is the SECOND opt-in path into hosted
|
||||
@@ -277,7 +277,7 @@ export default function CollectLogsButton({
|
||||
<div className="bg-sky-50 border border-sky-200 rounded-lg p-3 space-y-1">
|
||||
<p className="text-sm text-sky-900">
|
||||
<span className="font-semibold">Uploaded packages leave your own Azure tenant boundary.</span>{" "}
|
||||
They are stored in the AutopilotMonitor backend's Azure Storage (Germany West Central),
|
||||
They are stored in the Autopilot Monitor backend's Azure Storage (Germany West Central),
|
||||
isolated per tenant, and each upload uses a fresh blob-scoped, write-only token.
|
||||
</p>
|
||||
<p className="text-xs text-sky-800">
|
||||
|
||||
@@ -5,16 +5,35 @@ import { EnrollmentEvent, Session } from "@/types";
|
||||
import OobeConfigModal from "./OobeConfigModal";
|
||||
import { compareVersions, stripGitHashSuffix } from "@/utils/bootstrapVersion";
|
||||
|
||||
// Minimal structural shapes for the event payloads this card reads. Values arrive as
|
||||
// deserialized JSON; only the fields the JSX uses in a typed position (string props,
|
||||
// arithmetic, Date construction) are declared — everything else stays `unknown`.
|
||||
interface AgentStartedData { [key: string]: unknown; agentVersion?: string }
|
||||
interface BootTimeData { [key: string]: unknown; bootTimeUtc?: string; bootTime?: string; uptimeMinutes?: number }
|
||||
interface OsInfoData { [key: string]: unknown; osVersion?: string; displayVersion?: string; currentBuild?: string; buildRevision?: string; edition?: string; compositionEdition?: string; buildBranch?: string }
|
||||
interface NetworkAdaptersData { [key: string]: unknown; adapters?: unknown[] }
|
||||
interface DnsConfigData { [key: string]: unknown; dnsEntries?: unknown[] }
|
||||
interface NetworkInterfaceInfoData { [key: string]: unknown; status?: string; connectionType?: string; adapterDescription?: string; linkSpeedMbps?: number; gateways?: string }
|
||||
interface WifiSignalInfoData { [key: string]: unknown; wifiSsid?: string; wifiSignalPercent?: number; wifiRadioType?: string; wifiChannel?: number }
|
||||
interface ProxyConfigData { [key: string]: unknown; proxyType?: string; type?: string; proxyServer?: string; autoConfigUrl?: string; winHttpProxy?: string }
|
||||
interface AutopilotProfileData { [key: string]: unknown; PolicyDownloadDate?: string; AutopilotCreationDate?: string; autopilotModeLabel?: string; domainJoinMethodLabel?: string }
|
||||
interface ImeVersionData { [key: string]: unknown; version?: string; agentVersion?: string }
|
||||
interface RealmJoinInfoData { [key: string]: unknown; productVersion?: string; releaseChannel?: string }
|
||||
interface TpmStatusData { [key: string]: unknown; specVersion?: string }
|
||||
interface BitLockerStatusData { [key: string]: unknown; volumes?: unknown[] }
|
||||
interface DeviceLocationData { [key: string]: unknown; country?: string; Country?: string; timezone?: string; Timezone?: string }
|
||||
interface HwSpecData { [key: string]: unknown; cpuName?: string; cpuCores?: string; cpuLogicalProcessors?: string; cpuMaxClockSpeedGHz?: string; cpuArchitecture?: string; ramTotalGB?: string; biosVersion?: string; biosReleaseDate?: string; smbiosVersion?: string; disks?: unknown[]; gpus?: unknown[] }
|
||||
|
||||
export default function DeviceDetailsCard({ events, latestAgentVersion, session }: { events: EnrollmentEvent[]; latestAgentVersion?: string | null; session?: Session | null }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [showIpv6, setShowIpv6] = useState<Record<number, boolean>>({});
|
||||
const [showOobeModal, setShowOobeModal] = useState(false);
|
||||
|
||||
const getEventData = (eventType: string): Record<string, any> | null => {
|
||||
const getEventData = <T extends Record<string, unknown> = Record<string, unknown>>(eventType: string): T | null => {
|
||||
const matchingEvents = events.filter(e => e.eventType === eventType);
|
||||
if (matchingEvents.length === 0) return null;
|
||||
const latestEvent = matchingEvents[matchingEvents.length - 1];
|
||||
return latestEvent?.data ?? null;
|
||||
return (latestEvent?.data as T | undefined) ?? null;
|
||||
};
|
||||
|
||||
const isIpv6 = (ip: string): boolean => {
|
||||
@@ -68,7 +87,7 @@ export default function DeviceDetailsCard({ events, latestAgentVersion, session
|
||||
return names[method] ?? `Unknown (${method})`;
|
||||
};
|
||||
|
||||
const normalizeAutopilotProfile = (profile: Record<string, any> | null): Record<string, any> | null => {
|
||||
const normalizeAutopilotProfile = (profile: AutopilotProfileData | null): AutopilotProfileData | null => {
|
||||
if (!profile) return null;
|
||||
|
||||
const normalized = { ...profile };
|
||||
@@ -104,13 +123,15 @@ export default function DeviceDetailsCard({ events, latestAgentVersion, session
|
||||
return normalized;
|
||||
};
|
||||
|
||||
const hasValue = (value: unknown): boolean => value !== undefined && value !== null && `${value}` !== "";
|
||||
// Type predicate so guarded fields (e.g. PolicyDownloadDate) narrow away undefined.
|
||||
const hasValue = (value: unknown): value is NonNullable<unknown> => value !== undefined && value !== null && `${value}` !== "";
|
||||
|
||||
const agentStarted = getEventData("agent_started");
|
||||
const bootTime = getEventData("boot_time");
|
||||
const agentStarted = getEventData<AgentStartedData>("agent_started");
|
||||
const bootTime = getEventData<BootTimeData>("boot_time");
|
||||
|
||||
const estimatedBootTime = (bootTime?.bootTimeUtc || bootTime?.bootTime)
|
||||
? new Date(bootTime?.bootTimeUtc ?? bootTime?.bootTime)
|
||||
// Non-null: the surrounding guard proves one of the two fields is set.
|
||||
? new Date((bootTime?.bootTimeUtc ?? bootTime?.bootTime)!)
|
||||
: null;
|
||||
|
||||
const uptimeUntilEnrollment = useMemo(() => {
|
||||
@@ -140,26 +161,26 @@ export default function DeviceDetailsCard({ events, latestAgentVersion, session
|
||||
return null;
|
||||
}, [bootTime, events]);
|
||||
|
||||
const osInfo = getEventData("os_info");
|
||||
const networkAdapters = getEventData("network_adapters");
|
||||
const dnsConfig = getEventData("dns_configuration");
|
||||
const proxyConfig = getEventData("proxy_configuration");
|
||||
const networkInterfaceInfo = getEventData("network_interface_info");
|
||||
const wifiSignalInfo = getEventData("wifi_signal_info");
|
||||
const autopilotProfile = normalizeAutopilotProfile(getEventData("autopilot_profile"));
|
||||
const osInfo = getEventData<OsInfoData>("os_info");
|
||||
const networkAdapters = getEventData<NetworkAdaptersData>("network_adapters");
|
||||
const dnsConfig = getEventData<DnsConfigData>("dns_configuration");
|
||||
const proxyConfig = getEventData<ProxyConfigData>("proxy_configuration");
|
||||
const networkInterfaceInfo = getEventData<NetworkInterfaceInfoData>("network_interface_info");
|
||||
const wifiSignalInfo = getEventData<WifiSignalInfoData>("wifi_signal_info");
|
||||
const autopilotProfile = normalizeAutopilotProfile(getEventData<AutopilotProfileData>("autopilot_profile"));
|
||||
// Explicit agent signal (new sessions) OR ProfileAvailable=0 in the cached profile
|
||||
// (also covers sessions recorded before autopilot_profile_missing existed).
|
||||
const autopilotProfileMissing =
|
||||
getEventData("autopilot_profile_missing") !== null ||
|
||||
(autopilotProfile !== null && `${autopilotProfile.ProfileAvailable}` === "0");
|
||||
const aadJoinStatus = getEventData("aad_join_status");
|
||||
const imeVersion = getEventData("ime_agent_version");
|
||||
const realmJoinInfo = getEventData("realmjoin_detected");
|
||||
const bitLockerStatus = getEventData("bitlocker_status");
|
||||
const imeVersion = getEventData<ImeVersionData>("ime_agent_version");
|
||||
const realmJoinInfo = getEventData<RealmJoinInfoData>("realmjoin_detected");
|
||||
const bitLockerStatus = getEventData<BitLockerStatusData>("bitlocker_status");
|
||||
const secureBootStatus = getEventData("secureboot_status");
|
||||
const tpmStatus = getEventData("tpm_status");
|
||||
const deviceLocation = getEventData("device_location");
|
||||
const hwSpec = getEventData("hardware_spec");
|
||||
const tpmStatus = getEventData<TpmStatusData>("tpm_status");
|
||||
const deviceLocation = getEventData<DeviceLocationData>("device_location");
|
||||
const hwSpec = getEventData<HwSpecData>("hardware_spec");
|
||||
|
||||
// Connectivity: agent→backend measurements. Latency comes from the session row (persisted
|
||||
// at ingest from the cumulative snapshot counters); bandwidth/volume from their events.
|
||||
@@ -216,7 +237,7 @@ export default function DeviceDetailsCard({ events, latestAgentVersion, session
|
||||
<DetailSection title="Network">
|
||||
{networkAdapters && networkAdapters.adapters && (
|
||||
(networkAdapters.adapters as unknown[]).map((adapter, i: number) => {
|
||||
const a = adapter as Record<string, unknown>;
|
||||
const a = adapter as { [key: string]: unknown; macAddress?: string };
|
||||
const { ipv4, ipv6 } = a.ipAddresses ? splitIpAddresses(a.ipAddresses as string) : { ipv4: [], ipv6: [] };
|
||||
const hasIpv6 = ipv6.length > 0;
|
||||
const isIpv6Shown = showIpv6[i] ?? false;
|
||||
@@ -231,7 +252,7 @@ export default function DeviceDetailsCard({ events, latestAgentVersion, session
|
||||
<div className="mt-1">
|
||||
<button
|
||||
onClick={() => setShowIpv6(prev => ({ ...prev, [i]: !isIpv6Shown }))}
|
||||
className="text-xs text-blue-600 hover:text-blue-800 flex items-center space-x-1"
|
||||
className="text-xs text-green-700 hover:text-green-800 flex items-center space-x-1"
|
||||
>
|
||||
<svg className={`w-3 h-3 transition-transform duration-200 ${isIpv6Shown ? 'rotate-90' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M9 5l7 7-7 7" />
|
||||
@@ -424,10 +445,10 @@ export default function DeviceDetailsCard({ events, latestAgentVersion, session
|
||||
/>
|
||||
)}
|
||||
{(deviceLocation?.country || deviceLocation?.Country) && (
|
||||
<DetailRow label="Country" value={deviceLocation.country ?? deviceLocation.Country} />
|
||||
<DetailRow label="Country" value={(deviceLocation.country ?? deviceLocation.Country)!} />
|
||||
)}
|
||||
{(deviceLocation?.timezone || deviceLocation?.Timezone) && (
|
||||
<DetailRow label="Timezone" value={deviceLocation.timezone ?? deviceLocation.Timezone} />
|
||||
<DetailRow label="Timezone" value={(deviceLocation.timezone ?? deviceLocation.Timezone)!} />
|
||||
)}
|
||||
</DetailSection>
|
||||
)}
|
||||
@@ -462,7 +483,7 @@ export default function DeviceDetailsCard({ events, latestAgentVersion, session
|
||||
<span className="text-xs text-gray-500 min-w-[120px]">OOBE Config</span>
|
||||
<button
|
||||
onClick={() => setShowOobeModal(true)}
|
||||
className="text-xs text-right text-blue-600 hover:text-blue-800 hover:underline cursor-pointer font-mono transition-colors"
|
||||
className="text-xs text-right text-green-700 hover:text-green-800 hover:underline cursor-pointer font-mono transition-colors"
|
||||
title="Click to decode bitmask"
|
||||
>
|
||||
{`${autopilotProfile.CloudAssignedOobeConfig}`}
|
||||
@@ -526,7 +547,7 @@ export default function DeviceDetailsCard({ events, latestAgentVersion, session
|
||||
{hwSpec.disks && Array.isArray(hwSpec.disks) && (hwSpec.disks as unknown[]).map((disk, i: number) => {
|
||||
const d = disk as Record<string, unknown>;
|
||||
return (
|
||||
<DetailRow key={`disk-${i}`} label={hwSpec.disks.length > 1 ? `Disk ${i + 1}` : 'Disk'} value={`${d.model || 'Unknown'}${d.sizeGB ? ` (${d.sizeGB} GB)` : ''}${d.mediaType && d.mediaType !== 'Unknown' ? ` — ${d.mediaType}` : ''}`} />
|
||||
<DetailRow key={`disk-${i}`} label={hwSpec.disks!.length > 1 ? `Disk ${i + 1}` : 'Disk'} value={`${d.model || 'Unknown'}${d.sizeGB ? ` (${d.sizeGB} GB)` : ''}${d.mediaType && d.mediaType !== 'Unknown' ? ` — ${d.mediaType}` : ''}`} />
|
||||
);
|
||||
})}
|
||||
{hwSpec.systemDriveFreeGB !== undefined && hwSpec.systemDriveTotalGB !== undefined && (
|
||||
@@ -555,7 +576,7 @@ export default function DeviceDetailsCard({ events, latestAgentVersion, session
|
||||
{hwSpec.gpus && Array.isArray(hwSpec.gpus) && (hwSpec.gpus as unknown[]).map((gpu, i: number) => {
|
||||
const g = gpu as Record<string, unknown>;
|
||||
return (
|
||||
<DetailRow key={`gpu-${i}`} label={hwSpec.gpus.length > 1 ? `GPU ${i + 1}` : 'GPU'} value={`${g.name || 'Unknown'}${g.adapterRAMGB ? ` (${g.adapterRAMGB} GB)` : ''}`} />
|
||||
<DetailRow key={`gpu-${i}`} label={hwSpec.gpus!.length > 1 ? `GPU ${i + 1}` : 'GPU'} value={`${g.name || 'Unknown'}${g.adapterRAMGB ? ` (${g.adapterRAMGB} GB)` : ''}`} />
|
||||
);
|
||||
})}
|
||||
</DetailSection>
|
||||
|
||||
@@ -166,7 +166,7 @@ export default function DeviceHistoryBanner({
|
||||
{isCurrent ? (
|
||||
<span className="text-xs text-amber-800">this session</span>
|
||||
) : (
|
||||
<Link href={sessionHref(r.sessionId)} className="text-xs text-blue-600 hover:text-blue-800 underline underline-offset-2">
|
||||
<Link href={sessionHref(r.sessionId)} className="text-xs text-green-700 hover:text-green-800 underline underline-offset-2">
|
||||
open
|
||||
</Link>
|
||||
)}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import { EnrollmentEvent, Session } from "@/types";
|
||||
import { normalizeEventDataForDisplay, shortenBuildHashInMessage } from "../utils/eventHelpers";
|
||||
import { getErrorCodeEntry, getEnrichedOrLookup, formatErrorCode } from "@/utils/errorCodeMap";
|
||||
import { getEnrichedOrLookup, formatErrorCode, type ErrorCodeEntry } from "@/utils/errorCodeMap";
|
||||
|
||||
interface EventTimelineProps {
|
||||
filteredEvents: EnrollmentEvent[];
|
||||
@@ -44,7 +44,6 @@ export default function EventTimeline({
|
||||
expandAll,
|
||||
collapseAll,
|
||||
isWhiteGloveSession,
|
||||
whiteGloveSplitSequence,
|
||||
orderedPhases,
|
||||
eventsByPhase,
|
||||
preProvGrouped,
|
||||
@@ -91,7 +90,7 @@ export default function EventTimeline({
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search events..."
|
||||
className="w-full pl-7 pr-7 py-1 text-xs border border-gray-300 rounded-full focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
|
||||
className="w-full pl-7 pr-7 py-1 text-xs border border-gray-300 rounded-full focus:outline-none focus:ring-1 focus:ring-green-500 focus:border-green-500"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
@@ -332,7 +331,7 @@ function PhaseSection({
|
||||
onClick={onToggle}
|
||||
className="flex items-center justify-between w-full text-left mb-3 group"
|
||||
>
|
||||
<h3 className="text-lg font-semibold text-gray-900 group-hover:text-blue-600">
|
||||
<h3 className="text-lg font-semibold text-gray-900 group-hover:text-green-600">
|
||||
{phaseName} ({events.length} events)
|
||||
</h3>
|
||||
<svg className={`w-5 h-5 text-gray-400 transition-transform duration-200 ${isExpanded ? 'rotate-90' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -342,7 +341,7 @@ function PhaseSection({
|
||||
|
||||
{isExpanded && (
|
||||
<div className="space-y-3">
|
||||
{events.map((event, index) => (
|
||||
{events.map((event) => (
|
||||
<EventRow
|
||||
key={event.eventId || `${event.sessionId}-${event.sequence}`}
|
||||
event={event}
|
||||
@@ -444,7 +443,6 @@ function EventRow({ event, showScriptOutput }: { event: EnrollmentEvent; showScr
|
||||
}
|
||||
};
|
||||
|
||||
const hasData = isTruncated || (detailData && Object.keys(detailData).length > 0);
|
||||
const hasDetails = true; // Every event has at least the metadata block
|
||||
|
||||
const { isBackfilled, recordedAt } = getBackfillInfo(event);
|
||||
@@ -474,14 +472,14 @@ function EventRow({ event, showScriptOutput }: { event: EnrollmentEvent; showScr
|
||||
<p className="mt-1 text-sm text-gray-600" title={event.message || undefined}>{shortenBuildHashInMessage(event.message)}</p>
|
||||
{/* Exit code / HRESULT badge for app install events */}
|
||||
{(event.eventType === "app_install_failed" || event.eventType === "app_install_completed") && (() => {
|
||||
const ec = event.data?.exitCode ?? event.data?.exit_code;
|
||||
const hr = event.data?.hresultFromWin32 ?? event.data?.hresult_from_win32;
|
||||
const ec = (event.data?.exitCode ?? event.data?.exit_code) as string | number | undefined;
|
||||
const hr = (event.data?.hresultFromWin32 ?? event.data?.hresult_from_win32) as string | number | undefined;
|
||||
const hasNonZero = (ec && String(ec) !== "0") || (hr && String(hr) !== "0");
|
||||
if (!hasNonZero) return null;
|
||||
// Prefer backend-enriched *Info sibling, fall back to local lookup for older
|
||||
// responses that pre-date the backend ErrorCodeEnricher.
|
||||
const ecEntry = ec ? getEnrichedOrLookup(event.data?.exitCodeInfo, String(ec)) : null;
|
||||
const hrEntry = hr ? getEnrichedOrLookup(event.data?.hresultFromWin32Info, String(hr)) : null;
|
||||
const ecEntry = ec ? getEnrichedOrLookup(event.data?.exitCodeInfo as ErrorCodeEntry | undefined, String(ec)) : null;
|
||||
const hrEntry = hr ? getEnrichedOrLookup(event.data?.hresultFromWin32Info as ErrorCodeEntry | undefined, String(hr)) : null;
|
||||
return (
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs">
|
||||
{ec && String(ec) !== "0" && (
|
||||
@@ -517,12 +515,13 @@ function EventRow({ event, showScriptOutput }: { event: EnrollmentEvent; showScr
|
||||
(e.g. "Apps (0x87d1041c)") and surfaced as top-level event data so the UI can
|
||||
render it without parsing nested text. */}
|
||||
{(event.eventType === "enrollment_failed" || event.eventType === "esp_provisioning_status" || event.eventType === "esp_failure_advisory" || event.eventType === "esp_appx_failure_analysis") && (() => {
|
||||
const code = event.data?.errorCode ?? event.data?.failedSubcategoryErrorCode ?? event.data?.espErrorCode;
|
||||
const sub = event.data?.failedSubcategory ?? event.data?.failedSubcategories;
|
||||
const code = (event.data?.errorCode ?? event.data?.failedSubcategoryErrorCode ?? event.data?.espErrorCode) as string | number | undefined;
|
||||
const sub = (event.data?.failedSubcategory ?? event.data?.failedSubcategories) as string | undefined;
|
||||
const likelyCulpritApps = event.data?.likelyCulpritApps as string | undefined;
|
||||
const isAdvisory = event.eventType === "esp_failure_advisory";
|
||||
if (!code && !isAdvisory) return null;
|
||||
const codeStr = code ? String(code) : null;
|
||||
const entry = codeStr ? getEnrichedOrLookup(event.data?.errorCodeInfo, codeStr) : null;
|
||||
const entry = codeStr ? getEnrichedOrLookup(event.data?.errorCodeInfo as ErrorCodeEntry | undefined, codeStr) : null;
|
||||
// Advisory path uses warning-color palette (amber); the device continued past the
|
||||
// failure via ContinueAnyway, so this is not a hard error. PR1 Session 4fa5a2d4.
|
||||
const badgeBg = isAdvisory ? "bg-amber-100 text-amber-800" : "bg-red-100 text-red-800";
|
||||
@@ -552,12 +551,12 @@ function EventRow({ event, showScriptOutput }: { event: EnrollmentEvent; showScr
|
||||
subcategory: <span className="font-mono">{String(sub)}</span>
|
||||
</span>
|
||||
)}
|
||||
{event.data?.likelyCulpritApps && (
|
||||
{likelyCulpritApps && (
|
||||
<span
|
||||
className="px-1.5 py-0.5 rounded bg-orange-100 text-orange-800 font-medium"
|
||||
title="Tracked app(s) ESP most likely failed on — never-started apps ranked first (snapshot at failure time)."
|
||||
>
|
||||
Likely app: {String(event.data.likelyCulpritApps)}
|
||||
Likely app: {String(likelyCulpritApps)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -571,7 +570,7 @@ function EventRow({ event, showScriptOutput }: { event: EnrollmentEvent; showScr
|
||||
const isRetry = event.eventType === "esp_failure_retry_detected";
|
||||
const label = isRetry ? "User retry" : "Recovered";
|
||||
const badgeCls = isRetry ? "bg-sky-100 text-sky-800" : "bg-emerald-100 text-emerald-800";
|
||||
const cat = event.data?.category;
|
||||
const cat = event.data?.category as string | undefined;
|
||||
const sub = event.data?.subcategory ?? event.data?.failedSubcategory;
|
||||
const mins = event.data?.minutesSinceFailure ?? event.data?.minutesSinceAdvisory;
|
||||
return (
|
||||
@@ -603,7 +602,7 @@ function EventRow({ event, showScriptOutput }: { event: EnrollmentEvent; showScr
|
||||
{hasDetails && (
|
||||
<button
|
||||
onClick={() => setShowDetails(!showDetails)}
|
||||
className="text-xs text-blue-600 hover:text-blue-800 ml-4 flex-shrink-0"
|
||||
className="text-xs text-green-700 hover:text-green-800 ml-4 flex-shrink-0"
|
||||
>
|
||||
{showDetails ? 'Hide' : hasGatherOutput ? 'Output' : 'Details'}
|
||||
</button>
|
||||
@@ -622,7 +621,7 @@ function EventRow({ event, showScriptOutput }: { event: EnrollmentEvent; showScr
|
||||
type="button"
|
||||
onClick={copyEventId}
|
||||
title={copied ? 'Copied!' : 'Copy EventId'}
|
||||
className="absolute top-1.5 right-1.5 inline-flex items-center justify-center w-5 h-5 rounded border border-gray-200 bg-white text-gray-400 opacity-0 group-hover/meta:opacity-100 focus:opacity-100 hover:bg-gray-50 hover:text-blue-600 transition-opacity"
|
||||
className="absolute top-1.5 right-1.5 inline-flex items-center justify-center w-5 h-5 rounded border border-gray-200 bg-white text-gray-400 opacity-0 group-hover/meta:opacity-100 focus:opacity-100 hover:bg-gray-50 hover:text-green-600 transition-opacity"
|
||||
>
|
||||
{copied ? (
|
||||
<svg className="w-3 h-3 text-green-500" viewBox="0 0 20 20" fill="currentColor">
|
||||
@@ -814,7 +813,7 @@ function RawEventRow({ event }: { event: EnrollmentEvent }) {
|
||||
<span className="text-gray-900 font-medium flex-shrink-0">{event.eventType}</span>
|
||||
<span className="text-gray-500 truncate flex-1 min-w-0" title={event.message || undefined}>{shortenBuildHashInMessage(event.message)}</span>
|
||||
{hasDetails && (
|
||||
<button onClick={() => setExpanded(!expanded)} className="text-gray-400 hover:text-blue-600 flex-shrink-0 ml-1">
|
||||
<button onClick={() => setExpanded(!expanded)} className="text-gray-400 hover:text-green-600 flex-shrink-0 ml-1">
|
||||
{expanded ? '−' : '+'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -29,7 +29,7 @@ export default function MarkFailedModal({ show, session, onConfirm, onCancel }:
|
||||
You are about to manually mark this session as <span className="font-semibold text-red-600">Failed</span>.
|
||||
</p>
|
||||
<p className="text-sm text-gray-700 mb-2">
|
||||
Session <span className="font-mono text-xs">{session.sessionId}</span> for device <span className="font-semibold">{session.deviceName || session.serialNumber}</span> will be marked as failed with the reason "Manually marked as failed by administrator".
|
||||
Session <span className="font-mono text-xs">{session.sessionId}</span> for device <span className="font-semibold">{session.deviceName || session.serialNumber}</span> will be marked as failed with the reason "Manually marked as failed by administrator".
|
||||
</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
This action will update the session status and cannot be undone. Do you want to continue?
|
||||
|
||||
@@ -40,7 +40,6 @@ export default function OobeConfigModal({ show, value, onClose }: OobeConfigModa
|
||||
if (!show) return null;
|
||||
|
||||
const hexValue = `0x${value.toString(16).toUpperCase().padStart(4, "0")}`;
|
||||
const binaryValue = value.toString(2).padStart(11, "0");
|
||||
|
||||
const isSelfDeploying = (value & 0x0020) !== 0 && (value & 0x0040) !== 0;
|
||||
const profileType = isSelfDeploying ? "Self-Deploying" : "User-Driven";
|
||||
|
||||
@@ -40,7 +40,8 @@ export default function PhaseTimeline({ currentPhase, completedPhases, events =
|
||||
// Check for app_tracking_summary events (new strategic events)
|
||||
const trackingSummary = phaseEvents.find(e => e.eventType === "app_tracking_summary");
|
||||
if (trackingSummary?.data) {
|
||||
const d = trackingSummary.data;
|
||||
// Counters are serialized as strings on the wire.
|
||||
const d = trackingSummary.data as Record<string, string | undefined>;
|
||||
const completed = parseInt(d.completedApps ?? d.appsCompleted ?? "0", 10);
|
||||
const total = parseInt(d.totalApps ?? "0", 10);
|
||||
if (total > 0) {
|
||||
@@ -51,7 +52,7 @@ export default function PhaseTimeline({ currentPhase, completedPhases, events =
|
||||
// Check for esp_ui_state events (legacy) to show app install progress
|
||||
const espState = phaseEvents.find(e => e.eventType === "esp_ui_state");
|
||||
if (espState?.data) {
|
||||
const d = espState.data;
|
||||
const d = espState.data as Record<string, string | undefined>;
|
||||
const completed = parseInt(d.blocking_apps_completed ?? d.blockingAppsCompleted ?? "0", 10);
|
||||
const total = parseInt(d.blocking_apps_total ?? d.blockingAppsTotal ?? "0", 10);
|
||||
const currentItem = d.current_item ?? d.currentItem ?? d.status_text ?? d.statusText;
|
||||
@@ -76,7 +77,7 @@ export default function PhaseTimeline({ currentPhase, completedPhases, events =
|
||||
// Check for download_progress to show active download (legacy)
|
||||
const downloadEvt = phaseEvents.find(e => e.eventType === "download_progress");
|
||||
if (downloadEvt?.data) {
|
||||
const d = downloadEvt.data;
|
||||
const d = downloadEvt.data as Record<string, string | undefined>;
|
||||
const appName = d.app_name ?? d.appName ?? "content";
|
||||
const pct = d.bytes_total && d.bytes_downloaded
|
||||
? Math.round((parseInt(d.bytes_downloaded) / parseInt(d.bytes_total)) * 100)
|
||||
|
||||
@@ -245,7 +245,7 @@ export default function ReportSessionModal({
|
||||
value={comment}
|
||||
onChange={e => setComment(e.target.value)}
|
||||
placeholder="Describe what seems incorrect or unexpected..."
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500"
|
||||
rows={3}
|
||||
disabled={submitting}
|
||||
/>
|
||||
@@ -261,7 +261,7 @@ export default function ReportSessionModal({
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
placeholder="your.email@company.com"
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500"
|
||||
disabled={submitting}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
@@ -365,7 +365,7 @@ export default function ReportSessionModal({
|
||||
checked={hasDiagnostics && includeDiagnostics}
|
||||
onChange={e => setIncludeDiagnostics(e.target.checked)}
|
||||
disabled={submitting || !hasDiagnostics}
|
||||
className="mt-0.5 h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500"
|
||||
className="mt-0.5 h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-green-600 focus:ring-green-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">
|
||||
Include uploaded diagnostics archive
|
||||
@@ -424,7 +424,7 @@ export default function ReportSessionModal({
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={submitting || !!agentLogError || !!screenshotError}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors disabled:opacity-50 flex items-center gap-2"
|
||||
className="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 transition-colors disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
{submitting ? (
|
||||
<>
|
||||
|
||||
+20
-15
@@ -94,6 +94,15 @@ function confidenceTooltip(confidence?: string): string {
|
||||
return "Unknown identification confidence";
|
||||
}
|
||||
|
||||
type FindingsSortKey = "severity" | "software" | "cves" | "cvss";
|
||||
|
||||
// Module-level so React keeps a stable component identity across renders
|
||||
// (react-hooks/static-components: an inline component type remounts on every render).
|
||||
function SortIndicator({ column, sortKey, sortAsc }: { column: FindingsSortKey; sortKey: FindingsSortKey; sortAsc: boolean }) {
|
||||
if (sortKey !== column) return <span className="text-gray-300 ml-1">↕</span>;
|
||||
return <span className="ml-1">{sortAsc ? "▲" : "▼"}</span>;
|
||||
}
|
||||
|
||||
export default function VulnerabilityReportSection({
|
||||
events,
|
||||
vulnerabilityReport,
|
||||
@@ -104,7 +113,7 @@ export default function VulnerabilityReportSection({
|
||||
const [activeTab, setActiveTab] = useState<TabKey>("findings");
|
||||
const [rescanning, setRescanning] = useState(false);
|
||||
const [findingsSearch, setFindingsSearch] = useState("");
|
||||
const [findingsSortKey, setFindingsSortKey] = useState<"severity" | "software" | "cves" | "cvss">("severity");
|
||||
const [findingsSortKey, setFindingsSortKey] = useState<FindingsSortKey>("severity");
|
||||
const [findingsSortAsc, setFindingsSortAsc] = useState(false);
|
||||
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set());
|
||||
const [inventorySearch, setInventorySearch] = useState("");
|
||||
@@ -124,7 +133,7 @@ export default function VulnerabilityReportSection({
|
||||
// --- Findings data (wrapped in try/catch to prevent page crash on malformed report data) ---
|
||||
const { findings, unmatchedSoftware, mappedNames, unmappedNames, cleanNames, kevTotal, dataFreshness } = useMemo(() => { try {
|
||||
const findingsMap = new Map<string, Finding>();
|
||||
let unmatched: string[] = [];
|
||||
const unmatched: string[] = [];
|
||||
const mapped = new Set<string>();
|
||||
const unmapped = new Set<string>();
|
||||
const clean = new Set<string>();
|
||||
@@ -248,7 +257,8 @@ export default function VulnerabilityReportSection({
|
||||
const chunkGroups = new Map<string, { items: InventoryItem[]; chunkCount: number; seenChunks: Set<number> }>();
|
||||
|
||||
for (const evt of inventoryEvents) {
|
||||
const d = evt.data || {};
|
||||
// Chunk-envelope fields read in a typed position; the payload itself stays unknown.
|
||||
const d = (evt.data || {}) as { [key: string]: unknown; triggered_at?: string; triggeredAt?: string; chunk_index?: number; chunkIndex?: number; chunk_count?: number; chunkCount?: number };
|
||||
const triggeredAt = d.triggered_at || d.triggeredAt || evt.timestamp || "default";
|
||||
const chunkIndex = d.chunk_index ?? d.chunkIndex ?? 0;
|
||||
const chunkCount = d.chunk_count ?? d.chunkCount ?? 1;
|
||||
@@ -396,11 +406,6 @@ export default function VulnerabilityReportSection({
|
||||
});
|
||||
};
|
||||
|
||||
const SortIndicator = ({ column }: { column: typeof findingsSortKey }) => {
|
||||
if (findingsSortKey !== column) return <span className="text-gray-300 ml-1">↕</span>;
|
||||
return <span className="ml-1">{findingsSortAsc ? "\u25B2" : "\u25BC"}</span>;
|
||||
};
|
||||
|
||||
// Guard: no relevant data → render nothing (placed after all hooks to satisfy Rules of Hooks)
|
||||
if (vulnReportData.length === 0 && inventoryEvents.length === 0) return null;
|
||||
|
||||
@@ -562,7 +567,7 @@ export default function VulnerabilityReportSection({
|
||||
placeholder="Search software, versions, CVE IDs..."
|
||||
value={findingsSearch}
|
||||
onChange={e => setFindingsSearch(e.target.value)}
|
||||
className="w-full sm:w-80 px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none"
|
||||
className="w-full sm:w-80 px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-green-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -572,18 +577,18 @@ export default function VulnerabilityReportSection({
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<th className="py-2 px-3 cursor-pointer select-none" onClick={() => toggleFindingsSort("severity")}>
|
||||
Risk <SortIndicator column="severity" />
|
||||
Risk <SortIndicator column="severity" sortKey={findingsSortKey} sortAsc={findingsSortAsc} />
|
||||
</th>
|
||||
<th className="py-2 px-3 cursor-pointer select-none" onClick={() => toggleFindingsSort("software")}>
|
||||
Software <SortIndicator column="software" />
|
||||
Software <SortIndicator column="software" sortKey={findingsSortKey} sortAsc={findingsSortAsc} />
|
||||
</th>
|
||||
<th className="py-2 px-3">Version</th>
|
||||
<th className="py-2 px-3 cursor-pointer select-none" onClick={() => toggleFindingsSort("cves")}>
|
||||
CVEs <SortIndicator column="cves" />
|
||||
CVEs <SortIndicator column="cves" sortKey={findingsSortKey} sortAsc={findingsSortAsc} />
|
||||
</th>
|
||||
<th className="py-2 px-3">KEV</th>
|
||||
<th className="py-2 px-3 cursor-pointer select-none" onClick={() => toggleFindingsSort("cvss")}>
|
||||
Max CVSS <SortIndicator column="cvss" />
|
||||
Max CVSS <SortIndicator column="cvss" sortKey={findingsSortKey} sortAsc={findingsSortAsc} />
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -665,7 +670,7 @@ export default function VulnerabilityReportSection({
|
||||
placeholder="Search name, version, publisher, source..."
|
||||
value={inventorySearch}
|
||||
onChange={e => setInventorySearch(e.target.value)}
|
||||
className="w-full sm:w-80 px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none"
|
||||
className="w-full sm:w-80 px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-green-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
@@ -917,7 +922,7 @@ function FindingRow({ finding, isExpanded, onToggle }: {
|
||||
href={`https://nvd.nist.gov/vuln/detail/${cve.cveId}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm font-medium text-blue-600 hover:text-blue-800 underline"
|
||||
className="text-sm font-medium text-green-700 hover:text-green-800 underline"
|
||||
>
|
||||
{cve.cveId}
|
||||
</a>
|
||||
|
||||
@@ -70,7 +70,8 @@ export function useSessionDerivedData(
|
||||
const summaryEvents = events.filter(e => e.eventType === "app_tracking_summary");
|
||||
if (summaryEvents.length === 0) return null;
|
||||
const latest = summaryEvents[summaryEvents.length - 1];
|
||||
const d = latest.data;
|
||||
// Flat V1 summary schema; counters are serialized as strings on the wire.
|
||||
const d = latest.data as Record<string, string | undefined> | undefined;
|
||||
if (!d) return null;
|
||||
return {
|
||||
totalApps: parseInt(d.totalApps ?? "0", 10),
|
||||
|
||||
@@ -46,8 +46,8 @@ export interface UseSessionDetailReturn {
|
||||
setLoading: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
fetchSessionDetails: () => Promise<void>;
|
||||
resolveEffectiveTenantId: () => string | null;
|
||||
sessionRef: React.MutableRefObject<Session | null>;
|
||||
sessionIdRef: React.MutableRefObject<string>;
|
||||
sessionRef: React.RefObject<Session | null>;
|
||||
sessionIdRef: React.RefObject<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -63,7 +63,7 @@ interface UseSessionEventsParams {
|
||||
sessionTenantId: string | null;
|
||||
sessionStatus: string | null | undefined;
|
||||
resolveEffectiveTenantId: () => string | null;
|
||||
sessionRef: React.MutableRefObject<Session | null>;
|
||||
sessionRef: React.RefObject<Session | null>;
|
||||
fetchSessionDetails: () => Promise<void>;
|
||||
setLoading: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
isConnected: boolean;
|
||||
|
||||
@@ -20,8 +20,8 @@ interface UseSessionSignalRParams {
|
||||
tenantId: string;
|
||||
sessionTenantIdFromSession: string | undefined;
|
||||
globalAdminMode: boolean;
|
||||
sessionIdRef: React.MutableRefObject<string>;
|
||||
sessionRef: React.MutableRefObject<Session | null>;
|
||||
sessionIdRef: React.RefObject<string>;
|
||||
sessionRef: React.RefObject<Session | null>;
|
||||
resolveEffectiveTenantId: () => string | null;
|
||||
signalR: SignalRApi;
|
||||
scheduleFetchEvents: (delayMs?: number) => void;
|
||||
|
||||
@@ -167,7 +167,7 @@ function Tabs({
|
||||
onClick={() => onChange(t.id)}
|
||||
className={`border-b-2 px-1 py-2 text-sm font-medium transition-colors ${
|
||||
activeTab === t.id
|
||||
? "border-blue-600 text-blue-600"
|
||||
? "border-green-600 text-green-700"
|
||||
: "border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700"
|
||||
}`}
|
||||
>
|
||||
@@ -184,7 +184,7 @@ function NoV2DataNotice({ sessionId }: { sessionId: string }) {
|
||||
<div className="rounded border border-amber-200 bg-amber-50 p-6 text-amber-900">
|
||||
<h2 className="font-semibold">No V2 decision data for this session.</h2>
|
||||
<p className="mt-2 text-sm">
|
||||
The Inspector relies on the V2-agent's signal log and decision journal.
|
||||
The Inspector relies on the V2-agent's signal log and decision journal.
|
||||
This session likely ran on the legacy (V1) agent, or the V2 telemetry
|
||||
never reached the backend. Use the regular{" "}
|
||||
<Link
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user