Refactorings for many reusables, API URL builder, CollectorBaseClass, CorrelationID Middleware, added Polly for external APIs

This commit is contained in:
Oliver Kieselbach
2026-03-27 11:14:38 +01:00
parent 738f524083
commit 769beaa14a
86 changed files with 1774 additions and 1391 deletions
@@ -13,17 +13,10 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
/// and HTTP network traffic. Emits agent_metrics_snapshot events via the standard event pipeline.
/// No WMI, no PerformanceCounters — only Process properties and Interlocked counters.
/// </summary>
public class AgentSelfMetricsCollector : IDisposable
public class AgentSelfMetricsCollector : CollectorBase
{
private readonly AgentLogger _logger;
private readonly string _sessionId;
private readonly string _tenantId;
private readonly string _agentVersion;
private readonly Action<EnrollmentEvent> _onEventCollected;
private readonly NetworkMetrics _networkMetrics;
private readonly int _intervalSeconds;
private Timer _pollTimer;
// Previous sample for delta calculations
private TimeSpan _prevCpuTime;
@@ -38,20 +31,14 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
AgentLogger logger,
string agentVersion = "unknown",
int intervalSeconds = 60)
: base(sessionId, tenantId, onEventCollected, logger, intervalSeconds)
{
_sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
_tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
_onEventCollected = onEventCollected ?? throw new ArgumentNullException(nameof(onEventCollected));
_networkMetrics = networkMetrics ?? throw new ArgumentNullException(nameof(networkMetrics));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_agentVersion = string.IsNullOrWhiteSpace(agentVersion) ? "unknown" : agentVersion;
_intervalSeconds = intervalSeconds;
}
public void Start()
protected override void OnBeforeStart()
{
_logger.Info($"Starting AgentSelfMetrics collector (interval: {_intervalSeconds}s)");
// Prime the baseline for delta calculations
try
{
@@ -61,123 +48,97 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
}
catch (Exception ex)
{
_logger.Warning($"Failed to prime CPU baseline: {ex.Message}");
Logger.Warning($"Failed to prime CPU baseline: {ex.Message}");
_prevCpuTime = TimeSpan.Zero;
_prevWallTime = DateTime.UtcNow;
}
_prevNetSnapshot = _networkMetrics.GetSnapshot();
_pollTimer = new Timer(
_ => CollectMetrics(),
null,
TimeSpan.FromSeconds(_intervalSeconds), // first tick after one full interval
TimeSpan.FromSeconds(_intervalSeconds)
);
}
public void Stop()
protected override void Collect()
{
_logger.Info("Stopping AgentSelfMetrics collector");
_pollTimer?.Dispose();
_pollTimer = null;
}
var data = new Dictionary<string, object>
{
{ "agent_version", _agentVersion }
};
var now = DateTime.UtcNow;
private void CollectMetrics()
{
// --- Process metrics (no WMI, no PerformanceCounter) ---
try
{
var data = new Dictionary<string, object>
var proc = Process.GetCurrentProcess();
proc.Refresh(); // ensure fresh values
// CPU %: (delta CPU time) / (delta wall time) / cores * 100
var currentCpuTime = proc.TotalProcessorTime;
var cpuDelta = currentCpuTime - _prevCpuTime;
var wallDelta = now - _prevWallTime;
if (wallDelta.TotalMilliseconds > 0)
{
{ "agent_version", _agentVersion }
};
var now = DateTime.UtcNow;
// --- Process metrics (no WMI, no PerformanceCounter) ---
try
{
var proc = Process.GetCurrentProcess();
proc.Refresh(); // ensure fresh values
// CPU %: (delta CPU time) / (delta wall time) / cores * 100
var currentCpuTime = proc.TotalProcessorTime;
var cpuDelta = currentCpuTime - _prevCpuTime;
var wallDelta = now - _prevWallTime;
if (wallDelta.TotalMilliseconds > 0)
{
var cpuPercent = cpuDelta.TotalMilliseconds / wallDelta.TotalMilliseconds
/ Environment.ProcessorCount * 100.0;
data["agent_cpu_percent"] = Math.Round(cpuPercent, 2);
}
_prevCpuTime = currentCpuTime;
_prevWallTime = now;
// Memory
data["agent_working_set_mb"] = Math.Round(proc.WorkingSet64 / (1024.0 * 1024), 1);
data["agent_private_bytes_mb"] = Math.Round(proc.PrivateMemorySize64 / (1024.0 * 1024), 1);
// Threads & handles
data["agent_thread_count"] = proc.Threads.Count;
data["agent_handle_count"] = proc.HandleCount;
}
catch (Exception ex)
{
_logger.Debug($"Process metrics read failed: {ex.Message}");
var cpuPercent = cpuDelta.TotalMilliseconds / wallDelta.TotalMilliseconds
/ Environment.ProcessorCount * 100.0;
data["agent_cpu_percent"] = Math.Round(cpuPercent, 2);
}
// --- Network delta ---
try
{
var currentNet = _networkMetrics.GetSnapshot();
var delta = currentNet.DeltaFrom(_prevNetSnapshot);
_prevNetSnapshot = currentNet;
_prevCpuTime = currentCpuTime;
_prevWallTime = now;
data["net_requests"] = delta.Requests;
data["net_failures"] = delta.Failures;
data["net_bytes_up"] = delta.BytesUp;
data["net_bytes_down"] = delta.BytesDown;
data["net_avg_latency_ms"] = Math.Round(delta.AvgLatencyMs, 1);
// Memory
data["agent_working_set_mb"] = Math.Round(proc.WorkingSet64 / (1024.0 * 1024), 1);
data["agent_private_bytes_mb"] = Math.Round(proc.PrivateMemorySize64 / (1024.0 * 1024), 1);
// Cumulative totals for easy "total cost of this session" view
data["net_total_bytes_up"] = currentNet.TotalBytesUp;
data["net_total_bytes_down"] = currentNet.TotalBytesDown;
data["net_total_requests"] = currentNet.RequestCount;
}
catch (Exception ex)
{
_logger.Debug($"Network metrics read failed: {ex.Message}");
}
if (data.Count > 0)
{
_onEventCollected(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
Timestamp = now,
EventType = "agent_metrics_snapshot",
Severity = EventSeverity.Debug,
Source = "AgentSelfMetricsCollector",
Phase = EnrollmentPhase.Unknown,
Message = $"Agent CPU: {(data.ContainsKey("agent_cpu_percent") ? data["agent_cpu_percent"] : "?")}%, " +
$"WS: {(data.ContainsKey("agent_working_set_mb") ? data["agent_working_set_mb"] : "?")} MB, " +
$"Net: {(data.ContainsKey("net_requests") ? data["net_requests"] : "?")} req, " +
$"↑{(data.ContainsKey("net_bytes_up") ? data["net_bytes_up"] : "?")} B, " +
$"↓{(data.ContainsKey("net_bytes_down") ? data["net_bytes_down"] : "?")} B",
Data = data
});
}
// Threads & handles
data["agent_thread_count"] = proc.Threads.Count;
data["agent_handle_count"] = proc.HandleCount;
}
catch (Exception ex)
{
_logger.Warning($"AgentSelfMetrics collection failed: {ex.Message}");
Logger.Debug($"Process metrics read failed: {ex.Message}");
}
}
public void Dispose()
{
Stop();
// --- Network delta ---
try
{
var currentNet = _networkMetrics.GetSnapshot();
var delta = currentNet.DeltaFrom(_prevNetSnapshot);
_prevNetSnapshot = currentNet;
data["net_requests"] = delta.Requests;
data["net_failures"] = delta.Failures;
data["net_bytes_up"] = delta.BytesUp;
data["net_bytes_down"] = delta.BytesDown;
data["net_avg_latency_ms"] = Math.Round(delta.AvgLatencyMs, 1);
// Cumulative totals for easy "total cost of this session" view
data["net_total_bytes_up"] = currentNet.TotalBytesUp;
data["net_total_bytes_down"] = currentNet.TotalBytesDown;
data["net_total_requests"] = currentNet.RequestCount;
}
catch (Exception ex)
{
Logger.Debug($"Network metrics read failed: {ex.Message}");
}
if (data.Count > 0)
{
EmitEvent(new EnrollmentEvent
{
SessionId = SessionId,
TenantId = TenantId,
Timestamp = now,
EventType = "agent_metrics_snapshot",
Severity = EventSeverity.Debug,
Source = "AgentSelfMetricsCollector",
Phase = EnrollmentPhase.Unknown,
Message = $"Agent CPU: {(data.ContainsKey("agent_cpu_percent") ? data["agent_cpu_percent"] : "?")}%, " +
$"WS: {(data.ContainsKey("agent_working_set_mb") ? data["agent_working_set_mb"] : "?")} MB, " +
$"Net: {(data.ContainsKey("net_requests") ? data["net_requests"] : "?")} req, " +
$"\u2191{(data.ContainsKey("net_bytes_up") ? data["net_bytes_up"] : "?")} B, " +
$"\u2193{(data.ContainsKey("net_bytes_down") ? data["net_bytes_down"] : "?")} B",
Data = data
});
}
}
}
}
@@ -0,0 +1,84 @@
using System;
using System.Threading;
using AutopilotMonitor.Agent.Core.Logging;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
{
/// <summary>
/// Abstract base for interval-based event collectors.
/// Handles Timer lifecycle (Start/Stop/Dispose) so subclasses only implement Collect().
/// </summary>
public abstract class CollectorBase : IDisposable
{
protected readonly string SessionId;
protected readonly string TenantId;
protected readonly Action<EnrollmentEvent> EmitEvent;
protected readonly AgentLogger Logger;
private readonly int _intervalSeconds;
private Timer _timer;
private bool _disposed;
protected CollectorBase(
string sessionId,
string tenantId,
Action<EnrollmentEvent> emitEvent,
AgentLogger logger,
int intervalSeconds)
{
SessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
TenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
EmitEvent = emitEvent ?? throw new ArgumentNullException(nameof(emitEvent));
Logger = logger ?? throw new ArgumentNullException(nameof(logger));
_intervalSeconds = intervalSeconds;
}
/// <summary>Starts the collection interval timer.</summary>
public void Start()
{
if (_disposed) return;
OnBeforeStart();
var interval = TimeSpan.FromSeconds(_intervalSeconds);
_timer = new Timer(_ => CollectSafe(), null, GetInitialDelay(), interval);
Logger.Info($"[{GetType().Name}] Started (interval={_intervalSeconds}s)");
}
/// <summary>Stops the collection timer without disposing the collector.</summary>
public void Stop()
{
_timer?.Dispose();
_timer = null;
Logger.Info($"[{GetType().Name}] Stopped");
OnAfterStop();
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
Stop();
}
/// <summary>Override to perform collection. Called on each timer tick.</summary>
protected abstract void Collect();
/// <summary>
/// Override to return the initial delay before the first tick.
/// Default: one full interval (same as the repeat interval).
/// </summary>
protected virtual TimeSpan GetInitialDelay() => TimeSpan.FromSeconds(_intervalSeconds);
/// <summary>Optional hook called before the timer starts (e.g. to initialise counters).</summary>
protected virtual void OnBeforeStart() { }
/// <summary>Optional hook called after the timer stops (e.g. to dispose counters).</summary>
protected virtual void OnAfterStop() { }
private void CollectSafe()
{
try { Collect(); }
catch (Exception ex) { Logger.Error($"[{GetType().Name}] Collection error: {ex.Message}", ex); }
}
}
}
@@ -11,18 +11,11 @@ using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
{
/// <summary>
/// Collects system performance metrics (CPU, memory, disk) on a configurable interval
/// Optional collector - toggled on/off via remote config
/// Collects system performance metrics (CPU, memory, disk) on a configurable interval.
/// Optional collector - toggled on/off via remote config.
/// </summary>
public class PerformanceCollector : IDisposable
public class PerformanceCollector : CollectorBase
{
private readonly AgentLogger _logger;
private readonly string _sessionId;
private readonly string _tenantId;
private readonly Action<EnrollmentEvent> _onEventCollected;
private readonly int _intervalSeconds;
private Timer _pollTimer;
private PerformanceCounter _cpuCounter;
private PerformanceCounter _diskQueueCounter;
@@ -36,18 +29,15 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
public PerformanceCollector(string sessionId, string tenantId, Action<EnrollmentEvent> onEventCollected,
AgentLogger logger, int intervalSeconds = 60)
: base(sessionId, tenantId, onEventCollected, logger, intervalSeconds)
{
_sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
_tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
_onEventCollected = onEventCollected ?? throw new ArgumentNullException(nameof(onEventCollected));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_intervalSeconds = intervalSeconds;
}
public void Start()
{
_logger.Info($"Starting Performance collector (interval: {_intervalSeconds}s)");
/// <summary>Use a 5-second warm-up delay so counters are primed before first read.</summary>
protected override TimeSpan GetInitialDelay() => TimeSpan.FromSeconds(5);
protected override void OnBeforeStart()
{
try
{
_cpuCounter = new PerformanceCounter("Processor", "% Processor Time", "_Total");
@@ -58,7 +48,7 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
}
catch (Exception ex)
{
_logger.Warning($"Failed to initialize performance counters: {ex.Message}");
Logger.Warning($"Failed to initialize performance counters: {ex.Message}");
}
// Initialize network throughput baseline
@@ -74,209 +64,195 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
_prevBytesReceived = stats.BytesReceived;
_prevNetSampleTime = DateTime.UtcNow;
_networkInitialized = true;
_logger.Debug($"Network throughput tracking initialized on: {_activeNicName}");
Logger.Debug($"Network throughput tracking initialized on: {_activeNicName}");
}
else
{
_logger.Debug("No active network interface found for throughput tracking");
Logger.Debug("No active network interface found for throughput tracking");
}
}
catch (Exception ex)
{
_logger.Debug($"Failed to initialize network throughput baseline: {ex.Message}");
Logger.Debug($"Failed to initialize network throughput baseline: {ex.Message}");
}
// Start with an initial delay, then poll on interval
_pollTimer = new Timer(
_ => CollectMetrics(),
null,
TimeSpan.FromSeconds(5), // Initial delay to let counters warm up
TimeSpan.FromSeconds(_intervalSeconds)
);
}
public void Stop()
protected override void OnAfterStop()
{
_logger.Info("Stopping Performance collector");
_pollTimer?.Dispose();
_pollTimer = null;
_cpuCounter?.Dispose();
_cpuCounter = null;
_diskQueueCounter?.Dispose();
_diskQueueCounter = null;
}
private void CollectMetrics()
protected override void Collect()
{
var data = new Dictionary<string, object>();
// CPU usage
try
{
var data = new Dictionary<string, object>();
var cpuPercent = _cpuCounter?.NextValue() ?? 0;
data["cpu_percent"] = Math.Round(cpuPercent, 1);
}
catch (Exception ex)
{
Logger.Debug($"CPU counter read failed: {ex.Message}");
}
// CPU usage
try
// Memory info via WMI
try
{
using (var searcher = new ManagementObjectSearcher("SELECT FreePhysicalMemory, TotalVisibleMemorySize FROM Win32_OperatingSystem"))
{
var cpuPercent = _cpuCounter?.NextValue() ?? 0;
data["cpu_percent"] = Math.Round(cpuPercent, 1);
}
catch (Exception ex)
{
_logger.Debug($"CPU counter read failed: {ex.Message}");
}
// Memory info via WMI
try
{
using (var searcher = new ManagementObjectSearcher("SELECT FreePhysicalMemory, TotalVisibleMemorySize FROM Win32_OperatingSystem"))
foreach (ManagementObject obj in searcher.Get())
{
foreach (ManagementObject obj in searcher.Get())
{
var freeKb = Convert.ToDouble(obj["FreePhysicalMemory"]);
var totalKb = Convert.ToDouble(obj["TotalVisibleMemorySize"]);
data["memory_available_mb"] = Math.Round(freeKb / 1024, 0);
data["memory_total_mb"] = Math.Round(totalKb / 1024, 0);
data["memory_used_percent"] = Math.Round((1 - freeKb / totalKb) * 100, 1);
}
var freeKb = Convert.ToDouble(obj["FreePhysicalMemory"]);
var totalKb = Convert.ToDouble(obj["TotalVisibleMemorySize"]);
data["memory_available_mb"] = Math.Round(freeKb / 1024, 0);
data["memory_total_mb"] = Math.Round(totalKb / 1024, 0);
data["memory_used_percent"] = Math.Round((1 - freeKb / totalKb) * 100, 1);
}
}
catch (Exception ex)
{
_logger.Debug($"Memory WMI query failed: {ex.Message}");
}
// Disk queue length
try
{
var diskQueue = _diskQueueCounter?.NextValue() ?? 0;
data["disk_queue_length"] = Math.Round(diskQueue, 1);
}
catch (Exception ex)
{
_logger.Debug($"Disk queue counter read failed: {ex.Message}");
}
// Disk free space on system drive
try
{
var systemDrive = Path.GetPathRoot(Environment.SystemDirectory);
var driveInfo = new DriveInfo(systemDrive);
data["disk_free_gb"] = Math.Round(driveInfo.AvailableFreeSpace / (1024.0 * 1024 * 1024), 1);
data["disk_total_gb"] = Math.Round(driveInfo.TotalSize / (1024.0 * 1024 * 1024), 1);
}
catch (Exception ex)
{
_logger.Debug($"Disk space query failed: {ex.Message}");
}
// Network throughput (bytes sent/received delta + rate)
try
{
if (_networkInitialized)
{
// Re-find the NIC by cached ID
NetworkInterface currentNic = null;
try
{
foreach (var nic in NetworkInterface.GetAllNetworkInterfaces())
{
if (nic.Id == _activeNicId)
{
currentNic = nic;
break;
}
}
}
catch { /* swallow */ }
// If cached NIC is gone, try to find a new active one
if (currentNic == null || currentNic.OperationalStatus != OperationalStatus.Up)
{
currentNic = FindActiveNetworkInterface();
if (currentNic != null)
{
_activeNicId = currentNic.Id;
_activeNicName = currentNic.Description;
var freshStats = currentNic.GetIPStatistics();
_prevBytesSent = freshStats.BytesSent;
_prevBytesReceived = freshStats.BytesReceived;
_prevNetSampleTime = DateTime.UtcNow;
_logger.Debug($"Network throughput tracking switched to: {_activeNicName}");
}
else
{
_networkInitialized = false;
_logger.Debug("Active network interface lost, throughput tracking paused");
}
}
if (currentNic != null && currentNic.OperationalStatus == OperationalStatus.Up)
{
var stats = currentNic.GetIPStatistics();
var now = DateTime.UtcNow;
var elapsedSeconds = (now - _prevNetSampleTime).TotalSeconds;
if (elapsedSeconds > 0)
{
var deltaSent = stats.BytesSent - _prevBytesSent;
var deltaReceived = stats.BytesReceived - _prevBytesReceived;
// Guard against counter reset (adapter reconnect)
if (deltaSent < 0) deltaSent = 0;
if (deltaReceived < 0) deltaReceived = 0;
data["net_adapter"] = _activeNicName;
data["net_bytes_sent_delta"] = deltaSent;
data["net_bytes_received_delta"] = deltaReceived;
data["net_send_rate_kbps"] = Math.Round(deltaSent * 8.0 / 1000.0 / elapsedSeconds, 1);
data["net_receive_rate_kbps"] = Math.Round(deltaReceived * 8.0 / 1000.0 / elapsedSeconds, 1);
}
_prevBytesSent = stats.BytesSent;
_prevBytesReceived = stats.BytesReceived;
_prevNetSampleTime = now;
}
}
else
{
// Try to re-initialize (e.g., network came up after boot)
var activeNic = FindActiveNetworkInterface();
if (activeNic != null)
{
_activeNicId = activeNic.Id;
_activeNicName = activeNic.Description;
var stats = activeNic.GetIPStatistics();
_prevBytesSent = stats.BytesSent;
_prevBytesReceived = stats.BytesReceived;
_prevNetSampleTime = DateTime.UtcNow;
_networkInitialized = true;
_logger.Debug($"Network throughput tracking re-initialized on: {_activeNicName}");
}
}
}
catch (Exception ex)
{
_logger.Debug($"Network throughput collection failed: {ex.Message}");
}
if (data.Count > 0)
{
_onEventCollected(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
Timestamp = DateTime.UtcNow,
EventType = "performance_snapshot",
Severity = EventSeverity.Debug,
Source = "PerformanceCollector",
Phase = EnrollmentPhase.Unknown,
Message = $"CPU: {(data.ContainsKey("cpu_percent") ? data["cpu_percent"] : "?")}%, " +
$"Memory: {(data.ContainsKey("memory_used_percent") ? data["memory_used_percent"] : "?")}%, " +
$"Disk Free: {(data.ContainsKey("disk_free_gb") ? data["disk_free_gb"] : "?")} GB" +
(data.ContainsKey("net_receive_rate_kbps")
? $", Net: ↓{data["net_receive_rate_kbps"]} / ↑{data["net_send_rate_kbps"]} kbps"
: ""),
Data = data
});
}
}
catch (Exception ex)
{
_logger.Warning($"Performance collection failed: {ex.Message}");
Logger.Debug($"Memory WMI query failed: {ex.Message}");
}
// Disk queue length
try
{
var diskQueue = _diskQueueCounter?.NextValue() ?? 0;
data["disk_queue_length"] = Math.Round(diskQueue, 1);
}
catch (Exception ex)
{
Logger.Debug($"Disk queue counter read failed: {ex.Message}");
}
// Disk free space on system drive
try
{
var systemDrive = Path.GetPathRoot(Environment.SystemDirectory);
var driveInfo = new DriveInfo(systemDrive);
data["disk_free_gb"] = Math.Round(driveInfo.AvailableFreeSpace / (1024.0 * 1024 * 1024), 1);
data["disk_total_gb"] = Math.Round(driveInfo.TotalSize / (1024.0 * 1024 * 1024), 1);
}
catch (Exception ex)
{
Logger.Debug($"Disk space query failed: {ex.Message}");
}
// Network throughput (bytes sent/received delta + rate)
try
{
if (_networkInitialized)
{
// Re-find the NIC by cached ID
NetworkInterface currentNic = null;
try
{
foreach (var nic in NetworkInterface.GetAllNetworkInterfaces())
{
if (nic.Id == _activeNicId)
{
currentNic = nic;
break;
}
}
}
catch { /* swallow */ }
// If cached NIC is gone, try to find a new active one
if (currentNic == null || currentNic.OperationalStatus != OperationalStatus.Up)
{
currentNic = FindActiveNetworkInterface();
if (currentNic != null)
{
_activeNicId = currentNic.Id;
_activeNicName = currentNic.Description;
var freshStats = currentNic.GetIPStatistics();
_prevBytesSent = freshStats.BytesSent;
_prevBytesReceived = freshStats.BytesReceived;
_prevNetSampleTime = DateTime.UtcNow;
Logger.Debug($"Network throughput tracking switched to: {_activeNicName}");
}
else
{
_networkInitialized = false;
Logger.Debug("Active network interface lost, throughput tracking paused");
}
}
if (currentNic != null && currentNic.OperationalStatus == OperationalStatus.Up)
{
var stats = currentNic.GetIPStatistics();
var now = DateTime.UtcNow;
var elapsedSeconds = (now - _prevNetSampleTime).TotalSeconds;
if (elapsedSeconds > 0)
{
var deltaSent = stats.BytesSent - _prevBytesSent;
var deltaReceived = stats.BytesReceived - _prevBytesReceived;
// Guard against counter reset (adapter reconnect)
if (deltaSent < 0) deltaSent = 0;
if (deltaReceived < 0) deltaReceived = 0;
data["net_adapter"] = _activeNicName;
data["net_bytes_sent_delta"] = deltaSent;
data["net_bytes_received_delta"] = deltaReceived;
data["net_send_rate_kbps"] = Math.Round(deltaSent * 8.0 / 1000.0 / elapsedSeconds, 1);
data["net_receive_rate_kbps"] = Math.Round(deltaReceived * 8.0 / 1000.0 / elapsedSeconds, 1);
}
_prevBytesSent = stats.BytesSent;
_prevBytesReceived = stats.BytesReceived;
_prevNetSampleTime = now;
}
}
else
{
// Try to re-initialize (e.g., network came up after boot)
var activeNic = FindActiveNetworkInterface();
if (activeNic != null)
{
_activeNicId = activeNic.Id;
_activeNicName = activeNic.Description;
var stats = activeNic.GetIPStatistics();
_prevBytesSent = stats.BytesSent;
_prevBytesReceived = stats.BytesReceived;
_prevNetSampleTime = DateTime.UtcNow;
_networkInitialized = true;
Logger.Debug($"Network throughput tracking re-initialized on: {_activeNicName}");
}
}
}
catch (Exception ex)
{
Logger.Debug($"Network throughput collection failed: {ex.Message}");
}
if (data.Count > 0)
{
EmitEvent(new EnrollmentEvent
{
SessionId = SessionId,
TenantId = TenantId,
Timestamp = DateTime.UtcNow,
EventType = "performance_snapshot",
Severity = EventSeverity.Debug,
Source = "PerformanceCollector",
Phase = EnrollmentPhase.Unknown,
Message = $"CPU: {(data.ContainsKey("cpu_percent") ? data["cpu_percent"] : "?")}%, " +
$"Memory: {(data.ContainsKey("memory_used_percent") ? data["memory_used_percent"] : "?")}%, " +
$"Disk Free: {(data.ContainsKey("disk_free_gb") ? data["disk_free_gb"] : "?")} GB" +
(data.ContainsKey("net_receive_rate_kbps")
? $", Net: \u2193{data["net_receive_rate_kbps"]} / \u2191{data["net_send_rate_kbps"]} kbps"
: ""),
Data = data
});
}
}
@@ -310,12 +286,5 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
}
return null;
}
public void Dispose()
{
Stop();
_cpuCounter?.Dispose();
_diskQueueCounter?.Dispose();
}
}
}
@@ -1 +1 @@
609
617
@@ -23,6 +23,7 @@
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Microsoft.Identity.Web" Version="4.3.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.11" />
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="8.0.*" />
<PackageReference Include="Resend" Version="0.2.1" />
</ItemGroup>
@@ -13,21 +13,15 @@ public class AutopilotDeviceValidationConsentFunction
{
private readonly ILogger<AutopilotDeviceValidationConsentFunction> _logger;
private readonly IConfiguration _configuration;
private readonly TenantAdminsService _tenantAdminsService;
private readonly GlobalAdminService _globalAdminService;
private readonly GraphTokenService _graphTokenService;
public AutopilotDeviceValidationConsentFunction(
ILogger<AutopilotDeviceValidationConsentFunction> logger,
IConfiguration configuration,
TenantAdminsService tenantAdminsService,
GlobalAdminService globalAdminService,
GraphTokenService graphTokenService)
{
_logger = logger;
_configuration = configuration;
_tenantAdminsService = tenantAdminsService;
_globalAdminService = globalAdminService;
_graphTokenService = graphTokenService;
}
@@ -98,24 +92,15 @@ public class AutopilotDeviceValidationConsentFunction
private async Task<HttpResponseData?> EnsureAuthorizedTenantAdminAsync(HttpRequestData req, string tenantId)
{
// Authentication enforced by PolicyEnforcementMiddleware
var authenticatedTenantId = TenantHelper.GetTenantId(req);
var userIdentifier = TenantHelper.GetUserIdentifier(req);
var requestCtx = req.GetRequestContext();
if (!string.Equals(authenticatedTenantId, tenantId, StringComparison.OrdinalIgnoreCase))
{
var forbidden = req.CreateResponse(HttpStatusCode.Forbidden);
await forbidden.WriteAsJsonAsync(new { error = "Access denied. You can only manage your own tenant." });
return forbidden;
}
var isGlobalAdmin = await _globalAdminService.IsGlobalAdminAsync(userIdentifier);
var isTenantAdmin = await _tenantAdminsService.IsTenantAdminAsync(tenantId, userIdentifier);
if (!isGlobalAdmin && !isTenantAdmin)
// Middleware (TenantAdminOrGA policy) already verified GA or TenantAdmin of own tenant.
// For non-GA: only allow access to own tenant.
if (!requestCtx.IsGlobalAdmin && !tenantId.Equals(requestCtx.TenantId, StringComparison.OrdinalIgnoreCase))
{
_logger.LogWarning(
"User {User} attempted autopilot-device-validation consent operation without admin rights for tenant {TenantId}",
userIdentifier,
requestCtx.UserPrincipalName,
tenantId);
var forbidden = req.CreateResponse(HttpStatusCode.Forbidden);
@@ -1,5 +1,4 @@
using System.Net;
using AutopilotMonitor.Functions.Extensions;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.DataAccess;
@@ -18,18 +17,15 @@ public class TenantAdminManagementFunction
{
private readonly ILogger<TenantAdminManagementFunction> _logger;
private readonly TenantAdminsService _tenantAdminsService;
private readonly GlobalAdminService _globalAdminService;
private readonly IMaintenanceRepository _maintenanceRepo;
public TenantAdminManagementFunction(
ILogger<TenantAdminManagementFunction> logger,
TenantAdminsService tenantAdminsService,
GlobalAdminService globalAdminService,
IMaintenanceRepository maintenanceRepo)
{
_logger = logger;
_tenantAdminsService = tenantAdminsService;
_globalAdminService = globalAdminService;
_maintenanceRepo = maintenanceRepo;
}
@@ -46,17 +42,12 @@ public class TenantAdminManagementFunction
FunctionContext context)
{
// Authentication enforced by PolicyEnforcementMiddleware
var principal = context.GetUser()!;
var requestCtx = context.GetRequestContext();
var upn = requestCtx.UserPrincipalName;
var userTenantId = principal.GetTenantId();
var upn = principal.GetUserPrincipalName();
// Check authorization: either Global Admin OR Tenant Admin of the same tenant
var isGlobalAdmin = await _globalAdminService.IsGlobalAdminAsync(upn);
var isTenantAdmin = tenantId.Equals(userTenantId, StringComparison.OrdinalIgnoreCase) &&
await _tenantAdminsService.IsTenantAdminAsync(tenantId, upn);
if (!isGlobalAdmin && !isTenantAdmin)
// Middleware (TenantAdminOrGA policy) already verified GA or TenantAdmin of own tenant.
// For non-GA: only allow access to own tenant.
if (!requestCtx.IsGlobalAdmin && !tenantId.Equals(requestCtx.TenantId, StringComparison.OrdinalIgnoreCase))
{
_logger.LogWarning($"User {upn} attempted to access admins for tenant {tenantId} without authorization");
var forbiddenResponse = req.CreateResponse(HttpStatusCode.Forbidden);
@@ -86,17 +77,12 @@ public class TenantAdminManagementFunction
FunctionContext context)
{
// Authentication enforced by PolicyEnforcementMiddleware
var principal = context.GetUser()!;
var requestCtx = context.GetRequestContext();
var upn = requestCtx.UserPrincipalName;
var userTenantId = principal.GetTenantId();
var upn = principal.GetUserPrincipalName();
// Check authorization: either Global Admin OR Tenant Admin of the same tenant
var isGlobalAdmin = await _globalAdminService.IsGlobalAdminAsync(upn);
var isTenantAdmin = tenantId.Equals(userTenantId, StringComparison.OrdinalIgnoreCase) &&
await _tenantAdminsService.IsTenantAdminAsync(tenantId, upn);
if (!isGlobalAdmin && !isTenantAdmin)
// Middleware (TenantAdminOrGA policy) already verified GA or TenantAdmin of own tenant.
// For non-GA: only allow access to own tenant.
if (!requestCtx.IsGlobalAdmin && !tenantId.Equals(requestCtx.TenantId, StringComparison.OrdinalIgnoreCase))
{
_logger.LogWarning($"User {upn} attempted to add admin to tenant {tenantId} without authorization");
var forbiddenResponse = req.CreateResponse(HttpStatusCode.Forbidden);
@@ -152,17 +138,12 @@ public class TenantAdminManagementFunction
FunctionContext context)
{
// Authentication enforced by PolicyEnforcementMiddleware
var principal = context.GetUser()!;
var requestCtx = context.GetRequestContext();
var upn = requestCtx.UserPrincipalName;
var userTenantId = principal.GetTenantId();
var upn = principal.GetUserPrincipalName();
// Check authorization: either Global Admin OR Tenant Admin of the same tenant
var isGlobalAdmin = await _globalAdminService.IsGlobalAdminAsync(upn);
var isTenantAdmin = tenantId.Equals(userTenantId, StringComparison.OrdinalIgnoreCase) &&
await _tenantAdminsService.IsTenantAdminAsync(tenantId, upn);
if (!isGlobalAdmin && !isTenantAdmin)
// Middleware (TenantAdminOrGA policy) already verified GA or TenantAdmin of own tenant.
// For non-GA: only allow access to own tenant.
if (!requestCtx.IsGlobalAdmin && !tenantId.Equals(requestCtx.TenantId, StringComparison.OrdinalIgnoreCase))
{
_logger.LogWarning($"User {upn} attempted to remove admin from tenant {tenantId} without authorization");
var forbiddenResponse = req.CreateResponse(HttpStatusCode.Forbidden);
@@ -174,7 +155,7 @@ public class TenantAdminManagementFunction
if (adminUpn.Equals(upn, StringComparison.OrdinalIgnoreCase))
{
// Check if this is the last Admin-role member (only for non-Global-Admins)
if (!isGlobalAdmin)
if (!requestCtx.IsGlobalAdmin)
{
var members = await _tenantAdminsService.GetTenantAdminsAsync(tenantId);
var adminCount = members.Count(m => m.IsEnabled && (m.Role == null || m.Role == AutopilotMonitor.Shared.Constants.TenantRoles.Admin));
@@ -219,17 +200,12 @@ public class TenantAdminManagementFunction
FunctionContext context)
{
// Authentication enforced by PolicyEnforcementMiddleware
var principal = context.GetUser()!;
var requestCtx = context.GetRequestContext();
var upn = requestCtx.UserPrincipalName;
var userTenantId = principal.GetTenantId();
var upn = principal.GetUserPrincipalName();
// Check authorization: either Global Admin OR Tenant Admin of the same tenant
var isGlobalAdmin = await _globalAdminService.IsGlobalAdminAsync(upn);
var isTenantAdmin = tenantId.Equals(userTenantId, StringComparison.OrdinalIgnoreCase) &&
await _tenantAdminsService.IsTenantAdminAsync(tenantId, upn);
if (!isGlobalAdmin && !isTenantAdmin)
// Middleware (TenantAdminOrGA policy) already verified GA or TenantAdmin of own tenant.
// For non-GA: only allow access to own tenant.
if (!requestCtx.IsGlobalAdmin && !tenantId.Equals(requestCtx.TenantId, StringComparison.OrdinalIgnoreCase))
{
_logger.LogWarning($"User {upn} attempted to disable admin for tenant {tenantId} without authorization");
var forbiddenResponse = req.CreateResponse(HttpStatusCode.Forbidden);
@@ -270,17 +246,12 @@ public class TenantAdminManagementFunction
FunctionContext context)
{
// Authentication enforced by PolicyEnforcementMiddleware
var principal = context.GetUser()!;
var requestCtx = context.GetRequestContext();
var upn = requestCtx.UserPrincipalName;
var userTenantId = principal.GetTenantId();
var upn = principal.GetUserPrincipalName();
// Check authorization: either Global Admin OR Tenant Admin of the same tenant
var isGlobalAdmin = await _globalAdminService.IsGlobalAdminAsync(upn);
var isTenantAdmin = tenantId.Equals(userTenantId, StringComparison.OrdinalIgnoreCase) &&
await _tenantAdminsService.IsTenantAdminAsync(tenantId, upn);
if (!isGlobalAdmin && !isTenantAdmin)
// Middleware (TenantAdminOrGA policy) already verified GA or TenantAdmin of own tenant.
// For non-GA: only allow access to own tenant.
if (!requestCtx.IsGlobalAdmin && !tenantId.Equals(requestCtx.TenantId, StringComparison.OrdinalIgnoreCase))
{
_logger.LogWarning($"User {upn} attempted to enable admin for tenant {tenantId} without authorization");
var forbiddenResponse = req.CreateResponse(HttpStatusCode.Forbidden);
@@ -320,17 +291,12 @@ public class TenantAdminManagementFunction
FunctionContext context)
{
// Authentication enforced by PolicyEnforcementMiddleware
var principal = context.GetUser()!;
var requestCtx = context.GetRequestContext();
var upn = requestCtx.UserPrincipalName;
var userTenantId = principal.GetTenantId();
var upn = principal.GetUserPrincipalName();
// Check authorization: either Global Admin OR Tenant Admin of the same tenant
var isGlobalAdmin = await _globalAdminService.IsGlobalAdminAsync(upn);
var isTenantAdmin = tenantId.Equals(userTenantId, StringComparison.OrdinalIgnoreCase) &&
await _tenantAdminsService.IsTenantAdminAsync(tenantId, upn);
if (!isGlobalAdmin && !isTenantAdmin)
// Middleware (TenantAdminOrGA policy) already verified GA or TenantAdmin of own tenant.
// For non-GA: only allow access to own tenant.
if (!requestCtx.IsGlobalAdmin && !tenantId.Equals(requestCtx.TenantId, StringComparison.OrdinalIgnoreCase))
{
_logger.LogWarning("User {Upn} attempted to update member permissions for tenant {TenantId} without authorization", upn, tenantId);
var forbiddenResponse = req.CreateResponse(HttpStatusCode.Forbidden);
@@ -349,7 +315,7 @@ public class TenantAdminManagementFunction
// Prevent demoting yourself if you're the last Admin
if (adminUpn.Equals(upn, StringComparison.OrdinalIgnoreCase) && body.Role != AutopilotMonitor.Shared.Constants.TenantRoles.Admin)
{
if (!isGlobalAdmin)
if (!requestCtx.IsGlobalAdmin)
{
var members = await _tenantAdminsService.GetTenantAdminsAsync(tenantId);
var adminCount = members.Count(m => m.IsEnabled && (m.Role == null || m.Role == AutopilotMonitor.Shared.Constants.TenantRoles.Admin));
@@ -1,8 +1,6 @@
using System.Net;
using AutopilotMonitor.Functions.Extensions;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared;
using Microsoft.AspNetCore.Authorization;
@@ -20,19 +18,13 @@ namespace AutopilotMonitor.Functions.Functions.Admin;
public class TenantOffboardFunction
{
private readonly ILogger<TenantOffboardFunction> _logger;
private readonly TenantAdminsService _tenantAdminsService;
private readonly GlobalAdminService _globalAdminService;
private readonly IMaintenanceRepository _maintenanceRepo;
public TenantOffboardFunction(
ILogger<TenantOffboardFunction> logger,
TenantAdminsService tenantAdminsService,
GlobalAdminService globalAdminService,
IMaintenanceRepository maintenanceRepo)
{
_logger = logger;
_tenantAdminsService = tenantAdminsService;
_globalAdminService = globalAdminService;
_maintenanceRepo = maintenanceRepo;
}
@@ -50,17 +42,12 @@ public class TenantOffboardFunction
FunctionContext context)
{
// Authentication enforced by PolicyEnforcementMiddleware
var principal = context.GetUser()!;
var requestCtx = context.GetRequestContext();
var upn = requestCtx.UserPrincipalName;
var userTenantId = principal.GetTenantId();
var upn = principal.GetUserPrincipalName();
// Only Tenant Admins of the same tenant OR Global Admins may offboard
var isGlobalAdmin = await _globalAdminService.IsGlobalAdminAsync(upn);
var isTenantAdmin = tenantId.Equals(userTenantId, StringComparison.OrdinalIgnoreCase) &&
await _tenantAdminsService.IsTenantAdminAsync(tenantId, upn);
if (!isGlobalAdmin && !isTenantAdmin)
// Middleware (TenantAdminOrGA policy) already verified GA or TenantAdmin of own tenant.
// For non-GA: only allow access to own tenant.
if (!requestCtx.IsGlobalAdmin && !tenantId.Equals(requestCtx.TenantId, StringComparison.OrdinalIgnoreCase))
{
_logger.LogWarning($"User {upn} attempted to offboard tenant {tenantId} without authorization");
var forbiddenResponse = req.CreateResponse(HttpStatusCode.Forbidden);
@@ -21,20 +21,17 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
{
private readonly ILogger<CreateBootstrapSessionFunction> _logger;
private readonly BootstrapSessionService _bootstrapService;
private readonly GlobalAdminService _globalAdminService;
private readonly TenantConfigurationService _configService;
private readonly IMaintenanceRepository _maintenanceRepo;
public CreateBootstrapSessionFunction(
ILogger<CreateBootstrapSessionFunction> logger,
BootstrapSessionService bootstrapService,
GlobalAdminService globalAdminService,
TenantConfigurationService configService,
IMaintenanceRepository maintenanceRepo)
{
_logger = logger;
_bootstrapService = bootstrapService;
_globalAdminService = globalAdminService;
_configService = configService;
_maintenanceRepo = maintenanceRepo;
}
@@ -46,8 +43,9 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
try
{
// Authentication + BootstrapManagerOrGA authorization enforced by PolicyEnforcementMiddleware
var authenticatedTenantId = TenantHelper.GetTenantId(req);
var userIdentifier = TenantHelper.GetUserIdentifier(req);
var requestCtx = req.GetRequestContext();
var authenticatedTenantId = requestCtx.TenantId;
var userIdentifier = requestCtx.UserPrincipalName;
// Read request body
string body;
@@ -66,15 +64,11 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
var tenantId = !string.IsNullOrEmpty(request.TenantId) ? request.TenantId : authenticatedTenantId;
// Cross-tenant boundary check — only Global Admins may operate on other tenants
if (!string.Equals(authenticatedTenantId, tenantId, StringComparison.OrdinalIgnoreCase))
if (!requestCtx.IsGlobalAdmin && !string.Equals(authenticatedTenantId, tenantId, StringComparison.OrdinalIgnoreCase))
{
var isGlobal = await _globalAdminService.IsGlobalAdminAsync(userIdentifier);
if (!isGlobal)
{
var forbidden = req.CreateResponse(HttpStatusCode.Forbidden);
await forbidden.WriteAsJsonAsync(new { error = "Access denied: tenant mismatch" });
return forbidden;
}
var forbidden = req.CreateResponse(HttpStatusCode.Forbidden);
await forbidden.WriteAsJsonAsync(new { error = "Access denied: tenant mismatch" });
return forbidden;
}
// Check if bootstrap token feature is enabled for this tenant
@@ -19,18 +19,15 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
{
private readonly ILogger<ListBootstrapSessionsFunction> _logger;
private readonly BootstrapSessionService _bootstrapService;
private readonly GlobalAdminService _globalAdminService;
private readonly TenantConfigurationService _configService;
public ListBootstrapSessionsFunction(
ILogger<ListBootstrapSessionsFunction> logger,
BootstrapSessionService bootstrapService,
GlobalAdminService globalAdminService,
TenantConfigurationService configService)
{
_logger = logger;
_bootstrapService = bootstrapService;
_globalAdminService = globalAdminService;
_configService = configService;
}
@@ -41,21 +38,18 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
try
{
// Authentication + BootstrapManagerOrGA authorization enforced by PolicyEnforcementMiddleware
var authenticatedTenantId = TenantHelper.GetTenantId(req);
var userIdentifier = TenantHelper.GetUserIdentifier(req);
var requestCtx = req.GetRequestContext();
var authenticatedTenantId = requestCtx.TenantId;
var userIdentifier = requestCtx.UserPrincipalName;
var tenantId = req.Query["tenantId"] ?? authenticatedTenantId;
// Cross-tenant boundary check — only Global Admins may operate on other tenants
if (!string.Equals(authenticatedTenantId, tenantId, StringComparison.OrdinalIgnoreCase))
if (!requestCtx.IsGlobalAdmin && !string.Equals(authenticatedTenantId, tenantId, StringComparison.OrdinalIgnoreCase))
{
var isGlobal = await _globalAdminService.IsGlobalAdminAsync(userIdentifier);
if (!isGlobal)
{
var forbidden = req.CreateResponse(HttpStatusCode.Forbidden);
await forbidden.WriteAsJsonAsync(new { error = "Access denied: tenant mismatch" });
return forbidden;
}
var forbidden = req.CreateResponse(HttpStatusCode.Forbidden);
await forbidden.WriteAsJsonAsync(new { error = "Access denied: tenant mismatch" });
return forbidden;
}
// Check if bootstrap token feature is enabled for this tenant
@@ -18,20 +18,17 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
{
private readonly ILogger<RevokeBootstrapSessionFunction> _logger;
private readonly BootstrapSessionService _bootstrapService;
private readonly GlobalAdminService _globalAdminService;
private readonly TenantConfigurationService _configService;
private readonly IMaintenanceRepository _maintenanceRepo;
public RevokeBootstrapSessionFunction(
ILogger<RevokeBootstrapSessionFunction> logger,
BootstrapSessionService bootstrapService,
GlobalAdminService globalAdminService,
TenantConfigurationService configService,
IMaintenanceRepository maintenanceRepo)
{
_logger = logger;
_bootstrapService = bootstrapService;
_globalAdminService = globalAdminService;
_configService = configService;
_maintenanceRepo = maintenanceRepo;
}
@@ -44,21 +41,18 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
try
{
// Authentication + BootstrapManagerOrGA authorization enforced by PolicyEnforcementMiddleware
var authenticatedTenantId = TenantHelper.GetTenantId(req);
var userIdentifier = TenantHelper.GetUserIdentifier(req);
var requestCtx = req.GetRequestContext();
var authenticatedTenantId = requestCtx.TenantId;
var userIdentifier = requestCtx.UserPrincipalName;
var tenantId = req.Query["tenantId"] ?? authenticatedTenantId;
// Cross-tenant boundary check — only Global Admins may operate on other tenants
if (!string.Equals(authenticatedTenantId, tenantId, StringComparison.OrdinalIgnoreCase))
if (!requestCtx.IsGlobalAdmin && !string.Equals(authenticatedTenantId, tenantId, StringComparison.OrdinalIgnoreCase))
{
var isGlobal = await _globalAdminService.IsGlobalAdminAsync(userIdentifier);
if (!isGlobal)
{
var forbidden = req.CreateResponse(HttpStatusCode.Forbidden);
await forbidden.WriteAsJsonAsync(new { error = "Access denied: tenant mismatch" });
return forbidden;
}
var forbidden = req.CreateResponse(HttpStatusCode.Forbidden);
await forbidden.WriteAsJsonAsync(new { error = "Access denied: tenant mismatch" });
return forbidden;
}
// Check if bootstrap token feature is enabled for this tenant
@@ -13,16 +13,13 @@ namespace AutopilotMonitor.Functions.Functions.Config
{
private readonly ILogger<GetTenantConfigurationFunction> _logger;
private readonly TenantConfigurationService _configService;
private readonly GlobalAdminService _globalAdminService;
public GetTenantConfigurationFunction(
ILogger<GetTenantConfigurationFunction> logger,
TenantConfigurationService configService,
GlobalAdminService globalAdminService)
TenantConfigurationService configService)
{
_logger = logger;
_configService = configService;
_globalAdminService = globalAdminService;
}
[Function("GetTenantConfiguration")]
@@ -33,25 +30,21 @@ namespace AutopilotMonitor.Functions.Functions.Config
try
{
// Authentication + MemberRead authorization enforced by PolicyEnforcementMiddleware
string authenticatedTenantId = TenantHelper.GetTenantId(req);
string userIdentifier = TenantHelper.GetUserIdentifier(req);
var requestCtx = req.GetRequestContext();
var userIdentifier = requestCtx.UserPrincipalName;
// Validate tenant access: cross-tenant only for Global Admins
if (!string.Equals(authenticatedTenantId, tenantId, StringComparison.OrdinalIgnoreCase))
if (!requestCtx.IsGlobalAdmin && !string.Equals(requestCtx.TenantId, tenantId, StringComparison.OrdinalIgnoreCase))
{
var isGlobalAdmin = await _globalAdminService.IsGlobalAdminAsync(userIdentifier);
if (!isGlobalAdmin)
_logger.LogWarning("User {User} from tenant {AuthTenant} attempted to access configuration for tenant {TargetTenant}",
userIdentifier, requestCtx.TenantId, tenantId);
var forbiddenResponse = req.CreateResponse(HttpStatusCode.Forbidden);
await forbiddenResponse.WriteAsJsonAsync(new
{
_logger.LogWarning("User {User} from tenant {AuthTenant} attempted to access configuration for tenant {TargetTenant}",
userIdentifier, authenticatedTenantId, tenantId);
var forbiddenResponse = req.CreateResponse(HttpStatusCode.Forbidden);
await forbiddenResponse.WriteAsJsonAsync(new
{
success = false,
message = "Access denied. You can only access your own tenant's configuration."
});
return forbiddenResponse;
}
success = false,
message = "Access denied. You can only access your own tenant's configuration."
});
return forbiddenResponse;
}
_logger.LogInformation($"GetTenantConfiguration: {tenantId} by user {userIdentifier}");
@@ -18,18 +18,15 @@ namespace AutopilotMonitor.Functions.Functions.Config
{
private readonly ILogger<UpdateTenantConfigurationFunction> _logger;
private readonly TenantConfigurationService _configService;
private readonly GlobalAdminService _globalAdminService;
private readonly IMaintenanceRepository _maintenanceRepo;
public UpdateTenantConfigurationFunction(
ILogger<UpdateTenantConfigurationFunction> logger,
TenantConfigurationService configService,
GlobalAdminService globalAdminService,
IMaintenanceRepository maintenanceRepo)
{
_logger = logger;
_configService = configService;
_globalAdminService = globalAdminService;
_maintenanceRepo = maintenanceRepo;
}
@@ -41,17 +38,14 @@ namespace AutopilotMonitor.Functions.Functions.Config
try
{
// Authentication + TenantAdminOrGA authorization enforced by PolicyEnforcementMiddleware
string authenticatedTenantId = TenantHelper.GetTenantId(req);
string userIdentifier = TenantHelper.GetUserIdentifier(req);
// GA check needed for: cross-tenant bypass + protected field filtering
var isGlobalAdmin = await _globalAdminService.IsGlobalAdminAsync(userIdentifier);
var requestCtx = req.GetRequestContext();
var userIdentifier = requestCtx.UserPrincipalName;
// Validate tenant access: cross-tenant only for Global Admins
if (!isGlobalAdmin && !string.Equals(authenticatedTenantId, tenantId, StringComparison.OrdinalIgnoreCase))
if (!requestCtx.IsGlobalAdmin && !string.Equals(requestCtx.TenantId, tenantId, StringComparison.OrdinalIgnoreCase))
{
_logger.LogWarning("User {User} from tenant {AuthTenant} attempted to update configuration for tenant {TargetTenant}",
userIdentifier, authenticatedTenantId, tenantId);
userIdentifier, requestCtx.TenantId, tenantId);
var forbiddenResponse = req.CreateResponse(HttpStatusCode.Forbidden);
await forbiddenResponse.WriteAsJsonAsync(new
{
@@ -90,7 +84,7 @@ namespace AutopilotMonitor.Functions.Functions.Config
// Protect GA-only fields from non-Global-Admin callers
var existingConfig = await _configService.GetConfigurationAsync(tenantId);
if (!isGlobalAdmin)
if (!requestCtx.IsGlobalAdmin)
{
if (config.AllowInsecureAgentRequests != existingConfig.AllowInsecureAgentRequests ||
config.BootstrapTokenEnabled != existingConfig.BootstrapTokenEnabled ||
@@ -16,16 +16,13 @@ namespace AutopilotMonitor.Functions.Functions.Diagnostics
{
private readonly ILogger<DiagnosticsDownloadFunction> _logger;
private readonly TenantConfigurationService _configService;
private readonly GlobalAdminService _globalAdminService;
public DiagnosticsDownloadFunction(
ILogger<DiagnosticsDownloadFunction> logger,
TenantConfigurationService configService,
GlobalAdminService globalAdminService)
TenantConfigurationService configService)
{
_logger = logger;
_configService = configService;
_globalAdminService = globalAdminService;
}
[Function("DiagnosticsDownloadUrl")]
@@ -35,8 +32,7 @@ namespace AutopilotMonitor.Functions.Functions.Diagnostics
try
{
// Authentication + MemberRead authorization enforced by PolicyEnforcementMiddleware
var userTenantId = TenantHelper.GetTenantId(req);
var userIdentifier = TenantHelper.GetUserIdentifier(req);
var requestCtx = req.GetRequestContext();
var query = HttpUtility.ParseQueryString(req.Url.Query);
var tenantId = query["tenantId"];
@@ -50,15 +46,11 @@ namespace AutopilotMonitor.Functions.Functions.Diagnostics
}
// Validate tenant access
if (tenantId != userTenantId)
if (!requestCtx.IsGlobalAdmin && tenantId != requestCtx.TenantId)
{
var isGlobalAdmin = await _globalAdminService.IsGlobalAdminAsync(userIdentifier);
if (!isGlobalAdmin)
{
var forbiddenResponse = req.CreateResponse(HttpStatusCode.Forbidden);
await forbiddenResponse.WriteAsJsonAsync(new { success = false, message = "Access denied." });
return forbiddenResponse;
}
var forbiddenResponse = req.CreateResponse(HttpStatusCode.Forbidden);
await forbiddenResponse.WriteAsJsonAsync(new { success = false, message = "Access denied." });
return forbiddenResponse;
}
// Validate blob name (prevent path traversal, double-encoding, and null byte attacks)
@@ -1,6 +1,5 @@
using System.Net;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Services;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Azure.Functions.Worker.Extensions.SignalRService;
@@ -12,14 +11,11 @@ namespace AutopilotMonitor.Functions.Functions.Infrastructure
public class SignalRAddToGroupFunction
{
private readonly ILogger<SignalRAddToGroupFunction> _logger;
private readonly GlobalAdminService _globalAdminService;
public SignalRAddToGroupFunction(
ILogger<SignalRAddToGroupFunction> logger,
GlobalAdminService globalAdminService)
ILogger<SignalRAddToGroupFunction> logger)
{
_logger = logger;
_globalAdminService = globalAdminService;
}
[Function("AddToGroup")]
@@ -49,9 +45,10 @@ namespace AutopilotMonitor.Functions.Functions.Infrastructure
return new AddToGroupOutput { HttpResponse = errorResponse };
}
// Get user's tenant ID from JWT
var userTenantId = TenantHelper.GetTenantId(req);
var userEmail = TenantHelper.GetUserIdentifier(req);
// Get user's tenant ID from RequestContext
var requestCtx = req.GetRequestContext();
var userTenantId = requestCtx.TenantId;
var userEmail = requestCtx.UserPrincipalName;
// Validate tenant access
// Group names are in format: "tenant-{tenantId}", "session-{tenantId}-{sessionId}", or "global-admins"
@@ -60,8 +57,7 @@ namespace AutopilotMonitor.Functions.Functions.Infrastructure
// Explicit validation for the global-admins group
if (request.GroupName == "global-admins")
{
var isGlobalAdmin = await _globalAdminService.IsGlobalAdminAsync(userEmail);
if (!isGlobalAdmin)
if (!requestCtx.IsGlobalAdmin)
{
_logger.LogWarning($"User {userEmail} (tenant {userTenantId}) attempted to join global-admins group without being a Global Admin");
var forbiddenResponse = req.CreateResponse(HttpStatusCode.Forbidden);
@@ -85,9 +81,7 @@ namespace AutopilotMonitor.Functions.Functions.Infrastructure
if (requestedTenantId != userTenantId)
{
// Check if user is Global Admin (they can join any tenant's group)
var isGlobalAdmin = await _globalAdminService.IsGlobalAdminAsync(userEmail);
if (!isGlobalAdmin)
if (!requestCtx.IsGlobalAdmin)
{
_logger.LogWarning($"User {userEmail} (tenant {userTenantId}) attempted to join group for tenant {requestedTenantId}");
var forbiddenResponse = req.CreateResponse(HttpStatusCode.Forbidden);
@@ -1,6 +1,5 @@
using System.Net;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Services;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Azure.Functions.Worker.Extensions.SignalRService;
@@ -12,14 +11,11 @@ namespace AutopilotMonitor.Functions.Functions.Infrastructure
public class SignalRRemoveFromGroupFunction
{
private readonly ILogger<SignalRRemoveFromGroupFunction> _logger;
private readonly GlobalAdminService _globalAdminService;
public SignalRRemoveFromGroupFunction(
ILogger<SignalRRemoveFromGroupFunction> logger,
GlobalAdminService globalAdminService)
ILogger<SignalRRemoveFromGroupFunction> logger)
{
_logger = logger;
_globalAdminService = globalAdminService;
}
[Function("RemoveFromGroup")]
@@ -49,9 +45,10 @@ namespace AutopilotMonitor.Functions.Functions.Infrastructure
return new RemoveFromGroupOutput { HttpResponse = errorResponse };
}
// Get user's tenant ID from JWT
var userTenantId = TenantHelper.GetTenantId(req);
var userEmail = TenantHelper.GetUserIdentifier(req);
// Get user's tenant ID from RequestContext
var requestCtx = req.GetRequestContext();
var userTenantId = requestCtx.TenantId;
var userEmail = requestCtx.UserPrincipalName;
// Validate tenant access (same logic as AddToGroup)
// Group names are in format: "tenant-{tenantId}", "session-{tenantId}-{sessionId}", or "global-admins"
@@ -60,8 +57,7 @@ namespace AutopilotMonitor.Functions.Functions.Infrastructure
// Explicit validation for the global-admins group
if (request.GroupName == "global-admins")
{
var isGlobalAdmin = await _globalAdminService.IsGlobalAdminAsync(userEmail);
if (!isGlobalAdmin)
if (!requestCtx.IsGlobalAdmin)
{
_logger.LogWarning($"User {userEmail} (tenant {userTenantId}) attempted to leave global-admins group without being a Global Admin");
var forbiddenResponse = req.CreateResponse(HttpStatusCode.Forbidden);
@@ -85,9 +81,7 @@ namespace AutopilotMonitor.Functions.Functions.Infrastructure
if (requestedTenantId != userTenantId)
{
// Check if user is Global Admin (they can leave any tenant's group)
var isGlobalAdmin = await _globalAdminService.IsGlobalAdminAsync(userEmail);
if (!isGlobalAdmin)
if (!requestCtx.IsGlobalAdmin)
{
_logger.LogWarning($"User {userEmail} (tenant {userTenantId}) attempted to leave group for tenant {requestedTenantId}");
var forbiddenResponse = req.CreateResponse(HttpStatusCode.Forbidden);
@@ -1,7 +1,6 @@
using System.Net;
using System.Web;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.DataAccess;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
@@ -18,16 +17,13 @@ public class ProgressPortalFunction
{
private readonly ILogger<ProgressPortalFunction> _logger;
private readonly ISessionRepository _sessionRepo;
private readonly GlobalAdminService _globalAdminService;
public ProgressPortalFunction(
ILogger<ProgressPortalFunction> logger,
ISessionRepository sessionRepo,
GlobalAdminService globalAdminService)
ISessionRepository sessionRepo)
{
_logger = logger;
_sessionRepo = sessionRepo;
_globalAdminService = globalAdminService;
}
/// <summary>
@@ -43,7 +39,7 @@ public class ProgressPortalFunction
try
{
// Authentication + AuthenticatedUser authorization enforced by PolicyEnforcementMiddleware
var tenantId = TenantHelper.GetTenantId(req);
var tenantId = req.GetRequestContext().TenantId;
_logger.LogInformation("ProgressGetSessions: Fetching sessions for tenant {TenantId}", tenantId);
@@ -96,8 +92,8 @@ public class ProgressPortalFunction
try
{
// Authentication + AuthenticatedUser authorization enforced by PolicyEnforcementMiddleware
var userTenantId = TenantHelper.GetTenantId(req);
var userIdentifier = TenantHelper.GetUserIdentifier(req);
var requestCtx = req.GetRequestContext();
var userIdentifier = requestCtx.UserPrincipalName;
var query = HttpUtility.ParseQueryString(req.Url.Query);
var requestedTenantId = query["tenantId"];
@@ -117,23 +113,22 @@ public class ProgressPortalFunction
}
// Cross-tenant access only for Global Admins
if (requestedTenantId != userTenantId)
if (!requestCtx.IsGlobalAdmin && requestedTenantId != requestCtx.TenantId)
{
var isGlobalAdmin = await _globalAdminService.IsGlobalAdminAsync(userIdentifier);
if (!isGlobalAdmin)
var forbiddenResponse = req.CreateResponse(HttpStatusCode.Forbidden);
await forbiddenResponse.WriteAsJsonAsync(new
{
var forbiddenResponse = req.CreateResponse(HttpStatusCode.Forbidden);
await forbiddenResponse.WriteAsJsonAsync(new
{
success = false,
message = "Access denied. You can only view events for sessions in your own tenant.",
sessionId = sessionId,
count = 0,
events = Array.Empty<object>()
});
return forbiddenResponse;
}
success = false,
message = "Access denied. You can only view events for sessions in your own tenant.",
sessionId = sessionId,
count = 0,
events = Array.Empty<object>()
});
return forbiddenResponse;
}
if (requestCtx.IsGlobalAdmin && requestedTenantId != requestCtx.TenantId)
{
_logger.LogInformation("{SessionPrefix} Global Admin {User} accessing cross-tenant progress events (tenant: {TenantId})",
sessionPrefix, userIdentifier, requestedTenantId);
}
@@ -14,7 +14,6 @@ namespace AutopilotMonitor.Functions.Functions.Reports
private readonly ILogger<SubmitSessionReportFunction> _logger;
private readonly SessionReportService _sessionReportService;
private readonly IMaintenanceRepository _maintenanceRepo;
private readonly GlobalAdminService _globalAdminService;
private readonly TelegramNotificationService _telegramNotificationService;
private readonly GlobalNotificationService _globalNotificationService;
@@ -22,14 +21,12 @@ namespace AutopilotMonitor.Functions.Functions.Reports
ILogger<SubmitSessionReportFunction> logger,
SessionReportService sessionReportService,
IMaintenanceRepository maintenanceRepo,
GlobalAdminService globalAdminService,
TelegramNotificationService telegramNotificationService,
GlobalNotificationService globalNotificationService)
{
_logger = logger;
_sessionReportService = sessionReportService;
_maintenanceRepo = maintenanceRepo;
_globalAdminService = globalAdminService;
_telegramNotificationService = telegramNotificationService;
_globalNotificationService = globalNotificationService;
}
@@ -44,11 +41,9 @@ namespace AutopilotMonitor.Functions.Functions.Reports
try
{
// Authentication + TenantAdminOrGA authorization enforced by PolicyEnforcementMiddleware
string tenantId = TenantHelper.GetTenantId(req);
string userIdentifier = TenantHelper.GetUserIdentifier(req);
// Still needed: GA check for audit log skip logic
var isGlobalAdmin = await _globalAdminService.IsGlobalAdminAsync(userIdentifier);
var requestCtx = req.GetRequestContext();
var tenantId = requestCtx.TenantId;
var userIdentifier = requestCtx.UserPrincipalName;
// Request body size limit (1 MB)
if (req.Headers.TryGetValues("Content-Length", out var clValues)
@@ -84,7 +79,7 @@ namespace AutopilotMonitor.Functions.Functions.Reports
var metadata = await _sessionReportService.SubmitReportAsync(request, userIdentifier);
// Log audit entry — skip for Global Admins
if (!isGlobalAdmin)
if (!requestCtx.IsGlobalAdmin)
{
await _maintenanceRepo.LogAuditEntryAsync(
request.TenantId,
@@ -21,22 +21,19 @@ namespace AutopilotMonitor.Functions.Functions.Rules
private readonly IMaintenanceRepository _maintenanceRepo;
private readonly ISessionRepository _sessionRepo;
private readonly AnalyzeRuleService _analyzeRuleService;
private readonly GlobalAdminService _globalAdminService;
public GetRuleResultsFunction(
ILogger<GetRuleResultsFunction> logger,
IRuleRepository ruleRepo,
IMaintenanceRepository maintenanceRepo,
ISessionRepository sessionRepo,
AnalyzeRuleService analyzeRuleService,
GlobalAdminService globalAdminService)
AnalyzeRuleService analyzeRuleService)
{
_logger = logger;
_ruleRepo = ruleRepo;
_maintenanceRepo = maintenanceRepo;
_sessionRepo = sessionRepo;
_analyzeRuleService = analyzeRuleService;
_globalAdminService = globalAdminService;
}
[Function("GetRuleResults")]
@@ -45,25 +42,24 @@ namespace AutopilotMonitor.Functions.Functions.Rules
string sessionId)
{
// Authentication + MemberRead authorization enforced by PolicyEnforcementMiddleware
var userTenantId = TenantHelper.GetTenantId(req);
var userIdentifier = TenantHelper.GetUserIdentifier(req);
var requestCtx = req.GetRequestContext();
var userIdentifier = requestCtx.UserPrincipalName;
// Resolve effective tenant ID: use query param if provided, fall back to JWT tenant
var query = HttpUtility.ParseQueryString(req.Url.Query);
var requestedTenantId = query["tenantId"];
var effectiveTenantId = string.IsNullOrEmpty(requestedTenantId) ? userTenantId : requestedTenantId;
var effectiveTenantId = string.IsNullOrEmpty(requestedTenantId) ? requestCtx.TenantId : requestedTenantId;
// Cross-tenant access requires Global Admin
if (effectiveTenantId != userTenantId)
if (!requestCtx.IsGlobalAdmin && effectiveTenantId != requestCtx.TenantId)
{
var isGlobalAdmin = await _globalAdminService.IsGlobalAdminAsync(userIdentifier);
if (!isGlobalAdmin)
{
var forbidden = req.CreateResponse(HttpStatusCode.Forbidden);
await forbidden.WriteAsJsonAsync(new { success = false, message = "Access denied. You can only view analysis results for your own tenant." });
return forbidden;
}
var forbidden = req.CreateResponse(HttpStatusCode.Forbidden);
await forbidden.WriteAsJsonAsync(new { success = false, message = "Access denied. You can only view analysis results for your own tenant." });
return forbidden;
}
if (requestCtx.IsGlobalAdmin && effectiveTenantId != requestCtx.TenantId)
{
_logger.LogInformation($"Global Admin {userIdentifier} accessing cross-tenant analysis results (tenant: {effectiveTenantId})");
}
@@ -55,8 +55,7 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
page = await _sessionRepo.GetAllSessionsAsync(maxResults: 100, cursor: cursor);
}
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new
return await req.OkAsync(new
{
success = true,
count = page.Sessions.Count,
@@ -64,8 +63,6 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
cursor = page.Cursor,
sessions = page.Sessions
});
return response;
}
catch (Exception ex)
{
@@ -1,7 +1,6 @@
using System.Net;
using System.Web;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.DataAccess;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
@@ -13,16 +12,13 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
{
private readonly ILogger<GetSessionEventsFunction> _logger;
private readonly ISessionRepository _sessionRepo;
private readonly GlobalAdminService _globalAdminService;
public GetSessionEventsFunction(
ILogger<GetSessionEventsFunction> logger,
ISessionRepository sessionRepo,
GlobalAdminService globalAdminService)
ISessionRepository sessionRepo)
{
_logger = logger;
_sessionRepo = sessionRepo;
_globalAdminService = globalAdminService;
}
[Function("GetSessionEvents")]
@@ -45,8 +41,8 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
// Authentication + MemberRead authorization enforced by PolicyEnforcementMiddleware
// Get user's tenant ID and identifier from JWT
var userTenantId = TenantHelper.GetTenantId(req);
var userIdentifier = TenantHelper.GetUserIdentifier(req);
var requestCtx = req.GetRequestContext();
var userIdentifier = requestCtx.UserPrincipalName;
// Get requested tenant ID from query parameter
var query = HttpUtility.ParseQueryString(req.Url.Query);
@@ -68,43 +64,35 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
}
// Validate tenant access: user must either own the tenant or be Global Admin
if (requestedTenantId != userTenantId)
if (!requestCtx.IsGlobalAdmin && requestedTenantId != requestCtx.TenantId)
{
var isGlobalAdmin = await _globalAdminService.IsGlobalAdminAsync(userIdentifier);
if (!isGlobalAdmin)
_logger.LogWarning($"{sessionPrefix} User {userIdentifier} (tenant {requestCtx.TenantId}) attempted to access session events for tenant {requestedTenantId}");
var forbiddenResponse = req.CreateResponse(HttpStatusCode.Forbidden);
await forbiddenResponse.WriteAsJsonAsync(new
{
_logger.LogWarning($"{sessionPrefix} User {userIdentifier} (tenant {userTenantId}) attempted to access session events for tenant {requestedTenantId}");
var forbiddenResponse = req.CreateResponse(HttpStatusCode.Forbidden);
await forbiddenResponse.WriteAsJsonAsync(new
{
success = false,
message = "Access denied. You can only view events for sessions in your own tenant.",
sessionId = sessionId,
count = 0,
events = Array.Empty<object>()
});
return forbiddenResponse;
}
else
{
_logger.LogInformation($"{sessionPrefix} Global Admin {userIdentifier} accessing cross-tenant session events (tenant: {requestedTenantId})");
}
success = false,
message = "Access denied. You can only view events for sessions in your own tenant.",
sessionId = sessionId,
count = 0,
events = Array.Empty<object>()
});
return forbiddenResponse;
}
else if (requestCtx.IsGlobalAdmin && requestedTenantId != requestCtx.TenantId)
{
_logger.LogInformation($"{sessionPrefix} Global Admin {userIdentifier} accessing cross-tenant session events (tenant: {requestedTenantId})");
}
// Get events from storage using the requested tenant ID
var events = await _sessionRepo.GetSessionEventsAsync(requestedTenantId, sessionId);
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new
return await req.OkAsync(new
{
success = true,
sessionId = sessionId,
count = events.Count,
events = events
});
return response;
}
catch (Exception ex)
{
@@ -1,7 +1,6 @@
using System.Net;
using System.Web;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.DataAccess;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
@@ -13,16 +12,13 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
{
private readonly ILogger<GetSessionFunction> _logger;
private readonly ISessionRepository _sessionRepo;
private readonly GlobalAdminService _globalAdminService;
public GetSessionFunction(
ILogger<GetSessionFunction> logger,
ISessionRepository sessionRepo,
GlobalAdminService globalAdminService)
ISessionRepository sessionRepo)
{
_logger = logger;
_sessionRepo = sessionRepo;
_globalAdminService = globalAdminService;
}
[Function("GetSession")]
@@ -46,11 +42,11 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
try
{
// Authentication + MemberRead authorization enforced by PolicyEnforcementMiddleware
var userTenantId = TenantHelper.GetTenantId(req);
var userIdentifier = TenantHelper.GetUserIdentifier(req);
var requestCtx = req.GetRequestContext();
var userIdentifier = requestCtx.UserPrincipalName;
var query = HttpUtility.ParseQueryString(req.Url.Query);
var requestedTenantId = query["tenantId"];
var effectiveTenantId = string.IsNullOrWhiteSpace(requestedTenantId) ? userTenantId : requestedTenantId;
var effectiveTenantId = string.IsNullOrWhiteSpace(requestedTenantId) ? requestCtx.TenantId : requestedTenantId;
if (string.IsNullOrWhiteSpace(effectiveTenantId))
{
@@ -63,20 +59,16 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
return badRequest;
}
if (!string.Equals(effectiveTenantId, userTenantId, StringComparison.OrdinalIgnoreCase))
if (!requestCtx.IsGlobalAdmin && !string.Equals(effectiveTenantId, requestCtx.TenantId, StringComparison.OrdinalIgnoreCase))
{
var isGlobalAdmin = await _globalAdminService.IsGlobalAdminAsync(userIdentifier);
if (!isGlobalAdmin)
_logger.LogWarning($"{sessionPrefix} User {userIdentifier} (tenant {requestCtx.TenantId}) attempted to access session in tenant {effectiveTenantId}");
var forbiddenResponse = req.CreateResponse(HttpStatusCode.Forbidden);
await forbiddenResponse.WriteAsJsonAsync(new
{
_logger.LogWarning($"{sessionPrefix} User {userIdentifier} (tenant {userTenantId}) attempted to access session in tenant {effectiveTenantId}");
var forbiddenResponse = req.CreateResponse(HttpStatusCode.Forbidden);
await forbiddenResponse.WriteAsJsonAsync(new
{
success = false,
message = "Access denied. You can only view sessions for your own tenant."
});
return forbiddenResponse;
}
success = false,
message = "Access denied. You can only view sessions for your own tenant."
});
return forbiddenResponse;
}
var session = await _sessionRepo.GetSessionAsync(effectiveTenantId, sessionId);
@@ -92,13 +84,11 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
return notFound;
}
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new
return await req.OkAsync(new
{
success = true,
session
});
return response;
}
catch (Exception ex)
{
@@ -35,8 +35,7 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
var page = await _sessionRepo.GetSessionsAsync(tenantId, maxResults: 100, cursor: cursor);
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new
return await req.OkAsync(new
{
success = true,
count = page.Sessions.Count,
@@ -44,8 +43,6 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
cursor = page.Cursor,
sessions = page.Sessions
});
return response;
}
catch (Exception ex)
{
@@ -58,14 +58,12 @@ public class SearchSessionsByCveFunction
var sessions = await _sessionRepo.SearchSessionsByCveAsync(tenantId, cveId, minCvssScore, overallRisk, limit);
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new
return await req.OkAsync(new
{
success = true,
count = sessions.Count,
sessions
});
return response;
}
catch (Exception ex)
{
@@ -56,14 +56,12 @@ public class SearchSessionsByEventFunction
var sessions = await _sessionRepo.SearchSessionsByEventAsync(tenantId, eventType, null, null, null, limit);
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new
return await req.OkAsync(new
{
success = true,
count = sessions.Count,
sessions
});
return response;
}
catch (Exception ex)
{
@@ -84,14 +84,12 @@ public class SearchSessionsFunction
var sessions = await _sessionRepo.SearchSessionsAsync(tenantId, filter);
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new
return await req.OkAsync(new
{
success = true,
count = sessions.Count,
sessions
});
return response;
}
catch (Exception ex)
{
@@ -1,7 +1,6 @@
using System.Net;
using System.Web;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Functions.Services.Vulnerability;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.DataAccess;
@@ -23,20 +22,17 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
private readonly ILogger<GetVulnerabilityReportFunction> _logger;
private readonly IVulnerabilityRepository _vulnRepo;
private readonly ISessionRepository _sessionRepo;
private readonly GlobalAdminService _globalAdminService;
private readonly VulnerabilityCorrelationService _correlationService;
public GetVulnerabilityReportFunction(
ILogger<GetVulnerabilityReportFunction> logger,
IVulnerabilityRepository vulnRepo,
ISessionRepository sessionRepo,
GlobalAdminService globalAdminService,
VulnerabilityCorrelationService correlationService)
{
_logger = logger;
_vulnRepo = vulnRepo;
_sessionRepo = sessionRepo;
_globalAdminService = globalAdminService;
_correlationService = correlationService;
}
@@ -46,25 +42,24 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
string sessionId)
{
// Authentication + MemberRead authorization enforced by PolicyEnforcementMiddleware
var userTenantId = TenantHelper.GetTenantId(req);
var userIdentifier = TenantHelper.GetUserIdentifier(req);
var requestCtx = req.GetRequestContext();
var userIdentifier = requestCtx.UserPrincipalName;
// Resolve effective tenant ID: use query param if provided, fall back to JWT tenant
var query = HttpUtility.ParseQueryString(req.Url.Query);
var requestedTenantId = query["tenantId"];
var effectiveTenantId = string.IsNullOrEmpty(requestedTenantId) ? userTenantId : requestedTenantId;
var effectiveTenantId = string.IsNullOrEmpty(requestedTenantId) ? requestCtx.TenantId : requestedTenantId;
// Cross-tenant access requires Global Admin
if (effectiveTenantId != userTenantId)
if (!requestCtx.IsGlobalAdmin && effectiveTenantId != requestCtx.TenantId)
{
var isGlobalAdmin = await _globalAdminService.IsGlobalAdminAsync(userIdentifier);
if (!isGlobalAdmin)
{
var forbidden = req.CreateResponse(HttpStatusCode.Forbidden);
await forbidden.WriteAsJsonAsync(new { success = false, message = "Access denied. You can only view vulnerability reports for your own tenant." });
return forbidden;
}
var forbidden = req.CreateResponse(HttpStatusCode.Forbidden);
await forbidden.WriteAsJsonAsync(new { success = false, message = "Access denied. You can only view vulnerability reports for your own tenant." });
return forbidden;
}
if (requestCtx.IsGlobalAdmin && effectiveTenantId != requestCtx.TenantId)
{
_logger.LogInformation("Global Admin {User} accessing cross-tenant vulnerability report (tenant: {TenantId})",
userIdentifier, effectiveTenantId);
}
@@ -0,0 +1,61 @@
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
namespace AutopilotMonitor.Functions.Helpers;
/// <summary>
/// Resolved request context populated by PolicyEnforcementMiddleware after authentication and policy evaluation.
/// Eliminates redundant IsGlobalAdminAsync / IsTenantAdminAsync service calls in function handlers.
/// Retrieved via <c>req.GetRequestContext()</c> or <c>context.GetRequestContext()</c>.
/// </summary>
public sealed record RequestContext
{
internal const string ItemsKey = "RequestContext";
/// <summary>The user's Azure AD tenant ID (from JWT tid claim).</summary>
public string TenantId { get; init; } = string.Empty;
/// <summary>The user's UPN (from JWT upn/preferred_username claim).</summary>
public string UserPrincipalName { get; init; } = string.Empty;
/// <summary>True if the user is a Global Admin of the platform.</summary>
public bool IsGlobalAdmin { get; init; }
/// <summary>True if the user is a Tenant Admin of their own tenant.</summary>
public bool IsTenantAdmin { get; init; }
/// <summary>The resolved role string (e.g. "GlobalAdmin", "Admin", "Operator", "Viewer").</summary>
public string UserRole { get; init; } = string.Empty;
}
/// <summary>
/// Extension methods for accessing the resolved RequestContext.
/// </summary>
public static class RequestContextExtensions
{
/// <summary>
/// Gets the resolved RequestContext from FunctionContext.Items.
/// Populated by PolicyEnforcementMiddleware for authenticated requests.
/// Returns an empty context (all defaults) for device/anonymous routes.
/// </summary>
public static RequestContext GetRequestContext(this FunctionContext context)
{
if (context.Items.TryGetValue(RequestContext.ItemsKey, out var ctx) && ctx is RequestContext requestCtx)
return requestCtx;
return new RequestContext();
}
/// <summary>
/// Gets the resolved RequestContext via the HTTP request's FunctionContext.
/// </summary>
public static RequestContext GetRequestContext(this HttpRequestData req)
=> req.FunctionContext.GetRequestContext();
/// <summary>Gets the correlation ID for this request (set by CorrelationIdMiddleware).</summary>
public static string GetCorrelationId(this FunctionContext context)
{
if (context.Items.TryGetValue("CorrelationId", out var id) && id is string correlationId)
return correlationId;
return string.Empty;
}
}
@@ -0,0 +1,108 @@
using System.Net;
using Microsoft.Extensions.Logging;
using Polly;
namespace AutopilotMonitor.Functions.Helpers;
/// <summary>
/// Singleton container for Polly resilience policies used by external HTTP clients.
/// Must be registered as a DI singleton so circuit breaker state is shared across all requests.
/// </summary>
public sealed class ResiliencePolicies
{
private static readonly HttpStatusCode[] TransientCodes =
[
HttpStatusCode.RequestTimeout,
HttpStatusCode.TooManyRequests,
HttpStatusCode.InternalServerError,
HttpStatusCode.BadGateway,
HttpStatusCode.ServiceUnavailable,
HttpStatusCode.GatewayTimeout
];
/// <summary>
/// Policy for external data APIs (GitHub, NVD, CISA, MSRC):
/// Retry 3× with exponential backoff + jitter, wrapped in an advanced circuit breaker
/// (≥50% failures in 30s → 60s open).
/// </summary>
public IAsyncPolicy<HttpResponseMessage> ExternalDataApi { get; }
/// <summary>
/// Policy for notification clients (webhook, Telegram):
/// 2 retries with 2s fixed delay. No circuit breaker — a single failing endpoint
/// should not block all notifications across tenants.
/// </summary>
public IAsyncPolicy<HttpResponseMessage> Notification { get; }
public ResiliencePolicies(ILoggerFactory loggerFactory)
{
var logger = loggerFactory.CreateLogger(nameof(ResiliencePolicies));
ExternalDataApi = BuildExternalDataApi(logger);
Notification = BuildNotification(logger);
}
private static IAsyncPolicy<HttpResponseMessage> BuildExternalDataApi(ILogger logger)
{
var retry = Policy<HttpResponseMessage>
.HandleResult(r => TransientCodes.Contains(r.StatusCode))
.Or<HttpRequestException>()
.Or<TaskCanceledException>()
.WaitAndRetryAsync(
retryCount: 3,
sleepDurationProvider: attempt =>
TimeSpan.FromSeconds(Math.Pow(2, attempt - 1))
+ TimeSpan.FromMilliseconds(Random.Shared.Next(0, 500)),
onRetry: (outcome, delay, attempt, _) =>
{
if (outcome.Exception is not null)
logger.LogWarning(outcome.Exception,
"External API request failed (attempt {Attempt}/3), retrying in {DelayMs}ms",
attempt, (int)delay.TotalMilliseconds);
else
logger.LogWarning(
"External API returned {StatusCode} (attempt {Attempt}/3), retrying in {DelayMs}ms",
outcome.Result?.StatusCode, attempt, (int)delay.TotalMilliseconds);
});
var circuitBreaker = Policy<HttpResponseMessage>
.HandleResult(r => (int)r.StatusCode >= 500)
.Or<HttpRequestException>()
.AdvancedCircuitBreakerAsync(
failureThreshold: 0.5,
samplingDuration: TimeSpan.FromSeconds(30),
minimumThroughput: 5,
durationOfBreak: TimeSpan.FromSeconds(60),
onBreak: (outcome, duration) =>
logger.LogWarning(
"Circuit breaker opened for {DurationSec}s — {Reason}",
(int)duration.TotalSeconds,
outcome.Exception?.Message ?? outcome.Result?.StatusCode.ToString()),
onReset: () => logger.LogInformation("Circuit breaker reset — external API is healthy"),
onHalfOpen: () => logger.LogInformation("Circuit breaker half-open — probing external API"));
// Retry (outer) wraps circuit breaker (inner):
// When the CB is open it throws BrokenCircuitException, which the retry policy
// does not handle, so retries stop immediately without exhausting the retry budget.
return Policy.WrapAsync(retry, circuitBreaker);
}
private static IAsyncPolicy<HttpResponseMessage> BuildNotification(ILogger logger)
=> Policy<HttpResponseMessage>
.HandleResult(r => TransientCodes.Contains(r.StatusCode))
.Or<HttpRequestException>()
.Or<TaskCanceledException>()
.WaitAndRetryAsync(
retryCount: 2,
sleepDurationProvider: _ => TimeSpan.FromSeconds(2),
onRetry: (outcome, _, attempt, _) =>
{
if (outcome.Exception is not null)
logger.LogWarning(outcome.Exception,
"Notification request failed (attempt {Attempt}/2), retrying",
attempt);
else
logger.LogWarning(
"Notification returned {StatusCode} (attempt {Attempt}/2), retrying",
outcome.Result?.StatusCode, attempt);
});
}
@@ -0,0 +1,75 @@
using System.Net;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Helpers;
/// <summary>
/// Extension methods on HttpRequestData for creating consistent HTTP responses.
/// Eliminates repeated CreateResponse + WriteAsJsonAsync boilerplate across function handlers.
/// </summary>
public static class ResponseHelper
{
/// <summary>200 OK with a JSON body.</summary>
public static async Task<HttpResponseData> OkAsync(this HttpRequestData req, object data)
{
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(data);
return response;
}
/// <summary>201 Created with a JSON body.</summary>
public static async Task<HttpResponseData> CreatedAsync(this HttpRequestData req, object data)
{
var response = req.CreateResponse(HttpStatusCode.Created);
await response.WriteAsJsonAsync(data);
return response;
}
/// <summary>400 Bad Request with <c>{ "error": message }</c>.</summary>
public static async Task<HttpResponseData> BadRequestAsync(this HttpRequestData req, string message)
{
var response = req.CreateResponse(HttpStatusCode.BadRequest);
await response.WriteAsJsonAsync(new { error = message });
return response;
}
/// <summary>401 Unauthorized with <c>{ "error": message }</c>.</summary>
public static async Task<HttpResponseData> UnauthorizedAsync(this HttpRequestData req, string message)
{
var response = req.CreateResponse(HttpStatusCode.Unauthorized);
await response.WriteAsJsonAsync(new { error = message });
return response;
}
/// <summary>403 Forbidden with <c>{ "error": message }</c>.</summary>
public static async Task<HttpResponseData> ForbiddenAsync(this HttpRequestData req, string message)
{
var response = req.CreateResponse(HttpStatusCode.Forbidden);
await response.WriteAsJsonAsync(new { error = message });
return response;
}
/// <summary>404 Not Found with <c>{ "error": message }</c>.</summary>
public static async Task<HttpResponseData> NotFoundAsync(this HttpRequestData req, string message)
{
var response = req.CreateResponse(HttpStatusCode.NotFound);
await response.WriteAsJsonAsync(new { error = message });
return response;
}
/// <summary>
/// 500 Internal Server Error. Logs the exception and returns <c>{ "error": "Internal server error" }</c>.
/// </summary>
public static async Task<HttpResponseData> InternalServerErrorAsync(
this HttpRequestData req,
ILogger logger,
Exception ex,
string logMessage = "Unhandled error")
{
logger.LogError(ex, logMessage);
var response = req.CreateResponse(HttpStatusCode.InternalServerError);
await response.WriteAsJsonAsync(new { error = "Internal server error" });
return response;
}
}
@@ -0,0 +1,58 @@
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Middleware;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Middleware;
/// <summary>
/// Middleware that ensures every request carries a Correlation ID.
/// Reads <c>X-Correlation-ID</c> from the incoming request; generates a new compact GUID if absent.
/// The ID is stored in <c>FunctionContext.Items["CorrelationId"]</c> (retrieve via
/// <c>context.GetCorrelationId()</c>), echoed back in the <c>X-Correlation-ID</c> response header,
/// and injected into the logging scope so all log entries for the request carry it automatically.
/// </summary>
public class CorrelationIdMiddleware : IFunctionsWorkerMiddleware
{
private const string HeaderName = "X-Correlation-ID";
private const string ItemsKey = "CorrelationId";
private readonly ILogger<CorrelationIdMiddleware> _logger;
public CorrelationIdMiddleware(ILogger<CorrelationIdMiddleware> logger)
{
_logger = logger;
}
public async Task Invoke(FunctionContext context, FunctionExecutionDelegate next)
{
var httpContext = context.GetHttpContext();
string correlationId;
if (httpContext != null
&& httpContext.Request.Headers.TryGetValue(HeaderName, out var existingId)
&& !string.IsNullOrEmpty(existingId))
{
correlationId = existingId.ToString();
}
else
{
correlationId = Guid.NewGuid().ToString("N");
}
context.Items[ItemsKey] = correlationId;
if (httpContext != null)
{
httpContext.Response.OnStarting(() =>
{
if (!httpContext.Response.Headers.ContainsKey(HeaderName))
httpContext.Response.Headers[HeaderName] = correlationId;
return Task.CompletedTask;
});
}
using (_logger.BeginScope(new Dictionary<string, object> { ["CorrelationId"] = correlationId }))
{
await next(context);
}
}
}
@@ -1,6 +1,7 @@
using System.Net;
using System.Security.Claims;
using AutopilotMonitor.Functions.Extensions;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared;
@@ -72,6 +73,17 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
if (decision.IsAllowed)
{
// Store resolved context so functions can read IsGlobalAdmin/IsTenantAdmin without re-querying services
var principal = context.GetUser();
context.Items[RequestContext.ItemsKey] = new RequestContext
{
TenantId = principal?.GetTenantId() ?? string.Empty,
UserPrincipalName = decision.UserIdentifier,
IsGlobalAdmin = decision.UserRole == "GlobalAdmin",
IsTenantAdmin = decision.UserRole == Constants.TenantRoles.Admin,
UserRole = decision.UserRole
};
_logger.LogDebug("[PolicyEnforcement] ALLOW {Method} {Path} policy={Policy} user={User} role={Role}",
httpMethod, requestPath, catalogEntry.Policy, decision.UserIdentifier, decision.UserRole);
await next(context);
@@ -4,6 +4,7 @@ using AutopilotMonitor.Functions.DataAccess;
using AutopilotMonitor.Functions.Functions.Config;
using AutopilotMonitor.Functions.Functions.Ingest;
using AutopilotMonitor.Functions.Functions.Sessions;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Middleware;
using AutopilotMonitor.Functions.Services;
using Microsoft.AspNetCore.Authentication.JwtBearer;
@@ -20,7 +21,8 @@ var builder = FunctionsApplication.CreateBuilder(args);
builder.ConfigureFunctionsWebApplication();
// Register middleware pipeline (Azure Functions .NET 8 isolated worker pattern)
// Order matters: API key auth → JWT authentication (401) → policy enforcement (403)
// Order matters: correlation ID (all requests) → API key auth → JWT authentication (401) → policy enforcement (403)
builder.UseMiddleware<CorrelationIdMiddleware>();
builder.UseMiddleware<ApiKeyMiddleware>();
builder.UseMiddleware<AuthenticationMiddleware>();
builder.UseMiddleware<PolicyEnforcementMiddleware>();
@@ -99,6 +101,7 @@ builder.Services.AddHttpContextAccessor();
// Register our services
builder.Services.AddMemoryCache();
builder.Services.AddHttpClient();
builder.Services.AddSingleton<ResiliencePolicies>();
builder.Services.AddSingleton<TableStorageService>();
builder.Services.AddHostedService<TableInitializerService>(); // Initialize all tables at startup
@@ -118,7 +121,8 @@ builder.Services.AddSingleton<HealthCheckService>();
builder.Services.AddSingleton<GatherRuleService>();
builder.Services.AddSingleton<AnalyzeRuleService>();
builder.Services.AddSingleton<ImeLogPatternService>();
builder.Services.AddHttpClient<GitHubRuleRepository>();
builder.Services.AddHttpClient<GitHubRuleRepository>()
.AddPolicyHandler((sp, _) => sp.GetRequiredService<ResiliencePolicies>().ExternalDataApi);
builder.Services.AddSingleton<MaintenanceService>();
builder.Services.AddSingleton<BlockedDeviceService>();
builder.Services.AddSingleton<BlockedVersionService>();
@@ -129,9 +133,12 @@ builder.Services.AddSingleton<BootstrapSessionService>();
builder.Services.AddSingleton<SignalRNotificationService>();
// Vulnerability correlation services
builder.Services.AddHttpClient<AutopilotMonitor.Functions.Services.Vulnerability.NvdApiClient>();
builder.Services.AddHttpClient<AutopilotMonitor.Functions.Services.Vulnerability.KevDataService>();
builder.Services.AddHttpClient<AutopilotMonitor.Functions.Services.Vulnerability.MsrcApiClient>();
builder.Services.AddHttpClient<AutopilotMonitor.Functions.Services.Vulnerability.NvdApiClient>()
.AddPolicyHandler((sp, _) => sp.GetRequiredService<ResiliencePolicies>().ExternalDataApi);
builder.Services.AddHttpClient<AutopilotMonitor.Functions.Services.Vulnerability.KevDataService>()
.AddPolicyHandler((sp, _) => sp.GetRequiredService<ResiliencePolicies>().ExternalDataApi);
builder.Services.AddHttpClient<AutopilotMonitor.Functions.Services.Vulnerability.MsrcApiClient>()
.AddPolicyHandler((sp, _) => sp.GetRequiredService<ResiliencePolicies>().ExternalDataApi);
builder.Services.AddSingleton<AutopilotMonitor.Functions.Services.Vulnerability.VulnerabilityCorrelationService>();
// Register agent Function classes so bootstrap wrappers can inject them for code reuse
@@ -142,8 +149,10 @@ builder.Services.AddSingleton<ReportAgentErrorFunction>();
builder.Services.AddSingleton<AutopilotMonitor.Functions.Security.GraphTokenService>();
builder.Services.AddSingleton<AutopilotMonitor.Functions.Security.AutopilotDeviceValidator>();
builder.Services.AddSingleton<AutopilotMonitor.Functions.Security.CorporateIdentifierValidator>();
builder.Services.AddHttpClient<AutopilotMonitor.Functions.Services.Notifications.WebhookNotificationService>();
builder.Services.AddHttpClient<TelegramNotificationService>();
builder.Services.AddHttpClient<AutopilotMonitor.Functions.Services.Notifications.WebhookNotificationService>()
.AddPolicyHandler((sp, _) => sp.GetRequiredService<ResiliencePolicies>().Notification);
builder.Services.AddHttpClient<TelegramNotificationService>()
.AddPolicyHandler((sp, _) => sp.GetRequiredService<ResiliencePolicies>().Notification);
builder.Services.AddSingleton<ResendEmailService>();
builder.Services.AddSingleton<GlobalNotificationService>();
@@ -12,7 +12,7 @@ namespace AutopilotMonitor.Functions.Services
/// <summary>
/// Fetches rule definitions from GitHub (raw.githubusercontent.com).
/// Uses the CI-generated combined files from rules/dist/.
/// Includes retry logic with exponential backoff for transient failures.
/// Retry and circuit breaker resilience is handled by the Polly policy on the injected HttpClient.
/// </summary>
public class GitHubRuleRepository
{
@@ -20,13 +20,6 @@ namespace AutopilotMonitor.Functions.Services
private readonly ILogger<GitHubRuleRepository> _logger;
private readonly string _baseUrl;
private static readonly TimeSpan[] RetryDelays = new[]
{
TimeSpan.FromSeconds(2),
TimeSpan.FromSeconds(5),
TimeSpan.FromSeconds(10)
};
public GitHubRuleRepository(HttpClient httpClient, IConfiguration config, ILogger<GitHubRuleRepository> logger)
{
_httpClient = httpClient;
@@ -39,7 +32,7 @@ namespace AutopilotMonitor.Functions.Services
{
var url = $"{_baseUrl}/dist/gather-rules.json";
_logger.LogInformation("Fetching gather rules from {Url}", url);
var json = await FetchWithRetryAsync(url);
var json = await FetchAsync(url);
var wrapper = JsonConvert.DeserializeObject<RulesWrapper<GatherRule>>(json);
var rules = wrapper?.Rules ?? new List<GatherRule>();
_logger.LogInformation("Fetched {Count} gather rules from GitHub", rules.Count);
@@ -50,7 +43,7 @@ namespace AutopilotMonitor.Functions.Services
{
var url = $"{_baseUrl}/dist/analyze-rules.json";
_logger.LogInformation("Fetching analyze rules from {Url}", url);
var json = await FetchWithRetryAsync(url);
var json = await FetchAsync(url);
var wrapper = JsonConvert.DeserializeObject<RulesWrapper<AnalyzeRule>>(json);
var rules = wrapper?.Rules ?? new List<AnalyzeRule>();
_logger.LogInformation("Fetched {Count} analyze rules from GitHub", rules.Count);
@@ -61,7 +54,7 @@ namespace AutopilotMonitor.Functions.Services
{
var url = $"{_baseUrl}/dist/ime-log-patterns.json";
_logger.LogInformation("Fetching IME log patterns from {Url}", url);
var json = await FetchWithRetryAsync(url);
var json = await FetchAsync(url);
var wrapper = JsonConvert.DeserializeObject<RulesWrapper<ImeLogPattern>>(json);
var patterns = wrapper?.Rules ?? new List<ImeLogPattern>();
_logger.LogInformation("Fetched {Count} IME log patterns from GitHub", patterns.Count);
@@ -72,7 +65,7 @@ namespace AutopilotMonitor.Functions.Services
{
var url = $"{_baseUrl}/dist/cpe-community-mappings.json";
_logger.LogInformation("Fetching community CPE mappings from {Url}", url);
var json = await FetchWithRetryAsync(url);
var json = await FetchAsync(url);
_logger.LogInformation("Fetched community CPE mappings JSON from GitHub ({Length} bytes)", json.Length);
return json;
}
@@ -81,69 +74,16 @@ namespace AutopilotMonitor.Functions.Services
{
var url = $"{_baseUrl}/dist/cpe-mapping-seed.json";
_logger.LogInformation("Fetching CPE seed mappings from {Url}", url);
var json = await FetchWithRetryAsync(url);
var json = await FetchAsync(url);
_logger.LogInformation("Fetched CPE seed mappings JSON from GitHub ({Length} bytes)", json.Length);
return json;
}
/// <summary>
/// Fetches a URL with retry logic and exponential backoff.
/// Retries on transient HTTP errors (408, 429, 5xx) and network exceptions.
/// </summary>
private async Task<string> FetchWithRetryAsync(string url)
private async Task<string> FetchAsync(string url)
{
for (int attempt = 0; attempt <= RetryDelays.Length; attempt++)
{
try
{
var response = await _httpClient.GetAsync(url);
if (response.IsSuccessStatusCode)
{
return await response.Content.ReadAsStringAsync();
}
var statusCode = (int)response.StatusCode;
var isTransient = statusCode == 408 || statusCode == 429
|| statusCode >= 500 && statusCode <= 599;
if (!isTransient || attempt >= RetryDelays.Length)
{
// Non-transient error or retries exhausted — throw to caller
var body = await response.Content.ReadAsStringAsync();
_logger.LogWarning(
"GitHub fetch failed for {Url} after {Attempts} attempt(s). Status: {StatusCode}. Body: {Body}",
url, attempt + 1, statusCode, body);
response.EnsureSuccessStatusCode(); // Throws HttpRequestException
}
var delay = RetryDelays[attempt];
// Respect Retry-After header (GitHub rate limiting)
if (response.Headers.RetryAfter?.Delta is TimeSpan retryAfterDelta && retryAfterDelta > delay)
{
delay = retryAfterDelta;
}
_logger.LogWarning(
"GitHub fetch for {Url} returned {StatusCode} (attempt {Attempt}/{MaxAttempts}). Retrying in {Delay}s",
url, statusCode, attempt + 1, RetryDelays.Length + 1, delay.TotalSeconds);
await Task.Delay(delay);
}
catch (Exception ex) when (attempt < RetryDelays.Length && (ex is HttpRequestException or TaskCanceledException))
{
var delay = RetryDelays[attempt];
_logger.LogWarning(ex,
"GitHub fetch for {Url} failed with network error (attempt {Attempt}/{MaxAttempts}). Retrying in {Delay}s",
url, attempt + 1, RetryDelays.Length + 1, delay.TotalSeconds);
await Task.Delay(delay);
}
}
// Should not reach here, but just in case
throw new HttpRequestException($"Failed to fetch {url} after {RetryDelays.Length + 1} attempts");
var response = await _httpClient.GetAsync(url);
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStringAsync();
}
}
}
@@ -48,10 +48,15 @@ namespace AutopilotMonitor.Functions.Services.Notifications
var json = renderer.RenderToJson(alert);
var content = new StringContent(json, Encoding.UTF8, "application/json");
var sent = await PostWithRetryAsync(webhookUrl, content, alert.Summary ?? "notification");
var response = await _http.PostAsync(webhookUrl, content);
if (sent)
if (response.IsSuccessStatusCode)
_logger.LogInformation("Webhook notification sent: {Summary} (provider={ProviderType})", alert.Summary, providerType);
else
{
var body = await response.Content.ReadAsStringAsync();
_logger.LogWarning("Webhook returned {StatusCode} for {Summary}: {Body}", (int)response.StatusCode, alert.Summary, body);
}
}
catch (Exception ex)
{
@@ -99,46 +104,6 @@ namespace AutopilotMonitor.Functions.Services.Notifications
}
}
/// <summary>
/// Posts content to a webhook URL with a single retry on transient failures (429, 5xx, network errors).
/// </summary>
private async Task<bool> PostWithRetryAsync(string webhookUrl, StringContent content, string context)
{
const int maxAttempts = 2;
for (int attempt = 1; attempt <= maxAttempts; attempt++)
{
try
{
var response = await _http.PostAsync(webhookUrl, content);
if (response.IsSuccessStatusCode)
return true;
var statusCode = (int)response.StatusCode;
var isTransient = statusCode == 429 || statusCode >= 500 && statusCode <= 599;
if (!isTransient || attempt >= maxAttempts)
{
var body = await response.Content.ReadAsStringAsync();
_logger.LogWarning("Webhook returned {StatusCode} for {Context}: {Body}", statusCode, context, body);
return false;
}
_logger.LogWarning(
"Webhook returned {StatusCode} for {Context} (attempt {Attempt}/{MaxAttempts}). Retrying in 2s",
statusCode, context, attempt, maxAttempts);
await Task.Delay(TimeSpan.FromSeconds(2));
}
catch (Exception ex) when (attempt < maxAttempts && (ex is HttpRequestException or TaskCanceledException))
{
_logger.LogWarning(ex,
"Webhook network error for {Context} (attempt {Attempt}/{MaxAttempts}). Retrying in 2s",
context, attempt, maxAttempts);
await Task.Delay(TimeSpan.FromSeconds(2));
}
}
return false;
}
}
public class WebhookTestResult
@@ -52,14 +52,12 @@ namespace AutopilotMonitor.Functions.Services
var json = JsonConvert.SerializeObject(payload);
var content = new StringContent(json, Encoding.UTF8, "application/json");
var sent = await PostWithRetryAsync(webhookUrl, content, $"signup:{tenantId}");
var response = await _http.PostAsync(webhookUrl, content);
if (sent)
{
if (response.IsSuccessStatusCode)
_logger.LogInformation(
"Telegram Private Preview signup notification sent for tenant {TenantId}, UPN {Upn}",
tenantId, upn);
}
}
catch (Exception ex)
{
@@ -93,13 +91,11 @@ namespace AutopilotMonitor.Functions.Services
var json = JsonConvert.SerializeObject(payload);
var content = new StringContent(json, Encoding.UTF8, "application/json");
var sent = await PostWithRetryAsync(webhookUrl, content, $"report:{reportId}");
var response = await _http.PostAsync(webhookUrl, content);
if (sent)
{
if (response.IsSuccessStatusCode)
_logger.LogInformation(
"Telegram session report notification sent for report {ReportId}", reportId);
}
}
catch (Exception ex)
{
@@ -173,12 +169,10 @@ namespace AutopilotMonitor.Functions.Services
var json = JsonConvert.SerializeObject(payload);
var content = new StringContent(json, Encoding.UTF8, "application/json");
var sent = await PostWithRetryAsync(webhookUrl, content, $"feedback:{upn}");
var response = await _http.PostAsync(webhookUrl, content);
if (sent)
{
if (response.IsSuccessStatusCode)
_logger.LogInformation("Telegram feedback notification sent for user {Upn}", upn);
}
}
catch (Exception ex)
{
@@ -2,7 +2,7 @@
import { createContext, useCallback, useContext, useEffect, useState } from "react";
import { useAuth } from "../../contexts/AuthContext";
import { API_BASE_URL } from "@/lib/config";
import { api } from "@/lib/api";
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
// Re-export for section components
@@ -119,7 +119,7 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
setLoadingConfig(true);
setError(null);
const response = await authenticatedFetch(`${API_BASE_URL}/api/global/config`, getAccessToken);
const response = await authenticatedFetch(api.globalConfig.get(), getAccessToken);
if (!response.ok) {
throw new Error(`Failed to load admin configuration: ${response.statusText}`);
@@ -159,8 +159,8 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
setLoadingTenants(true);
const [tenantsRes, previewRes] = await Promise.all([
authenticatedFetch(`${API_BASE_URL}/api/config/all`, getAccessToken),
authenticatedFetch(`${API_BASE_URL}/api/preview/whitelist`, getAccessToken)
authenticatedFetch(api.config.all(), getAccessToken),
authenticatedFetch(api.preview.whitelist(), getAccessToken)
]);
if (!tenantsRes.ok) {
@@ -211,7 +211,7 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
maintenanceBlockDurationHours,
};
const response = await authenticatedFetch(`${API_BASE_URL}/api/global/config`, getAccessToken, {
const response = await authenticatedFetch(api.globalConfig.get(), getAccessToken, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(updatedConfig),
@@ -267,7 +267,7 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
diagnosticsGlobalLogPathsJson: JSON.stringify(paths),
};
const response = await authenticatedFetch(`${API_BASE_URL}/api/global/config`, getAccessToken, {
const response = await authenticatedFetch(api.globalConfig.get(), getAccessToken, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(updatedConfig),
@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import { API_BASE_URL } from "@/lib/config";
import { api } from "@/lib/api";
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
interface ConfigReseedSectionProps {
@@ -26,7 +26,7 @@ export function ConfigReseedSection({
setError(null);
setSuccessMessage(null);
const response = await authenticatedFetch(`${API_BASE_URL}/api/rules/reseed-from-github?type=analyze`, getAccessToken, {
const response = await authenticatedFetch(api.rules.reseedFromGitHub("analyze"), getAccessToken, {
method: "POST",
});
@@ -56,7 +56,7 @@ export function ConfigReseedSection({
setError(null);
setSuccessMessage(null);
const response = await authenticatedFetch(`${API_BASE_URL}/api/rules/reseed-from-github?type=gather`, getAccessToken, {
const response = await authenticatedFetch(api.rules.reseedFromGitHub("gather"), getAccessToken, {
method: "POST",
});
@@ -86,7 +86,7 @@ export function ConfigReseedSection({
setError(null);
setSuccessMessage(null);
const response = await authenticatedFetch(`${API_BASE_URL}/api/rules/reseed-from-github?type=ime`, getAccessToken, {
const response = await authenticatedFetch(api.rules.reseedFromGitHub("ime"), getAccessToken, {
method: "POST",
});
@@ -116,7 +116,7 @@ export function ConfigReseedSection({
setError(null);
setSuccessMessage(null);
const response = await authenticatedFetch(`${API_BASE_URL}/api/rules/reseed-from-github?type=all`, getAccessToken, {
const response = await authenticatedFetch(api.rules.reseedFromGitHub("all"), getAccessToken, {
method: "POST",
});
@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import { API_BASE_URL } from "@/lib/config";
import { api } from "@/lib/api";
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
import { trackEvent } from "@/lib/appInsights";
import { TenantConfiguration } from "./TenantManagementSection";
@@ -62,7 +62,7 @@ export function DeviceBlockSection({
if (!tenantId) return;
try {
setLoadingBlockedDevices(true);
const response = await authenticatedFetch(`${API_BASE_URL}/api/devices/blocked?tenantId=${encodeURIComponent(tenantId)}`, getAccessToken);
const response = await authenticatedFetch(api.devices.blocked(tenantId), getAccessToken);
if (!response.ok) throw new Error(`Failed to load blocked devices: ${response.statusText}`);
const data = await response.json();
setBlockedDevices(data.blocked ?? []);
@@ -79,7 +79,7 @@ export function DeviceBlockSection({
const fetchAllBlockedDevices = async () => {
try {
setLoadingAllBlocked(true);
const response = await authenticatedFetch(`${API_BASE_URL}/api/global/devices/blocked`, getAccessToken);
const response = await authenticatedFetch(api.devices.allBlocked(), getAccessToken);
if (!response.ok) throw new Error(`Failed to load blocked devices: ${response.statusText}`);
const data = await response.json();
setBlockedDevices(data.blocked ?? []);
@@ -104,7 +104,7 @@ export function DeviceBlockSection({
try {
setBlockingDevice(true);
setError(null);
const response = await authenticatedFetch(`${API_BASE_URL}/api/devices/block`, getAccessToken, {
const response = await authenticatedFetch(api.devices.block(), getAccessToken, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -143,7 +143,7 @@ export function DeviceBlockSection({
setUnblockingDevice(serialNumber);
setError(null);
const response = await authenticatedFetch(
`${API_BASE_URL}/api/devices/block/${encodeURIComponent(serialNumber)}?tenantId=${encodeURIComponent(tenantId)}`,
api.devices.unblock(serialNumber, tenantId),
getAccessToken,
{ method: "DELETE" }
);
@@ -172,7 +172,7 @@ export function DeviceBlockSection({
setKillingDevice(device.serialNumber);
setError(null);
const remainingHours = Math.max(1, Math.ceil((new Date(device.unblockAt).getTime() - Date.now()) / 3600000));
const response = await authenticatedFetch(`${API_BASE_URL}/api/devices/block`, getAccessToken, {
const response = await authenticatedFetch(api.devices.block(), getAccessToken, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -1,7 +1,7 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { API_BASE_URL } from "@/lib/config";
import { api } from "@/lib/api";
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
interface FeedbackEntry {
@@ -31,7 +31,7 @@ export function FeedbackSection({ getAccessToken, setError }: FeedbackSectionPro
setLoading(true);
setError(null);
const response = await authenticatedFetch(`${API_BASE_URL}/api/feedback/all`, getAccessToken);
const response = await authenticatedFetch(api.feedback.all(), getAccessToken);
if (!response.ok) {
throw new Error(`Failed to load feedback: ${response.statusText}`);
@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import { API_BASE_URL } from "@/lib/config";
import { api } from "@/lib/api";
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
interface MaintenanceSectionProps {
@@ -28,8 +28,7 @@ export function MaintenanceSection({
setError(null);
setSuccessMessage(null);
const queryParams = maintenanceDate ? `?date=${maintenanceDate}` : '';
const response = await authenticatedFetch(`${API_BASE_URL}/api/maintenance/trigger${queryParams}`, getAccessToken, {
const response = await authenticatedFetch(api.maintenance.trigger(maintenanceDate || undefined), getAccessToken, {
method: "POST",
});
@@ -60,7 +59,7 @@ export function MaintenanceSection({
setError(null);
setSuccessMessage(null);
const response = await authenticatedFetch(`${API_BASE_URL}/api/rules/reseed-from-github?type=analyze`, getAccessToken, {
const response = await authenticatedFetch(api.rules.reseedFromGitHub("analyze"), getAccessToken, {
method: "POST",
});
@@ -90,7 +89,7 @@ export function MaintenanceSection({
setError(null);
setSuccessMessage(null);
const response = await authenticatedFetch(`${API_BASE_URL}/api/rules/reseed-from-github?type=gather`, getAccessToken, {
const response = await authenticatedFetch(api.rules.reseedFromGitHub("gather"), getAccessToken, {
method: "POST",
});
@@ -120,7 +119,7 @@ export function MaintenanceSection({
setError(null);
setSuccessMessage(null);
const response = await authenticatedFetch(`${API_BASE_URL}/api/rules/reseed-from-github?type=ime`, getAccessToken, {
const response = await authenticatedFetch(api.rules.reseedFromGitHub("ime"), getAccessToken, {
method: "POST",
});
@@ -150,7 +149,7 @@ export function MaintenanceSection({
setError(null);
setSuccessMessage(null);
const response = await authenticatedFetch(`${API_BASE_URL}/api/rules/reseed-from-github?type=all`, getAccessToken, {
const response = await authenticatedFetch(api.rules.reseedFromGitHub("all"), getAccessToken, {
method: "POST",
});
@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import { API_BASE_URL } from "@/lib/config";
import { api } from "@/lib/api";
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
interface MaintenanceTriggerSectionProps {
@@ -24,8 +24,7 @@ export function MaintenanceTriggerSection({
setError(null);
setSuccessMessage(null);
const queryParams = maintenanceDate ? `?date=${maintenanceDate}` : '';
const response = await authenticatedFetch(`${API_BASE_URL}/api/maintenance/trigger${queryParams}`, getAccessToken, {
const response = await authenticatedFetch(api.maintenance.trigger(maintenanceDate || undefined), getAccessToken, {
method: "POST",
});
@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import { API_BASE_URL } from "@/lib/config";
import { api } from "@/lib/api";
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
import { TenantConfiguration } from "./TenantManagementSection";
import { TenantSearchSelect } from "./TenantSearchSelect";
@@ -47,7 +47,7 @@ export function SessionExportSection({
setExportError(null);
setExportedEvents(null);
const res = await authenticatedFetch(
`${API_BASE_URL}/api/sessions/${sid}/events?tenantId=${encodeURIComponent(tid)}`,
api.sessions.events(sid, tid),
getAccessToken
);
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
@@ -1,7 +1,7 @@
"use client";
import { useState, useEffect } from "react";
import { API_BASE_URL } from "@/lib/config";
import { api } from "@/lib/api";
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
import { trackEvent } from "@/lib/appInsights";
@@ -81,7 +81,7 @@ export function SessionReportsSection({
setDownloadingBlob(blobName);
const res = await authenticatedFetch(
`${API_BASE_URL}/api/global/session-reports/download-url?blobName=${encodeURIComponent(blobName)}`,
api.reports.downloadUrl(blobName),
getAccessToken
);
if (!res.ok) throw new Error(`Failed to get download URL: ${res.statusText}`);
@@ -110,7 +110,7 @@ export function SessionReportsSection({
setNoteSaveResult(null);
const res = await authenticatedFetch(
`${API_BASE_URL}/api/global/session-reports/${selectedReport.reportId}/note`,
api.reports.note(selectedReport.reportId),
getAccessToken,
{
method: "PATCH",
@@ -139,7 +139,7 @@ export function SessionReportsSection({
try {
setLoading(true);
const res = await authenticatedFetch(`${API_BASE_URL}/api/global/session-reports`, getAccessToken);
const res = await authenticatedFetch(api.reports.list(), getAccessToken);
if (res.status === 404) {
// Table/container doesn't exist yet — no reports submitted so far
@@ -1,7 +1,7 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { API_BASE_URL } from "@/lib/config";
import { api } from "@/lib/api";
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
import { trackEvent } from "@/lib/appInsights";
@@ -82,7 +82,7 @@ export function SoftwareMappingSection({
setError(null);
const response = await authenticatedFetch(
`${API_BASE_URL}/api/vulnerability/unmatched-software`,
api.vulnerability.unmatchedSoftware(),
getAccessToken
);
@@ -117,7 +117,7 @@ export function SoftwareMappingSection({
setError(null);
const response = await authenticatedFetch(
`${API_BASE_URL}/api/vulnerability/cpe-mappings`,
api.vulnerability.cpeMappings(),
getAccessToken
);
@@ -207,7 +207,7 @@ export function SoftwareMappingSection({
setError(null);
const response = await authenticatedFetch(
`${API_BASE_URL}/api/vulnerability/cpe-mapping`,
api.vulnerability.cpeMapping(),
getAccessToken,
{
method: "POST",
@@ -316,7 +316,7 @@ export function SoftwareMappingSection({
.filter(Boolean);
const response = await authenticatedFetch(
`${API_BASE_URL}/api/vulnerability/cpe-mapping`,
api.vulnerability.cpeMapping(),
getAccessToken,
{
method: "POST",
@@ -363,7 +363,7 @@ export function SoftwareMappingSection({
setError(null);
const response = await authenticatedFetch(
`${API_BASE_URL}/api/vulnerability/cpe-mapping`,
api.vulnerability.cpeMapping(),
getAccessToken,
{
method: "DELETE",
@@ -1,7 +1,7 @@
"use client";
import { useEffect, useState } from "react";
import { API_BASE_URL } from "@/lib/config";
import { api } from "@/lib/api";
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
interface TenantAdmin {
@@ -43,7 +43,7 @@ export function TenantAdminSection({
setLoadingAdmins(true);
setCurrentAdminPage(0); // Reset to first page when loading new tenant
const response = await authenticatedFetch(`${API_BASE_URL}/api/tenants/${tid}/admins`, getAccessToken);
const response = await authenticatedFetch(api.tenants.admins(tid), getAccessToken);
if (!response.ok) {
throw new Error(`Failed to load admins: ${response.statusText}`);
@@ -75,7 +75,7 @@ export function TenantAdminSection({
setAddingAdmin(true);
setError(null);
const response = await authenticatedFetch(`${API_BASE_URL}/api/tenants/${tenantId}/admins`, getAccessToken, {
const response = await authenticatedFetch(api.tenants.admins(tenantId), getAccessToken, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ upn: newAdminEmail.trim() }),
@@ -115,7 +115,7 @@ export function TenantAdminSection({
setRemovingAdmin(adminUpn);
setError(null);
const response = await authenticatedFetch(`${API_BASE_URL}/api/tenants/${tenantId}/admins/${encodeURIComponent(adminUpn)}`, getAccessToken, {
const response = await authenticatedFetch(api.tenants.admin(tenantId, adminUpn), getAccessToken, {
method: "DELETE",
});
@@ -149,7 +149,7 @@ export function TenantAdminSection({
setError(null);
const action = isEnabled ? 'disable' : 'enable';
const response = await authenticatedFetch(`${API_BASE_URL}/api/tenants/${tenantId}/admins/${encodeURIComponent(adminUpn)}/${action}`, getAccessToken, {
const response = await authenticatedFetch(api.tenants.adminAction(tenantId, adminUpn, action), getAccessToken, {
method: "PATCH",
});
@@ -1,7 +1,7 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { API_BASE_URL } from "@/lib/config";
import { api } from "@/lib/api";
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
import { TenantAdminSection } from "./TenantAdminSection";
@@ -93,7 +93,7 @@ export function TenantManagementSection({
setError(null);
setSuccessMessage(null);
const response = await authenticatedFetch(`${API_BASE_URL}/api/config/${tenant.tenantId}`, getAccessToken, {
const response = await authenticatedFetch(api.config.tenant(tenant.tenantId), getAccessToken, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(tenant),
@@ -131,7 +131,7 @@ export function TenantManagementSection({
setSuccessMessage(null);
const response = await authenticatedFetch(
`${API_BASE_URL}/api/preview/send-welcome-email/${tenantId}`,
api.preview.sendWelcomeEmail(tenantId),
getAccessToken,
{
method: "POST",
@@ -166,7 +166,7 @@ export function TenantManagementSection({
const isCurrentlyApproved = previewApproved.has(tenantId);
const response = await authenticatedFetch(`${API_BASE_URL}/api/preview/whitelist/${tenantId}`, getAccessToken, {
const response = await authenticatedFetch(api.preview.whitelistTenant(tenantId), getAccessToken, {
method: isCurrentlyApproved ? "DELETE" : "POST",
});
@@ -373,7 +373,7 @@ export function TenantManagementSection({
setNotificationEmail("");
try {
const resp = await authenticatedFetch(
`${API_BASE_URL}/api/preview/notification-email/${tenant.tenantId}`,
api.preview.notificationEmailTenant(tenant.tenantId),
getAccessToken
);
if (resp.ok) {
@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import { API_BASE_URL } from "@/lib/config";
import { api } from "@/lib/api";
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
interface BlockedVersion {
@@ -34,7 +34,7 @@ export function VersionBlockSection({
const fetchBlockedVersions = async () => {
try {
setLoadingVersions(true);
const response = await authenticatedFetch(`${API_BASE_URL}/api/versions/blocked`, getAccessToken);
const response = await authenticatedFetch(api.versions.blocked(), getAccessToken);
if (!response.ok) throw new Error(`Failed to load version blocks: ${response.statusText}`);
const data = await response.json();
setBlockedVersions(data.rules ?? []);
@@ -58,7 +58,7 @@ export function VersionBlockSection({
try {
setAddingRule(true);
setError(null);
const response = await authenticatedFetch(`${API_BASE_URL}/api/versions/block`, getAccessToken, {
const response = await authenticatedFetch(api.versions.block(), getAccessToken, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -94,7 +94,7 @@ export function VersionBlockSection({
setRemovingPattern(pattern);
setError(null);
const response = await authenticatedFetch(
`${API_BASE_URL}/api/versions/block/${encodeURIComponent(pattern)}`,
api.versions.unblock(pattern),
getAccessToken,
{ method: "DELETE" }
);
@@ -1,7 +1,7 @@
"use client";
import { useCallback, useState } from "react";
import { API_BASE_URL } from "@/lib/config";
import { api } from "@/lib/api";
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
import SaveResetBar from "../../settings/components/SaveResetBar";
@@ -85,7 +85,7 @@ export function VulnerabilityDataSection({
vulnerabilityCorrelationEnabled,
};
const response = await authenticatedFetch(`${API_BASE_URL}/api/global/config`, getAccessToken, {
const response = await authenticatedFetch(api.globalConfig.get(), getAccessToken, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(updatedConfig),
@@ -124,7 +124,7 @@ export function VulnerabilityDataSection({
try {
const response = await authenticatedFetch(
`${API_BASE_URL}/api/vulnerability/sync`,
api.vulnerability.sync(),
getAccessToken,
{ method: "POST" }
);
@@ -143,7 +143,7 @@ export function VulnerabilityDataSection({
// Refresh admin config to get updated timestamp
try {
const configResponse = await authenticatedFetch(`${API_BASE_URL}/api/global/config`, getAccessToken);
const configResponse = await authenticatedFetch(api.globalConfig.get(), getAccessToken);
if (configResponse.ok) {
const updatedConfig = await configResponse.json();
onAdminConfigUpdated(updatedConfig);
@@ -173,7 +173,7 @@ export function VulnerabilityDataSection({
try {
const response = await authenticatedFetch(
`${API_BASE_URL}/api/vulnerability/sync?reseed=${type}`,
api.vulnerability.syncReseed(type),
getAccessToken,
{ method: "POST" }
);
@@ -197,7 +197,7 @@ export function VulnerabilityDataSection({
// Refresh admin config to get updated timestamp
try {
const configResponse = await authenticatedFetch(`${API_BASE_URL}/api/global/config`, getAccessToken);
const configResponse = await authenticatedFetch(api.globalConfig.get(), getAccessToken);
if (configResponse.ok) {
const updatedConfig = await configResponse.json();
onAdminConfigUpdated(updatedConfig);
@@ -2,7 +2,7 @@
import { useEffect, useState, useMemo, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { API_BASE_URL } from '@/lib/config';
import { api } from '@/lib/api';
import { useAuth } from '../../../../contexts/AuthContext';
import { useNotifications } from '../../../../contexts/NotificationContext';
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
@@ -111,7 +111,7 @@ export function SectionAgentMetrics() {
setError(null);
try {
const res = await authenticatedFetch(`${API_BASE_URL}/api/global/metrics/platform`, getAccessToken);
const res = await authenticatedFetch(api.metrics.platform(), getAccessToken);
if (!res.ok) {
if (res.status === 403) {
@@ -2,7 +2,7 @@
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { API_BASE_URL } from '@/lib/config';
import { api } from '@/lib/api';
import { useAuth } from '../../../../contexts/AuthContext';
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
@@ -99,7 +99,7 @@ export function SectionPlatformUsage() {
setError(null);
// Platform-wide metrics - cross-tenant (Global Admin only)
const response = await authenticatedFetch(`${API_BASE_URL}/api/global/metrics/usage`, getAccessToken);
const response = await authenticatedFetch(api.metrics.globalUsage(), getAccessToken);
if (!response.ok) {
throw new Error(`Failed to fetch platform usage metrics: ${response.statusText}`);
@@ -2,7 +2,7 @@
import { useEffect, useState, useCallback } from 'react';
import { useAuth } from '../../../../contexts/AuthContext';
import { API_BASE_URL } from '@/lib/config';
import { api } from '@/lib/api';
import { authenticatedFetch, TokenExpiredError } from '@/lib/authenticatedFetch';
// ── Types ────────────────────────────────────────────────────────────────────
@@ -330,7 +330,7 @@ export function SectionTenantConfigReport() {
const fetchTenants = async () => {
try {
setLoadingTenants(true);
const response = await authenticatedFetch(`${API_BASE_URL}/api/config/all`, getAccessToken);
const response = await authenticatedFetch(api.config.all(), getAccessToken);
if (response.ok) {
const data = await response.json();
const mapped: TenantInfo[] = data.map((t: { tenantId: string; domainName: string }) => ({
@@ -361,7 +361,7 @@ export function SectionTenantConfigReport() {
setLoading(true);
setError(null);
const response = await authenticatedFetch(
`${API_BASE_URL}/api/config/${selectedTenantId}`,
api.config.tenant(selectedTenantId),
getAccessToken,
);
if (!response.ok) {
@@ -6,7 +6,7 @@ import { useRouter } from "next/navigation";
import { ProtectedRoute } from "../../components/ProtectedRoute";
import { useTenant } from "../../contexts/TenantContext";
import { useAuth } from "../../contexts/AuthContext";
import { API_BASE_URL } from "@/lib/config";
import { api } from "@/lib/api";
import { authenticatedFetch } from "@/lib/authenticatedFetch";
import { trackEvent } from "@/lib/appInsights";
import { downloadAsJson, stripInternalFields, bumpVersion } from "@/lib/rulePageHelpers";
@@ -15,6 +15,7 @@ import { RuleFilterBar } from "@/components/rules/RuleFilterBar";
import { EmptyState } from "@/components/rules/EmptyState";
import { FormJsonToggle, JsonModeToggleButtons } from "@/components/rules/FormJsonToggle";
import { useAuthenticatedFetch, useNotificationMessages } from "@/hooks";
import { useAdminMode } from "@/hooks/useAdminMode";
import { AnalyzeRule, RuleForm, EMPTY_FORM, EMPTY_CONDITION, ruleToForm } from "./types";
import AnalyzeRuleFormFields from "./components/AnalyzeRuleFormFields";
@@ -71,12 +72,7 @@ export default function AnalyzeRulesPage() {
const [deletingRuleId, setDeletingRuleId] = useState<string | null>(null);
// Global admin mode
const [globalAdminMode] = useState(() => {
if (typeof window !== 'undefined') {
return localStorage.getItem('globalAdminMode') === 'true';
}
return false;
});
const { globalAdminMode } = useAdminMode();
const [tenants, setTenants] = useState<TenantInfo[]>([]);
const [selectedTenantId, setSelectedTenantId] = useState<string>('');
@@ -84,7 +80,7 @@ export default function AnalyzeRulesPage() {
if (!globalAdminMode || !user?.isGlobalAdmin) return;
const fetchTenants = async () => {
try {
const response = await authenticatedFetch(`${API_BASE_URL}/api/config/all`, getAccessToken);
const response = await authenticatedFetch(api.config.all(), getAccessToken);
if (response.ok) {
const data = await response.json();
const mapped: TenantInfo[] = data.map((t: { tenantId: string; domainName: string }) => ({
@@ -118,8 +114,8 @@ export default function AnalyzeRulesPage() {
const fetchRules = useCallback(async () => {
if (!effectiveTenantId) return;
const url = isGlobalOverride
? `${API_BASE_URL}/api/global/rules/analyze?tenantId=${effectiveTenantId}`
: `${API_BASE_URL}/api/rules/analyze`;
? api.rules.globalAnalyze(effectiveTenantId)
: api.rules.analyze();
await fetchRulesExec(
url,
undefined,
@@ -135,7 +131,7 @@ export default function AnalyzeRulesPage() {
const handleToggleRule = async (rule: AnalyzeRule) => {
setTogglingRuleId(rule.ruleId);
const result = await mutate(
`${API_BASE_URL}/api/rules/analyze/${encodeURIComponent(rule.ruleId)}`,
api.rules.analyzeRule(rule.ruleId),
{
method: "PUT",
headers: { "Content-Type": "application/json" },
@@ -159,7 +155,7 @@ export default function AnalyzeRulesPage() {
setDeletingRuleId(rule.ruleId);
const result = await mutate(
`${API_BASE_URL}/api/rules/analyze/${encodeURIComponent(rule.ruleId)}`,
api.rules.analyzeRule(rule.ruleId),
{ method: "DELETE" }
);
if (result !== null) {
@@ -197,7 +193,7 @@ export default function AnalyzeRulesPage() {
};
const result = await mutate(
`${API_BASE_URL}/api/rules/analyze`,
api.rules.analyze(),
{
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -248,7 +244,7 @@ export default function AnalyzeRulesPage() {
};
const result = await mutate(
`${API_BASE_URL}/api/rules/analyze/${encodeURIComponent(rule.ruleId)}`,
api.rules.analyzeRule(rule.ruleId),
{
method: "PUT",
headers: { "Content-Type": "application/json" },
@@ -6,8 +6,9 @@ import { useTenant } from '../../contexts/TenantContext';
import { useAuth } from '../../contexts/AuthContext';
import { useNotifications } from '../../contexts/NotificationContext';
import { ProtectedRoute } from '../../components/ProtectedRoute';
import { API_BASE_URL } from '@/lib/config';
import { api } from '@/lib/api';
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
import { useAdminMode } from "@/hooks/useAdminMode";
interface AuditLogEntry {
@@ -40,12 +41,7 @@ export default function AuditPage() {
const [currentPage, setCurrentPage] = useState(1);
const logsPerPage = 12;
const [globalAdminMode] = useState(() => {
if (typeof window !== 'undefined') {
return localStorage.getItem('globalAdminMode') === 'true';
}
return false;
});
const { globalAdminMode } = useAdminMode();
const fetchAuditLogs = async (showRefreshing = false) => {
try {
@@ -56,8 +52,8 @@ export default function AuditPage() {
}
const endpoint = globalAdminMode
? `${API_BASE_URL}/api/global/audit/logs`
: `${API_BASE_URL}/api/audit/logs`;
? api.audit.globalLogs()
: api.audit.logs();
const response = await authenticatedFetch(endpoint, getAccessToken);
if (!response.ok) {
@@ -0,0 +1,94 @@
"use client";
import { useState, useEffect } from "react";
import { api } from "@/lib/api";
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
import { NotificationType } from "@/contexts/NotificationContext";
interface BlockTarget {
serialNumber: string;
tenantId: string;
deviceName?: string;
}
export function useBlockDevice(
getAccessToken: (forceRefresh?: boolean) => Promise<string | null>,
addNotification: (type: NotificationType, title: string, message: string, key?: string, href?: string) => void,
adminMode: boolean,
globalAdminMode: boolean
) {
const [showBlockConfirm, setShowBlockConfirm] = useState(false);
const [sessionToBlock, setSessionToBlock] = useState<BlockTarget | null>(null);
const [blockingDevice, setBlockingDevice] = useState(false);
const [blockedDevicesSet, setBlockedDevicesSet] = useState<Set<string>>(new Set());
// Reset blocked set when admin mode or global admin mode is turned off
useEffect(() => {
if (!adminMode || !globalAdminMode) {
setBlockedDevicesSet(new Set());
}
}, [adminMode, globalAdminMode]);
const blockDevice = (serialNumber: string, tenantId: string, deviceName?: string) => {
setSessionToBlock({ serialNumber, tenantId, deviceName });
setShowBlockConfirm(true);
};
const confirmBlock = async () => {
if (!sessionToBlock) return;
try {
setBlockingDevice(true);
const response = await authenticatedFetch(api.devices.block(), getAccessToken, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
tenantId: sessionToBlock.tenantId,
serialNumber: sessionToBlock.serialNumber,
durationHours: 24,
reason: `Blocked from dashboard by Global Admin`
})
});
if (response.ok) {
console.log(`Device ${sessionToBlock.serialNumber} blocked successfully`);
setShowBlockConfirm(false);
setSessionToBlock(null);
addNotification('success', 'Device Blocked', `Device ${sessionToBlock.deviceName || sessionToBlock.serialNumber} blocked for 24 hours.`);
setBlockedDevicesSet(prev => {
const next = new Set(prev);
next.add(`${sessionToBlock.tenantId}:${sessionToBlock.serialNumber}`);
return next;
});
} else {
const data = await response.json();
alert(`Fehler beim Blocken: ${data.message || 'Unbekannter Fehler'}`);
}
} catch (error) {
if (error instanceof TokenExpiredError) {
addNotification('error', 'Session Expired', error.message, 'session-expired-error');
} else {
console.error('Failed to block device:', error);
alert('Fehler beim Blocken des Geräts');
}
} finally {
setBlockingDevice(false);
}
};
const cancelBlock = () => {
setShowBlockConfirm(false);
setSessionToBlock(null);
};
return {
showBlockConfirm,
sessionToBlock,
blockingDevice,
blockedDevicesSet,
setBlockedDevicesSet,
blockDevice,
confirmBlock,
cancelBlock,
};
}
@@ -0,0 +1,62 @@
"use client";
import { useState } from "react";
import { api } from "@/lib/api";
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
import { trackEvent } from "@/lib/appInsights";
import { NotificationType } from "@/contexts/NotificationContext";
interface DeleteTarget {
sessionId: string;
tenantId: string;
deviceName?: string;
}
export function useDeleteSession(
getAccessToken: (forceRefresh?: boolean) => Promise<string | null>,
addNotification: (type: NotificationType, title: string, message: string, key?: string, href?: string) => void,
adminMode: boolean,
onSessionDeleted: (sessionId: string) => void
) {
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [sessionToDelete, setSessionToDelete] = useState<DeleteTarget | null>(null);
const deleteSession = (sessionId: string, tenantId: string, deviceName?: string) => {
setSessionToDelete({ sessionId, tenantId, deviceName });
setShowDeleteConfirm(true);
};
const confirmDelete = async () => {
if (!sessionToDelete) return;
try {
const response = await authenticatedFetch(api.sessions.delete(sessionToDelete.sessionId, sessionToDelete.tenantId), getAccessToken, {
method: 'DELETE',
});
if (response.ok) {
trackEvent("session_deleted", { inAdminMode: adminMode });
onSessionDeleted(sessionToDelete.sessionId);
console.log(`Session ${sessionToDelete.sessionId} deleted successfully`);
setShowDeleteConfirm(false);
setSessionToDelete(null);
} else {
const data = await response.json();
alert(`Fehler beim Löschen: ${data.message || 'Unbekannter Fehler'}`);
}
} catch (error) {
if (error instanceof TokenExpiredError) {
addNotification('error', 'Session Expired', error.message, 'session-expired-error');
} else {
console.error('Failed to delete session:', error);
alert('Fehler beim Löschen der Session');
}
}
};
const cancelDelete = () => {
setShowDeleteConfirm(false);
setSessionToDelete(null);
};
return { showDeleteConfirm, sessionToDelete, deleteSession, confirmDelete, cancelDelete };
}
@@ -8,7 +8,7 @@ import { useSignalR } from "../../contexts/SignalRContext";
import { useTenant } from "../../contexts/TenantContext";
import { useAuth } from "../../contexts/AuthContext";
import { useNotifications } from "../../contexts/NotificationContext";
import { API_BASE_URL } from "@/lib/config";
import { api } from "@/lib/api";
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
import { trackEvent } from "@/lib/appInsights";
import { Session } from "./types";
@@ -16,6 +16,9 @@ import { StatsCard } from "./components/StatsCards";
import { WelcomeMessage } from "./components/WelcomeMessage";
import { SessionTable } from "./components/SessionTable";
import { DeleteConfirmModal, BlockConfirmModal } from "./components/ConfirmationModals";
import { useAdminMode } from "@/hooks/useAdminMode";
import { useDeleteSession } from "./hooks/useDeleteSession";
import { useBlockDevice } from "./hooks/useBlockDevice";
interface TenantConfigurationSummary {
validateAutopilotDevice: boolean;
@@ -46,24 +49,22 @@ export default function Home() {
}
return 10;
});
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [sessionToDelete, setSessionToDelete] = useState<{ sessionId: string; tenantId: string; deviceName?: string } | null>(null);
const [showBlockConfirm, setShowBlockConfirm] = useState(false);
const [sessionToBlock, setSessionToBlock] = useState<{ serialNumber: string; tenantId: string; deviceName?: string } | null>(null);
const [blockingDevice, setBlockingDevice] = useState(false);
const [blockedDevicesSet, setBlockedDevicesSet] = useState<Set<string>>(new Set());
const [adminMode, setAdminMode] = useState(() => {
if (typeof window !== 'undefined') {
return localStorage.getItem('adminMode') === 'true';
}
return false;
});
const [globalAdminMode, setGlobalAdminMode] = useState(() => {
if (typeof window !== 'undefined') {
return localStorage.getItem('globalAdminMode') === 'true';
}
return false;
});
const { adminMode, setAdminMode, globalAdminMode, setGlobalAdminMode } = useAdminMode();
const {
showDeleteConfirm, sessionToDelete,
deleteSession, confirmDelete, cancelDelete,
} = useDeleteSession(
getAccessToken,
addNotification,
adminMode,
(deletedId) => setSessions(prev => prev.filter(s => s.sessionId !== deletedId))
);
const {
showBlockConfirm, sessionToBlock, blockingDevice, blockedDevicesSet, setBlockedDevicesSet,
blockDevice, confirmBlock, cancelBlock,
} = useBlockDevice(getAccessToken, addNotification, adminMode, globalAdminMode);
// Use global contexts
const { on, off, isConnected, joinGroup, leaveGroup } = useSignalR();
@@ -94,8 +95,8 @@ export default function Home() {
// Use different endpoint based on global admin mode
const effectiveTenantFilter = globalTenantIdOverride !== undefined ? globalTenantIdOverride : tenantIdFilter.trim();
let endpoint = globalAdminMode
? `${API_BASE_URL}/api/global/sessions${effectiveTenantFilter ? `?tenantId=${encodeURIComponent(effectiveTenantFilter)}` : ''}`
: `${API_BASE_URL}/api/sessions?tenantId=${tenantId}`;
? api.globalSessions.list(effectiveTenantFilter || undefined)
: api.sessions.list(tenantId);
// Append cursor for "Load More" requests
if (loadMoreCursor) {
@@ -160,7 +161,7 @@ export default function Home() {
const results = await Promise.allSettled(
tenantIds.map(tid =>
authenticatedFetch(`${API_BASE_URL}/api/devices/blocked?tenantId=${encodeURIComponent(tid)}`, getAccessToken)
authenticatedFetch(api.devices.blocked(tid), getAccessToken)
.then(res => res.ok ? res.json() : { blocked: [] })
)
);
@@ -197,7 +198,7 @@ export default function Home() {
if (user && !user.isTenantAdmin && !user.isGlobalAdmin && user.role !== 'Operator') return;
try {
const response = await authenticatedFetch(`${API_BASE_URL}/api/config/${tenantId}`, getAccessToken);
const response = await authenticatedFetch(api.config.tenant(tenantId), getAccessToken);
if (!response.ok) {
setSerialValidationEnabled(null);
@@ -358,22 +359,6 @@ export default function Home() {
return () => clearTimeout(timer);
}, [searchQuery]);
// Save admin mode to localStorage when it changes
useEffect(() => {
localStorage.setItem('adminMode', adminMode.toString());
}, [adminMode]);
// Save global admin mode to localStorage when it changes
useEffect(() => {
localStorage.setItem('globalAdminMode', globalAdminMode.toString());
}, [globalAdminMode]);
// Clear blocked devices set when admin mode or global admin mode is turned off
useEffect(() => {
if (!adminMode || !globalAdminMode) {
setBlockedDevicesSet(new Set());
}
}, [adminMode, globalAdminMode]);
// Disable global admin mode if user is not a global admin
useEffect(() => {
@@ -383,37 +368,6 @@ export default function Home() {
}
}, [user, globalAdminMode]);
// Listen for localStorage changes from other components (e.g., Navbar)
useEffect(() => {
const handleStorageChange = (e: StorageEvent) => {
if (e.key === 'adminMode' && e.newValue !== null) {
setAdminMode(e.newValue === 'true');
}
if (e.key === 'globalAdminMode' && e.newValue !== null) {
setGlobalAdminMode(e.newValue === 'true');
}
};
const handleCustomStorageChange = () => {
const newAdminMode = localStorage.getItem('adminMode') === 'true';
const newGlobalMode = localStorage.getItem('globalAdminMode') === 'true';
if (newAdminMode !== adminMode) {
setAdminMode(newAdminMode);
}
if (newGlobalMode !== globalAdminMode) {
setGlobalAdminMode(newGlobalMode);
}
};
window.addEventListener('storage', handleStorageChange);
window.addEventListener('localStorageChange', handleCustomStorageChange);
return () => {
window.removeEventListener('storage', handleStorageChange);
window.removeEventListener('localStorageChange', handleCustomStorageChange);
};
}, [adminMode, globalAdminMode]);
// Refetch sessions when global admin mode changes
useEffect(() => {
@@ -464,97 +418,6 @@ export default function Home() {
fetchSessions(undefined, "");
};
const deleteSession = (sessionId: string, sessionTenantId: string, deviceName?: string) => {
setSessionToDelete({ sessionId, tenantId: sessionTenantId, deviceName });
setShowDeleteConfirm(true);
};
const confirmDelete = async () => {
if (!sessionToDelete) return;
try {
const response = await authenticatedFetch(`${API_BASE_URL}/api/sessions/${sessionToDelete.sessionId}?tenantId=${sessionToDelete.tenantId}`, getAccessToken, {
method: 'DELETE',
});
if (response.ok) {
trackEvent("session_deleted", { inAdminMode: adminMode });
setSessions(prevSessions => prevSessions.filter(s => s.sessionId !== sessionToDelete.sessionId));
console.log(`Session ${sessionToDelete.sessionId} deleted successfully`);
setShowDeleteConfirm(false);
setSessionToDelete(null);
} else {
const data = await response.json();
alert(`Fehler beim Löschen: ${data.message || 'Unbekannter Fehler'}`);
}
} catch (error) {
if (error instanceof TokenExpiredError) {
addNotification('error', 'Session Expired', error.message, 'session-expired-error');
} else {
console.error('Failed to delete session:', error);
alert('Fehler beim Löschen der Session');
}
}
};
const cancelDelete = () => {
setShowDeleteConfirm(false);
setSessionToDelete(null);
};
const blockDevice = (serialNumber: string, sessionTenantId: string, deviceName?: string) => {
setSessionToBlock({ serialNumber, tenantId: sessionTenantId, deviceName });
setShowBlockConfirm(true);
};
const confirmBlock = async () => {
if (!sessionToBlock) return;
try {
setBlockingDevice(true);
const response = await authenticatedFetch(`${API_BASE_URL}/api/devices/block`, getAccessToken, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
tenantId: sessionToBlock.tenantId,
serialNumber: sessionToBlock.serialNumber,
durationHours: 24,
reason: `Blocked from dashboard by Global Admin`
})
});
if (response.ok) {
console.log(`Device ${sessionToBlock.serialNumber} blocked successfully`);
setShowBlockConfirm(false);
setSessionToBlock(null);
addNotification('success', 'Device Blocked', `Device ${sessionToBlock.deviceName || sessionToBlock.serialNumber} blocked for 24 hours.`);
setBlockedDevicesSet(prev => {
const next = new Set(prev);
next.add(`${sessionToBlock.tenantId}:${sessionToBlock.serialNumber}`);
return next;
});
} else {
const data = await response.json();
alert(`Fehler beim Blocken: ${data.message || 'Unbekannter Fehler'}`);
}
} catch (error) {
if (error instanceof TokenExpiredError) {
addNotification('error', 'Session Expired', error.message, 'session-expired-error');
} else {
console.error('Failed to block device:', error);
alert('Fehler beim Blocken des Geräts');
}
} finally {
setBlockingDevice(false);
}
};
const cancelBlock = () => {
setShowBlockConfirm(false);
setSessionToBlock(null);
};
// Client-side tenant filter: ensures sessions from other tenants are never displayed,
// regardless of how they entered the sessions state (SignalR, race conditions, etc.)
const effectiveSessions = globalAdminMode
@@ -632,14 +495,21 @@ export default function Home() {
const sortedSessions = [...filteredSessions].sort((a, b) => {
if (!sortColumn) return 0;
let aValue: any = a[sortColumn];
let bValue: any = b[sortColumn];
const rawA: Session[typeof sortColumn] = a[sortColumn];
const rawB: Session[typeof sortColumn] = b[sortColumn];
let aValue: number | string | boolean | null | undefined;
let bValue: number | string | boolean | null | undefined;
if (sortColumn === "startedAt") {
aValue = new Date(aValue).getTime();
bValue = new Date(bValue).getTime();
aValue = new Date(rawA as string).getTime();
bValue = new Date(rawB as string).getTime();
} else {
aValue = rawA;
bValue = rawB;
}
if (aValue == null || bValue == null) return 0;
if (aValue < bValue) return sortDirection === "asc" ? -1 : 1;
if (aValue > bValue) return sortDirection === "asc" ? 1 : -1;
return 0;
@@ -7,11 +7,12 @@ import { useTenant } from "../../../contexts/TenantContext";
import { useAuth } from "../../../contexts/AuthContext";
import { useNotifications } from "../../../contexts/NotificationContext";
import { ProtectedRoute } from "../../../components/ProtectedRoute";
import { API_BASE_URL } from "@/lib/config";
import { api } from "@/lib/api";
import { formatInlineMarkdown } from "@/lib/formatInlineMarkdown";
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
import { ConfidenceBadge, SeverityBadge } from "./components/DiagnosisBadges";
import { Session, EnrollmentEvent, RuleResult } from "@/types";
import { useAdminMode } from "@/hooks/useAdminMode";
export default function DiagnosisPage() {
const params = useParams();
@@ -37,12 +38,7 @@ export default function DiagnosisPage() {
const { getAccessToken } = useAuth();
const { addNotification } = useNotifications();
const [globalAdminMode] = useState(() => {
if (typeof window !== "undefined") {
return localStorage.getItem("globalAdminMode") === "true";
}
return false;
});
const { globalAdminMode } = useAdminMode();
useEffect(() => {
if (!sessionId) return;
@@ -117,8 +113,8 @@ export default function DiagnosisPage() {
const fetchSessionDetails = async () => {
try {
const endpoint = globalAdminMode
? `${API_BASE_URL}/api/global/sessions`
: `${API_BASE_URL}/api/sessions?tenantId=${tenantId}`;
? api.globalSessions.list()
: api.sessions.list(tenantId);
const response = await authenticatedFetch(endpoint, getAccessToken);
if (response.ok) {
const data = await response.json();
@@ -146,7 +142,7 @@ export default function DiagnosisPage() {
const effectiveTenantId = sessionTenantId || tenantId;
try {
const response = await authenticatedFetch(
`${API_BASE_URL}/api/sessions/${sessionId}/events?tenantId=${effectiveTenantId}`,
api.sessions.events(sessionId, effectiveTenantId),
getAccessToken
);
if (response.ok) {
@@ -169,7 +165,7 @@ export default function DiagnosisPage() {
const effectiveTenantId = sessionTenantId || tenantId;
try {
const response = await authenticatedFetch(
`${API_BASE_URL}/api/sessions/${sessionId}/analysis?tenantId=${effectiveTenantId}`,
api.sessions.analysis(sessionId, effectiveTenantId),
getAccessToken
);
if (response.ok) {
@@ -7,10 +7,11 @@ import { useSignalR } from "../../contexts/SignalRContext";
import { useTenant } from "../../contexts/TenantContext";
import { useAuth } from "../../contexts/AuthContext";
import { useNotifications } from "../../contexts/NotificationContext";
import { API_BASE_URL } from "@/lib/config";
import { api } from "@/lib/api";
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
import FleetStatCard from "./components/FleetStatCard";
import { Session } from "@/types";
import { useAdminMode } from "@/hooks/useAdminMode";
interface AppMetric {
appName: string;
@@ -49,12 +50,7 @@ export default function FleetHealthPage() {
const { getAccessToken } = useAuth();
const { addNotification } = useNotifications();
const [globalAdminMode] = useState(() => {
if (typeof window !== "undefined") {
return localStorage.getItem("globalAdminMode") === "true";
}
return false;
});
const { globalAdminMode } = useAdminMode();
useEffect(() => {
if (!globalAdminMode && !tenantId) return; // wait for real tenant ID
@@ -128,8 +124,8 @@ export default function FleetHealthPage() {
const fetchSessions = async () => {
try {
const endpoint = globalAdminMode
? `${API_BASE_URL}/api/global/sessions`
: `${API_BASE_URL}/api/sessions?tenantId=${tenantId}`;
? api.globalSessions.list()
: api.sessions.list(tenantId);
const response = await authenticatedFetch(endpoint, getAccessToken);
if (response.ok) {
const data = await response.json();
@@ -153,8 +149,8 @@ export default function FleetHealthPage() {
try {
const days = range === "7d" ? 7 : range === "30d" ? 30 : 90;
const endpoint = globalAdminMode
? `${API_BASE_URL}/api/global/metrics/app?days=${days}`
: `${API_BASE_URL}/api/metrics/app?tenantId=${tenantId}&days=${days}`;
? api.metrics.globalApp(days)
: api.metrics.app(tenantId, days);
const response = await authenticatedFetch(endpoint, getAccessToken);
if (response.ok) {
const data = await response.json();
@@ -6,7 +6,7 @@ import { useRouter } from "next/navigation";
import { ProtectedRoute } from "../../components/ProtectedRoute";
import { useTenant } from "../../contexts/TenantContext";
import { useAuth } from "../../contexts/AuthContext";
import { API_BASE_URL } from "@/lib/config";
import { api } from "@/lib/api";
import { authenticatedFetch } from "@/lib/authenticatedFetch";
import { trackEvent } from "@/lib/appInsights";
import { downloadAsJson, stripInternalFields, bumpVersion } from "@/lib/rulePageHelpers";
@@ -15,6 +15,7 @@ import { RuleFilterBar } from "@/components/rules/RuleFilterBar";
import { EmptyState } from "@/components/rules/EmptyState";
import { FormJsonToggle, JsonModeToggleButtons } from "@/components/rules/FormJsonToggle";
import { useAuthenticatedFetch, useNotificationMessages } from "@/hooks";
import { useAdminMode } from "@/hooks/useAdminMode";
import { GatherRule, NewRuleForm, EMPTY_FORM, CATEGORY_COLORS } from "./types";
import { GatherRuleFormFields } from "./components/GatherRuleFormFields";
import { GatherRuleCard } from "./components/GatherRuleCard";
@@ -72,12 +73,7 @@ export default function GatherRulesPage() {
const [unrestrictedMode, setUnrestrictedMode] = useState(false);
// Global admin mode
const [globalAdminMode] = useState(() => {
if (typeof window !== 'undefined') {
return localStorage.getItem('globalAdminMode') === 'true';
}
return false;
});
const { globalAdminMode } = useAdminMode();
const [tenants, setTenants] = useState<TenantInfo[]>([]);
const [selectedTenantId, setSelectedTenantId] = useState<string>('');
@@ -85,7 +81,7 @@ export default function GatherRulesPage() {
if (!globalAdminMode || !user?.isGlobalAdmin) return;
const fetchTenants = async () => {
try {
const response = await authenticatedFetch(`${API_BASE_URL}/api/config/all`, getAccessToken);
const response = await authenticatedFetch(api.config.all(), getAccessToken);
if (response.ok) {
const data = await response.json();
const mapped: TenantInfo[] = data.map((t: { tenantId: string; domainName: string }) => ({
@@ -119,8 +115,8 @@ export default function GatherRulesPage() {
const fetchRules = useCallback(async () => {
if (!effectiveTenantId) return;
const url = isGlobalOverride
? `${API_BASE_URL}/api/global/rules/gather?tenantId=${effectiveTenantId}`
: `${API_BASE_URL}/api/rules/gather?tenantId=${effectiveTenantId}`;
? api.rules.globalGather(effectiveTenantId)
: api.rules.gather(effectiveTenantId);
await fetchRulesExec(
url,
undefined,
@@ -140,8 +136,8 @@ export default function GatherRulesPage() {
const fetchConfig = async () => {
try {
const url = isGlobalOverride
? `${API_BASE_URL}/api/global/config/${effectiveTenantId}`
: `${API_BASE_URL}/api/config/${effectiveTenantId}`;
? api.globalConfig.tenant(effectiveTenantId)
: api.config.tenant(effectiveTenantId);
const response = await authenticatedFetch(url, getAccessToken);
if (response.ok) {
const data = await response.json();
@@ -157,7 +153,7 @@ export default function GatherRulesPage() {
const handleToggleRule = async (rule: GatherRule) => {
setTogglingRule(rule.ruleId);
const result = await mutate(
`${API_BASE_URL}/api/rules/gather/${rule.ruleId}?tenantId=${effectiveTenantId}`,
api.rules.gatherRule(rule.ruleId, effectiveTenantId),
{
method: "PUT",
headers: { "Content-Type": "application/json" },
@@ -180,7 +176,7 @@ export default function GatherRulesPage() {
setDeletingRule(rule.ruleId);
const result = await mutate(
`${API_BASE_URL}/api/rules/gather/${rule.ruleId}?tenantId=${effectiveTenantId}`,
api.rules.gatherRule(rule.ruleId, effectiveTenantId),
{ method: "DELETE" }
);
if (result !== null) {
@@ -250,7 +246,7 @@ export default function GatherRulesPage() {
};
const result = await mutate(
`${API_BASE_URL}/api/rules/gather?tenantId=${effectiveTenantId}`,
api.rules.gather(effectiveTenantId),
{
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -334,7 +330,7 @@ export default function GatherRulesPage() {
};
const result = await mutate(
`${API_BASE_URL}/api/rules/gather/${rule.ruleId}?tenantId=${effectiveTenantId}`,
api.rules.gatherRule(rule.ruleId, effectiveTenantId),
{
method: "PUT",
headers: { "Content-Type": "application/json" },
@@ -6,8 +6,9 @@ import dynamic from "next/dynamic";
import { ProtectedRoute } from "../../components/ProtectedRoute";
import { useTenant } from "../../contexts/TenantContext";
import { useAuth } from "../../contexts/AuthContext";
import { API_BASE_URL } from "@/lib/config";
import { api } from "@/lib/api";
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
import { useAdminMode } from "@/hooks/useAdminMode";
// Dynamically import the map component (Leaflet requires window/document)
const GeoMap = dynamic(() => import("./GeoMap"), { ssr: false });
@@ -106,19 +107,14 @@ export default function GeographicPerformancePage() {
const { tenantId } = useTenant();
const { getAccessToken } = useAuth();
const [globalAdminMode] = useState(() => {
if (typeof window !== "undefined") {
return localStorage.getItem("globalAdminMode") === "true";
}
return false;
});
const { globalAdminMode } = useAdminMode();
const fetchGeoMetrics = useCallback(async (range: TimeRange = timeRange, group: GroupBy = groupBy) => {
try {
const days = range === "7d" ? 7 : range === "30d" ? 30 : 90;
const endpoint = globalAdminMode
? `${API_BASE_URL}/api/global/metrics/geographic?days=${days}&groupBy=${group}`
: `${API_BASE_URL}/api/metrics/geographic?tenantId=${tenantId}&days=${days}&groupBy=${group}`;
? api.metrics.globalGeographic(days, group)
: api.metrics.geographic(tenantId, days, group);
const response = await authenticatedFetch(endpoint, getAccessToken);
if (response.ok) {
const data = await response.json();
@@ -5,8 +5,9 @@ import { useRouter, useSearchParams } from "next/navigation";
import { ProtectedRoute } from "../../../components/ProtectedRoute";
import { useTenant } from "../../../contexts/TenantContext";
import { useAuth } from "../../../contexts/AuthContext";
import { API_BASE_URL } from "@/lib/config";
import { api } from "@/lib/api";
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
import { useAdminMode } from "@/hooks/useAdminMode";
interface SessionSummary {
sessionId: string;
@@ -93,19 +94,14 @@ function LocationSessionsContent() {
const { tenantId } = useTenant();
const { getAccessToken } = useAuth();
const [globalAdminMode] = useState(() => {
if (typeof window !== "undefined") {
return localStorage.getItem("globalAdminMode") === "true";
}
return false;
});
const { globalAdminMode } = useAdminMode();
const fetchSessions = useCallback(async () => {
if (!locationKey) return;
try {
const endpoint = globalAdminMode
? `${API_BASE_URL}/api/global/metrics/geographic/sessions?days=${days}&groupBy=${groupBy}&locationKey=${encodeURIComponent(locationKey)}`
: `${API_BASE_URL}/api/metrics/geographic/sessions?tenantId=${tenantId}&days=${days}&groupBy=${groupBy}&locationKey=${encodeURIComponent(locationKey)}`;
? api.metrics.globalGeographicSessions(Number(days), groupBy, locationKey)
: api.metrics.geographicSessions(tenantId, Number(days), groupBy, locationKey);
const response = await authenticatedFetch(endpoint, getAccessToken);
if (response.ok) {
const result = await response.json();
@@ -6,7 +6,7 @@ import { useSignalR } from '@/contexts/SignalRContext';
import { useTenant } from '@/contexts/TenantContext';
import { useState, useEffect, useCallback } from 'react';
import * as signalR from '@microsoft/signalr';
import { API_BASE_URL } from '@/lib/config';
import { api } from '@/lib/api';
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
interface HealthCheck {
@@ -37,7 +37,7 @@ export default function HealthCheckPage() {
const performHealthCheck = useCallback(async () => {
setLoading(true);
try {
const response = await authenticatedFetch(`${API_BASE_URL}/api/health/detailed`, getAccessToken);
const response = await authenticatedFetch(api.health.detailed(), getAccessToken);
if (!response.ok) {
if (response.status === 403) {
@@ -5,8 +5,9 @@ import { useRouter } from "next/navigation";
import { ProtectedRoute } from "../../components/ProtectedRoute";
import { useTenant } from "../../contexts/TenantContext";
import { useAuth } from "../../contexts/AuthContext";
import { API_BASE_URL } from "@/lib/config";
import { api } from "@/lib/api";
import { useAuthenticatedFetch, useNotificationMessages } from "@/hooks";
import { useAdminMode } from "@/hooks/useAdminMode";
import { downloadAsJson, stripInternalFields } from "@/lib/rulePageHelpers";
import { StatCard } from "@/components/rules/StatCard";
import { RuleFilterBar } from "@/components/rules/RuleFilterBar";
@@ -22,7 +23,7 @@ export default function ImeLogPatternsPage() {
const { tenantId } = useTenant();
const { user } = useAuth();
const isGlobalAdmin = user?.isGlobalAdmin ?? false;
const [globalAdminMode, setGlobalAdminMode] = useState(false);
const { globalAdminMode } = useAdminMode();
const { successMessage, error, showSuccess, showError } = useNotificationMessages();
@@ -59,20 +60,12 @@ export default function ImeLogPatternsPage() {
const [newParamKey, setNewParamKey] = useState("");
const [newParamValue, setNewParamValue] = useState("");
// Check global admin mode from localStorage
useEffect(() => {
const mode = localStorage.getItem("globalAdminMode") === "true";
setGlobalAdminMode(mode);
const handleStorage = () => setGlobalAdminMode(localStorage.getItem("globalAdminMode") === "true");
window.addEventListener("storage", handleStorage);
return () => window.removeEventListener("storage", handleStorage);
}, []);
// Fetch patterns
const fetchPatterns = useCallback(async () => {
if (!tenantId) return;
await fetchPatternsExec(
`${API_BASE_URL}/api/rules/ime-log-patterns`,
api.rules.imeLogPatterns(),
undefined,
{ transform: (d) => { const r = d as { success?: boolean; patterns?: ImeLogPattern[] }; return r.success && Array.isArray(r.patterns) ? r.patterns : []; } }
);
@@ -88,7 +81,7 @@ export default function ImeLogPatternsPage() {
setTogglingPattern(pattern.patternId);
const result = await mutate(
`${API_BASE_URL}/api/rules/ime-log-patterns/${encodeURIComponent(pattern.patternId)}?global=true`,
api.rules.imeLogPattern(pattern.patternId),
{
method: "PUT",
headers: { "Content-Type": "application/json" },
@@ -139,7 +132,7 @@ export default function ImeLogPatternsPage() {
};
const result = await mutate(
`${API_BASE_URL}/api/rules/ime-log-patterns/${encodeURIComponent(patternId)}?global=true`,
api.rules.imeLogPattern(patternId),
{
method: "PUT",
headers: { "Content-Type": "application/json" },
@@ -3,7 +3,7 @@
import { useAuth } from "../../contexts/AuthContext";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { API_BASE_URL } from "@/lib/config";
import { api } from "@/lib/api";
export default function PreviewPage() {
const { isAuthenticated, isLoading, user, isPreviewBlocked, previewMessage, logout, getAccessToken } = useAuth();
@@ -44,7 +44,7 @@ export default function PreviewPage() {
throw new Error("Not authenticated");
}
const response = await fetch(`${API_BASE_URL}/api/preview/notification-email`, {
const response = await fetch(api.preview.notificationEmail(), {
method: "PUT",
headers: {
"Authorization": `Bearer ${token}`,
@@ -1,7 +1,7 @@
"use client";
import { useState, useEffect, useRef, useMemo } from "react";
import { API_BASE_URL } from "@/lib/config";
import { api } from "@/lib/api";
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
import { trackEvent } from "@/lib/appInsights";
import { useTenant } from "../../contexts/TenantContext";
@@ -162,7 +162,7 @@ export default function ProgressPortalPage() {
const fetchEvents = async () => {
try {
const response = await authenticatedFetch(
`${API_BASE_URL}/api/progress/sessions/${session.sessionId}/events?tenantId=${tenantId}`,
api.progress.sessionEvents(session.sessionId, tenantId),
getAccessToken
);
if (response.ok) {
@@ -205,7 +205,7 @@ export default function ProgressPortalPage() {
try {
const response = await authenticatedFetch(
`${API_BASE_URL}/api/progress/sessions?tenantId=${tenantId}`,
api.progress.sessions(tenantId),
getAccessToken
);
@@ -201,16 +201,17 @@ export default function DeviceDetailsCard({ events }: { events: EnrollmentEvent[
{(networkAdapters || dnsConfig || proxyConfig || networkInterfaceInfo || wifiSignalInfo) && (
<DetailSection title="Network">
{networkAdapters && networkAdapters.adapters && (
(networkAdapters.adapters as any[]).map((adapter: any, i: number) => {
const { ipv4, ipv6 } = adapter.ipAddresses ? splitIpAddresses(adapter.ipAddresses) : { ipv4: [], ipv6: [] };
(networkAdapters.adapters as unknown[]).map((adapter, i: number) => {
const a = adapter as Record<string, unknown>;
const { ipv4, ipv6 } = a.ipAddresses ? splitIpAddresses(a.ipAddresses as string) : { ipv4: [], ipv6: [] };
const hasIpv6 = ipv6.length > 0;
const isIpv6Shown = showIpv6[i] ?? false;
return (
<div key={i} className="mb-3 pb-3 border-b border-gray-100 last:border-b-0 last:mb-0 last:pb-0">
<div className="text-sm font-medium text-gray-700 mb-1">{adapter.description || adapter.name || `Adapter ${i + 1}`}</div>
{adapter.dhcpEnabled !== undefined && <DetailRow label="DHCP" value={adapter.dhcpEnabled ? "Enabled" : "Disabled"} />}
{adapter.macAddress && <DetailRow label="MAC" value={adapter.macAddress} />}
<div className="text-sm font-medium text-gray-700 mb-1">{(a.description || a.name || `Adapter ${i + 1}`) as string}</div>
{a.dhcpEnabled !== undefined && <DetailRow label="DHCP" value={a.dhcpEnabled ? "Enabled" : "Disabled"} />}
{a.macAddress && <DetailRow label="MAC" value={a.macAddress as string} />}
{ipv4.length > 0 && <DetailRow label="IPv4" value={ipv4.join(", ")} />}
{hasIpv6 && (
<div className="mt-1">
@@ -233,10 +234,11 @@ export default function DeviceDetailsCard({ events }: { events: EnrollmentEvent[
</div>
)}
{dnsConfig?.dnsEntries && Array.isArray(dnsConfig.dnsEntries) && (
(dnsConfig.dnsEntries as any[])
.filter((entry: any) => entry.adapter === adapter.description || entry.adapter === adapter.name)
.map((entry: any, dnsIdx: number) => (
<DetailRow key={`dns-${dnsIdx}`} label="DNS" value={entry.servers || "N/A"} />
(dnsConfig.dnsEntries as unknown[])
.map(e => e as Record<string, unknown>)
.filter(entry => entry.adapter === a.description || entry.adapter === a.name)
.map((entry, dnsIdx: number) => (
<DetailRow key={`dns-${dnsIdx}`} label="DNS" value={(entry.servers || "N/A") as string} />
))
)}
</div>
@@ -303,14 +305,17 @@ export default function DeviceDetailsCard({ events }: { events: EnrollmentEvent[
<DetailRow label="BitLocker" value={bitLockerStatus.systemDriveProtected ? "Protected" : "Not Protected"} />
{bitLockerStatus.volumes && Array.isArray(bitLockerStatus.volumes) && bitLockerStatus.volumes.length > 0 && (
<div className="mt-1 text-xs text-gray-500">
{(bitLockerStatus.volumes as any[]).map((vol: any, i: number) => (
{(bitLockerStatus.volumes as unknown[]).map((vol, i: number) => {
const v = vol as Record<string, unknown>;
return (
<div key={i}>
{vol.driveLetter} {vol.protectionStatus === "1" ? "Protected" : "Not Protected"}
{vol.encryptionMethod !== undefined && vol.encryptionMethod !== null && vol.encryptionMethod !== "" && (
` (Method: ${getBitLockerEncryptionMethodLabel(vol.encryptionMethod)})`
{v.driveLetter as string} {v.protectionStatus === "1" ? "Protected" : "Not Protected"}
{v.encryptionMethod !== undefined && v.encryptionMethod !== null && v.encryptionMethod !== "" && (
` (Method: ${getBitLockerEncryptionMethodLabel(v.encryptionMethod)})`
)}
</div>
))}
);
})}
</div>
)}
</>
@@ -414,9 +419,12 @@ export default function DeviceDetailsCard({ events }: { events: EnrollmentEvent[
<DetailRow label="DIMMs" value={`${hwSpec.ramDimmCount}${hwSpec.ramFormFactor ? ` (${hwSpec.ramFormFactor})` : ''}`} />
)}
{hwSpec.disks && Array.isArray(hwSpec.disks) && (hwSpec.disks as any[]).map((disk: any, i: number) => (
<DetailRow key={`disk-${i}`} label={hwSpec.disks.length > 1 ? `Disk ${i + 1}` : 'Disk'} value={`${disk.model || 'Unknown'}${disk.sizeGB ? ` (${disk.sizeGB} GB)` : ''}${disk.mediaType && disk.mediaType !== 'Unknown' ? ` — ${disk.mediaType}` : ''}`} />
))}
{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}` : ''}`} />
);
})}
{hwSpec.systemDriveFreeGB !== undefined && hwSpec.systemDriveTotalGB !== undefined && (
<DetailRow label="C: Free Space" value={`${hwSpec.systemDriveFreeGB} / ${hwSpec.systemDriveTotalGB} GB`} />
)}
@@ -440,9 +448,12 @@ export default function DeviceDetailsCard({ events }: { events: EnrollmentEvent[
)}
{hwSpec.batteryPresent === false && <DetailRow label="Battery" value="Not present (Desktop)" />}
{hwSpec.gpus && Array.isArray(hwSpec.gpus) && (hwSpec.gpus as any[]).map((gpu: any, i: number) => (
<DetailRow key={`gpu-${i}`} label={hwSpec.gpus.length > 1 ? `GPU ${i + 1}` : 'GPU'} value={`${gpu.name || 'Unknown'}${gpu.adapterRAMGB ? ` (${gpu.adapterRAMGB} GB)` : ''}`} />
))}
{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)` : ''}`} />
);
})}
</DetailSection>
)}
</div>
@@ -104,9 +104,9 @@ export default function ReportSessionModal({
setScreenshotFiles([]);
setAgentLogFiles([]);
setAgentLogError(null);
} catch (err: any) {
} catch (err: unknown) {
setSubmitResult('error');
setSubmitErrorMessage(err?.message || 'Failed to submit report.');
setSubmitErrorMessage(err instanceof Error ? err.message : 'Failed to submit report.');
}
};
@@ -1,10 +1,11 @@
"use client";
import { useState, useMemo } from "react";
import type { EnrollmentEvent } from "@/types";
interface VulnerabilityReportSectionProps {
events: any[];
vulnerabilityReport: any;
events: EnrollmentEvent[];
vulnerabilityReport: unknown;
expanded: boolean;
setExpanded: (v: boolean) => void;
onRescan?: () => Promise<void> | void;
@@ -128,18 +129,18 @@ export default function VulnerabilityReportSection({
let kevCount = 0;
for (const evt of vulnReportData) {
const d = evt.data || {};
const d = (evt.data || {}) as Record<string, unknown>;
if (d.findings && Array.isArray(d.findings)) {
for (const f of d.findings) {
const key = `${f.softwareName || f.software || "Unknown"}|||${f.version || ""}`;
const cves: CveDetail[] = (f.cves || f.vulnerabilities || []).map((c: any) => ({
cveId: c.cveId || c.id || c.cve_id || "Unknown",
cvss: c.cvss ?? c.cvssScore ?? c.cvss_score ?? 0,
severity: normalizeSeverity(c.severity),
description: c.description || c.summary || "",
kev: c.kev ?? c.isKev ?? c.is_kev ?? false,
publishedDate: c.publishedDate || c.published_date || "",
references: c.references || [],
const cves: CveDetail[] = (f.cves || f.vulnerabilities || []).map((c: Record<string, unknown>) => ({
cveId: (c.cveId || c.id || c.cve_id || "Unknown") as string,
cvss: (c.cvss ?? c.cvssScore ?? c.cvss_score ?? 0) as number,
severity: normalizeSeverity(c.severity as string | undefined),
description: (c.description || c.summary || "") as string,
kev: (c.kev ?? c.isKev ?? c.is_kev ?? false) as boolean,
publishedDate: (c.publishedDate || c.published_date || "") as string,
references: (c.references || []) as string[],
}));
const maxCvss = cves.reduce((max: number, c: CveDetail) => Math.max(max, c.cvss ?? 0), 0);
@@ -179,8 +180,8 @@ export default function VulnerabilityReportSection({
} else if (d.unmatched_software && Array.isArray(d.unmatched_software)) {
unmatched = [...unmatched, ...d.unmatched_software];
}
total = d.totalSoftware ?? d.total_software ?? d.totalCount ?? total;
freshness = d.dataFreshness || d.data_freshness || d.analyzedAt || d.analyzed_at || freshness;
total = (d.totalSoftware ?? d.total_software ?? d.totalCount ?? total) as number;
freshness = (d.dataFreshness || d.data_freshness || d.analyzedAt || d.analyzed_at || freshness) as string;
}
// Deduplicate unmatched
@@ -216,14 +217,17 @@ export default function VulnerabilityReportSection({
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;
const items: InventoryItem[] = (d.inventory || d.software || d.items || []).map((item: any) => ({
name: item.displayName || item.name || item.normalizedName || item.softwareName || "Unknown",
version: item.displayVersion || item.version || item.normalizedVersion || "",
publisher: item.publisher || item.normalizedPublisher || item.vendor || "",
installDate: item.installDate || item.install_date || "",
source: item.registrySource || item.source || "",
confidence: item.normalizationConfidence || item.confidence || "",
}));
const items: InventoryItem[] = ((d.inventory || d.software || d.items || []) as unknown[]).map((item: unknown) => {
const i = item as Record<string, unknown>;
return {
name: (i.displayName || i.name || i.normalizedName || i.softwareName || "Unknown") as string,
version: (i.displayVersion || i.version || i.normalizedVersion || "") as string,
publisher: (i.publisher || i.normalizedPublisher || i.vendor || "") as string,
installDate: (i.installDate || i.install_date || "") as string,
source: (i.registrySource || i.source || "") as string,
confidence: (i.normalizationConfidence || i.confidence || "") as string,
};
});
if (!chunkGroups.has(triggeredAt)) {
chunkGroups.set(triggeredAt, { items: [], chunkCount, seenChunks: new Set() });
@@ -252,17 +256,18 @@ export default function VulnerabilityReportSection({
const items: InventoryItem[] = [];
// Look in vulnerability report and inventory events for new_installs_during_enrollment
for (const evt of [...vulnReportData, ...inventoryEvents]) {
const d = evt.data || {};
const d = (evt.data || {}) as Record<string, unknown>;
const newInstalls = d.new_installs_during_enrollment || d.newInstallsDuringEnrollment || [];
if (Array.isArray(newInstalls)) {
for (const item of newInstalls) {
for (const rawItem of newInstalls) {
const item = rawItem as Record<string, unknown>;
items.push({
name: item.displayName || item.name || item.normalizedName || item.softwareName || "Unknown",
version: item.displayVersion || item.version || item.normalizedVersion || "",
publisher: item.publisher || item.normalizedPublisher || item.vendor || "",
installDate: item.installDate || item.install_date || "",
source: item.registrySource || item.source || "",
confidence: item.normalizationConfidence || item.confidence || "",
name: (item.displayName || item.name || item.normalizedName || item.softwareName || "Unknown") as string,
version: (item.displayVersion || item.version || item.normalizedVersion || "") as string,
publisher: (item.publisher || item.normalizedPublisher || item.vendor || "") as string,
installDate: (item.installDate || item.install_date || "") as string,
source: (item.registrySource || item.source || "") as string,
confidence: (item.normalizationConfidence || item.confidence || "") as string,
});
}
}
@@ -0,0 +1,57 @@
"use client";
import { useState, useRef, useEffect } from "react";
import { EnrollmentEvent } from "@/types";
export function useAutoScroll(events: EnrollmentEvent[]) {
const [autoScroll, setAutoScroll] = useState(false);
const isNearBottomRef = useRef(false);
const isProgrammaticScrollRef = useRef(false);
// Auto-scroll: track whether user is near the bottom (only while feature is enabled).
// Uses a ref so scroll events don't cause re-renders.
useEffect(() => {
if (!autoScroll) return;
const handleScroll = () => {
const distanceFromBottom = document.body.scrollHeight - window.scrollY - window.innerHeight;
if (isProgrammaticScrollRef.current) {
// During a programmatic scroll animation, only allow flipping to true
// (we reached the bottom) — never flip to false (content grew mid-animation).
if (distanceFromBottom <= 150) isNearBottomRef.current = true;
} else {
isNearBottomRef.current = distanceFromBottom <= 150;
}
};
window.addEventListener('scroll', handleScroll, { passive: true });
return () => window.removeEventListener('scroll', handleScroll);
}, [autoScroll]);
// Auto-scroll: when new events arrive, scroll to bottom only if user is already near the bottom.
useEffect(() => {
if (!autoScroll || events.length === 0 || !isNearBottomRef.current) return;
isProgrammaticScrollRef.current = true;
const timer = setTimeout(() => {
window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
// Keep the guard up long enough for the smooth scroll animation to finish.
setTimeout(() => { isProgrammaticScrollRef.current = false; }, 600);
}, 150);
return () => { clearTimeout(timer); isProgrammaticScrollRef.current = false; };
}, [events, autoScroll]);
const handleAutoScrollToggle = () => {
setAutoScroll(prev => {
const next = !prev;
if (next) {
// Enabling: immediately scroll to bottom and mark as near-bottom
isNearBottomRef.current = true;
isProgrammaticScrollRef.current = true;
setTimeout(() => {
window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
setTimeout(() => { isProgrammaticScrollRef.current = false; }, 600);
}, 50);
}
return next;
});
};
return { autoScroll, handleAutoScrollToggle };
}
@@ -0,0 +1,64 @@
"use client";
import { useState, useCallback } from "react";
import { api } from "@/lib/api";
import { authenticatedFetch } from "@/lib/authenticatedFetch";
import { RuleResult } from "@/types";
const GUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
export function useSessionAnalysis(
sessionId: string,
sessionTenantId: string | null,
getAccessToken: (forceRefresh?: boolean) => Promise<string | null>
) {
const [analysisResults, setAnalysisResults] = useState<RuleResult[]>([]);
const [loadingAnalysis, setLoadingAnalysis] = useState(false);
const [vulnerabilityReport, setVulnerabilityReport] = useState<unknown>(null);
const fetchAnalysisResults = useCallback(async (reanalyze = false) => {
if (!sessionTenantId || !GUID_REGEX.test(sessionTenantId)) return;
try {
setLoadingAnalysis(true);
const response = await authenticatedFetch(
api.sessions.analysis(sessionId, sessionTenantId, reanalyze || undefined),
getAccessToken
);
if (response.ok) {
const data = await response.json();
if (data.results) {
setAnalysisResults(data.results.sort((a: RuleResult, b: RuleResult) => b.confidenceScore - a.confidenceScore));
}
}
} catch (error) {
console.error("Failed to fetch analysis results:", error);
} finally {
setLoadingAnalysis(false);
}
}, [sessionId, sessionTenantId, getAccessToken]);
const fetchVulnerabilityReport = useCallback(async (rescan = false) => {
if (!sessionTenantId || !GUID_REGEX.test(sessionTenantId)) return;
try {
const response = await authenticatedFetch(
api.sessions.vulnerabilityReport(sessionId, sessionTenantId, rescan || undefined),
getAccessToken
);
if (response.ok) {
const data = await response.json();
setVulnerabilityReport(data.report ?? null);
}
} catch (error) {
console.error("Failed to fetch vulnerability report:", error);
}
}, [sessionId, sessionTenantId, getAccessToken]);
return {
analysisResults,
loadingAnalysis,
vulnerabilityReport,
setVulnerabilityReport,
fetchAnalysisResults,
fetchVulnerabilityReport,
};
}
@@ -1,6 +1,8 @@
"use client";
import { useEffect, useState, useRef, useMemo } from "react";
import { useSessionAnalysis } from "./hooks/useSessionAnalysis";
import { useAutoScroll } from "./hooks/useAutoScroll";
import { useParams, useRouter } from "next/navigation";
import { useSignalR } from "../../../contexts/SignalRContext";
import { useTenant } from "../../../contexts/TenantContext";
@@ -11,7 +13,7 @@ import PerformanceChart from '../../../components/PerformanceChart';
import DownloadProgress from '../../../components/DownloadProgress';
import InstallProgress from '../../../components/InstallProgress';
import ScriptExecutions from '../../../components/ScriptExecutions';
import { API_BASE_URL } from "@/lib/config";
import { api } from "@/lib/api";
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
import { V1_PHASE_NAMES, V2_PHASE_NAMES, V1_PHASE_ORDER, V2_PHASE_ORDER } from "./utils/phaseConstants";
@@ -30,6 +32,7 @@ import DeviceDetailsCard from "./components/DeviceDetailsCard";
import { generateUiExport, generateCsvExport, generateSessionCsvExport, generateRuleResultsCsvExport, SessionExportEvent } from "@/lib/sessionExportUtils";
import { Session, EnrollmentEvent, RuleResult } from "@/types";
import { trackEvent } from "@/lib/appInsights";
import { useAdminMode } from "@/hooks/useAdminMode";
const GUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
@@ -49,27 +52,12 @@ export default function SessionDetailPage() {
const [reportSubmitting, setReportSubmitting] = useState(false);
const [showScriptOutput, setShowScriptOutput] = useState(true);
const [enableSoftwareInventoryAnalyzer, setEnableSoftwareInventoryAnalyzer] = useState(false);
const [analysisResults, setAnalysisResults] = useState<RuleResult[]>([]);
const [loadingAnalysis, setLoadingAnalysis] = useState(false);
const [analysisExpanded, setAnalysisExpanded] = useState(true);
const [vulnerabilityReport, setVulnerabilityReport] = useState<any>(null);
const [vulnerabilityReportExpanded, setVulnerabilityReportExpanded] = useState(false);
const [phaseTimelineExpanded, setPhaseTimelineExpanded] = useState(true);
const [perfExpanded, setPerfExpanded] = useState(true);
const [timelineExpanded, setTimelineExpanded] = useState(true);
const [autoScroll, setAutoScroll] = useState(false);
const [adminMode, setAdminMode] = useState(() => {
if (typeof window !== 'undefined') {
return localStorage.getItem('adminMode') === 'true';
}
return false;
});
const [globalAdminMode, setGlobalAdminMode] = useState(() => {
if (typeof window !== 'undefined') {
return localStorage.getItem('globalAdminMode') === 'true';
}
return false;
});
const { adminMode, setAdminMode, globalAdminMode, setGlobalAdminMode } = useAdminMode();
// Debounce real-time event refreshes to avoid burst reads in Table Storage.
const eventRefreshTimeoutRef = useRef<NodeJS.Timeout | null>(null);
@@ -88,19 +76,23 @@ export default function SessionDetailPage() {
const fetchEventsInFlight = useRef(false);
const fetchEventsQueued = useRef(false);
// Auto-scroll: tracks whether the user is near the bottom of the page
const isNearBottomRef = useRef(false);
// Auto-scroll: guards against race condition where content growth during
// a programmatic smooth-scroll temporarily moves the viewport away from
// the bottom, causing isNearBottomRef to flip false prematurely.
const isProgrammaticScrollRef = useRef(false);
// Use global contexts
const { on, off, isConnected, joinGroup, leaveGroup } = useSignalR();
const { tenantId } = useTenant();
const { getAccessToken, user } = useAuth();
const { addNotification } = useNotifications();
const {
analysisResults,
loadingAnalysis,
vulnerabilityReport,
setVulnerabilityReport,
fetchAnalysisResults,
fetchVulnerabilityReport,
} = useSessionAnalysis(sessionId, sessionTenantId, getAccessToken);
const { autoScroll, handleAutoScrollToggle } = useAutoScroll(events);
useEffect(() => {
sessionRef.current = session;
}, [session]);
@@ -117,52 +109,6 @@ export default function SessionDetailPage() {
globalAdminModeRef.current = globalAdminMode;
}, [globalAdminMode]);
// Auto-scroll: track whether user is near the bottom (only while feature is enabled).
// Uses a ref so scroll events don't cause re-renders.
useEffect(() => {
if (!autoScroll) return;
const handleScroll = () => {
const distanceFromBottom = document.body.scrollHeight - window.scrollY - window.innerHeight;
if (isProgrammaticScrollRef.current) {
// During a programmatic scroll animation, only allow flipping to true
// (we reached the bottom) — never flip to false (content grew mid-animation).
if (distanceFromBottom <= 150) isNearBottomRef.current = true;
} else {
isNearBottomRef.current = distanceFromBottom <= 150;
}
};
window.addEventListener('scroll', handleScroll, { passive: true });
return () => window.removeEventListener('scroll', handleScroll);
}, [autoScroll]);
// Auto-scroll: when new events arrive, scroll to bottom only if user is already near the bottom.
useEffect(() => {
if (!autoScroll || events.length === 0 || !isNearBottomRef.current) return;
isProgrammaticScrollRef.current = true;
const timer = setTimeout(() => {
window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
// Keep the guard up long enough for the smooth scroll animation to finish.
setTimeout(() => { isProgrammaticScrollRef.current = false; }, 600);
}, 150);
return () => { clearTimeout(timer); isProgrammaticScrollRef.current = false; };
}, [events, autoScroll]);
const handleAutoScrollToggle = () => {
setAutoScroll(prev => {
const next = !prev;
if (next) {
// Enabling: immediately scroll to bottom and mark as near-bottom
isNearBottomRef.current = true;
isProgrammaticScrollRef.current = true;
setTimeout(() => {
window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
setTimeout(() => { isProgrammaticScrollRef.current = false; }, 600);
}, 50);
}
return next;
});
};
const resolveEffectiveTenantId = () => {
const knownSessionTenant = sessionTenantIdRef.current || sessionRef.current?.tenantId || null;
if (knownSessionTenant) return knownSessionTenant;
@@ -214,7 +160,7 @@ export default function SessionDetailPage() {
(async () => {
try {
const res = await authenticatedFetch(
`${API_BASE_URL}/api/config/${sessionTenantId}`,
api.config.tenant(sessionTenantId),
getAccessToken
);
if (res.ok) {
@@ -347,10 +293,10 @@ export default function SessionDetailPage() {
try {
const knownTenantId = resolveEffectiveTenantId();
const endpoint = knownTenantId
? `${API_BASE_URL}/api/sessions/${sessionId}?tenantId=${knownTenantId}`
? api.sessions.get(sessionId, knownTenantId)
: globalAdminMode
? `${API_BASE_URL}/api/global/sessions`
: `${API_BASE_URL}/api/sessions/${sessionId}`;
? api.globalSessions.list()
: api.sessions.get(sessionId);
const response = await authenticatedFetch(endpoint, getAccessToken);
if (response.ok) {
@@ -392,7 +338,7 @@ export default function SessionDetailPage() {
try {
const response = await authenticatedFetch(
`${API_BASE_URL}/api/sessions/${sessionId}/events?tenantId=${effectiveTenantId}`,
api.sessions.events(sessionId, effectiveTenantId),
getAccessToken
);
if (response.ok) {
@@ -449,48 +395,6 @@ export default function SessionDetailPage() {
}
};
const fetchAnalysisResults = async (reanalyze = false) => {
const effectiveTenantId = resolveEffectiveTenantId();
if (!effectiveTenantId || !GUID_REGEX.test(effectiveTenantId)) return;
try {
setLoadingAnalysis(true);
const reanalyzeParam = reanalyze ? '&reanalyze=true' : '';
const response = await authenticatedFetch(
`${API_BASE_URL}/api/sessions/${sessionId}/analysis?tenantId=${effectiveTenantId}${reanalyzeParam}`,
getAccessToken
);
if (response.ok) {
const data = await response.json();
if (data.results) {
setAnalysisResults(data.results.sort((a: RuleResult, b: RuleResult) => b.confidenceScore - a.confidenceScore));
}
}
} catch (error) {
console.error("Failed to fetch analysis results:", error);
} finally {
setLoadingAnalysis(false);
}
};
const fetchVulnerabilityReport = async (rescan = false) => {
const effectiveTenantId = resolveEffectiveTenantId();
if (!effectiveTenantId || !GUID_REGEX.test(effectiveTenantId)) return;
try {
const rescanParam = rescan ? '&rescan=true' : '';
const response = await authenticatedFetch(
`${API_BASE_URL}/api/sessions/${sessionId}/vulnerability-report?tenantId=${effectiveTenantId}${rescanParam}`,
getAccessToken
);
if (response.ok) {
const data = await response.json();
setVulnerabilityReport(data.report ?? null);
}
} catch (error) {
console.error("Failed to fetch vulnerability report:", error);
}
};
const markAsFailed = () => {
setShowMarkFailedConfirm(true);
};
@@ -504,7 +408,7 @@ export default function SessionDetailPage() {
try {
const response = await authenticatedFetch(
`${API_BASE_URL}/api/sessions/${sessionId}/mark-failed?tenantId=${effectiveTenantId}`,
api.sessions.markFailed(sessionId, effectiveTenantId),
getAccessToken,
{ method: 'POST' }
);
@@ -527,7 +431,7 @@ export default function SessionDetailPage() {
try {
const response = await authenticatedFetch(
`${API_BASE_URL}/api/sessions/${sessionId}/mark-succeeded?tenantId=${effectiveTenantId}`,
api.sessions.markSucceeded(sessionId, effectiveTenantId),
getAccessToken,
{ method: 'POST' }
);
@@ -573,7 +477,7 @@ export default function SessionDetailPage() {
const ruleResultsCsv = generateRuleResultsCsvExport(analysisResults);
const response = await authenticatedFetch(
`${API_BASE_URL}/api/sessions/${sessionId}/report?tenantId=${effectiveTenantId}`,
api.sessions.report(sessionId, effectiveTenantId),
getAccessToken,
{
method: 'POST',
@@ -604,10 +508,11 @@ export default function SessionDetailPage() {
addNotification('error', 'Report Failed', message, 'report-error');
throw new Error(message);
}
} catch (err: any) {
} catch (err: unknown) {
// Re-throw so the modal can show inline error feedback.
// Only log unexpected errors (not the ones we threw ourselves above).
if (!err?.message?.includes('Failed to submit report') && !err?.message?.includes('Failed to get access token')) {
const errMessage = err instanceof Error ? err.message : '';
if (!errMessage.includes('Failed to submit report') && !errMessage.includes('Failed to get access token')) {
console.error('Error submitting report:', err);
}
throw err;
@@ -930,7 +835,7 @@ export default function SessionDetailPage() {
onClick={async () => {
try {
const res = await authenticatedFetch(
`${API_BASE_URL}/api/diagnostics/download-url?tenantId=${session.tenantId}&blobName=${encodeURIComponent(session.diagnosticsBlobName!)}`,
api.diagnostics.downloadUrl(session.tenantId, session.diagnosticsBlobName!),
getAccessToken
);
if (!res.ok) throw new Error('Failed to get download URL');
@@ -62,7 +62,8 @@ export function groupEventsByPhase(
return { eventsByPhase, orderedPhases };
}
export function normalizeJsonLikeValue(value: any): any {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function normalizeJsonLikeValue(value: unknown): any {
if (typeof value === "string") {
const trimmed = value.trim();
const looksLikeJson =
@@ -5,7 +5,7 @@ import { useRouter } from "next/navigation";
import { useTenant } from "../../contexts/TenantContext";
import { useAuth } from "../../contexts/AuthContext";
import { useNotifications } from "../../contexts/NotificationContext";
import { API_BASE_URL } from "@/lib/config";
import { api } from "@/lib/api";
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
import { trackEvent } from "@/lib/appInsights";
import { parseSasExpiry } from "./components/DiagnosticsSection";
@@ -278,7 +278,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
setLoading(true);
setError(null);
const response = await authenticatedFetch(`${API_BASE_URL}/api/config/${tenantId}`, getAccessToken);
const response = await authenticatedFetch(api.config.tenant(tenantId), getAccessToken);
if (!response.ok) {
throw new Error(`Failed to load configuration: ${response.statusText}`);
@@ -390,7 +390,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
if (!tenantId) return;
try {
setLoadingAdmins(true);
const response = await authenticatedFetch(`${API_BASE_URL}/api/tenants/${tenantId}/admins`, getAccessToken);
const response = await authenticatedFetch(api.tenants.admins(tenantId), getAccessToken);
if (!response.ok) {
throw new Error(`Failed to load admins: ${response.statusText}`);
}
@@ -422,7 +422,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
try {
setBootstrapLoading(true);
const response = await authenticatedFetch(
`${API_BASE_URL}/api/bootstrap/sessions?tenantId=${tenantId}`,
api.bootstrap.sessions(tenantId),
getAccessToken,
);
if (response.ok) {
@@ -448,7 +448,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
if (!user?.isGlobalAdmin) return;
const fetchGlobalDiagPaths = async () => {
try {
const res = await authenticatedFetch(`${API_BASE_URL}/api/global/config`, getAccessToken);
const res = await authenticatedFetch(api.globalConfig.get(), getAccessToken);
if (!res.ok) return;
const data = await res.json();
if (data.diagnosticsGlobalLogPathsJson) {
@@ -516,7 +516,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
unrestrictedMode,
};
const response = await authenticatedFetch(`${API_BASE_URL}/api/config/${tenantId}`, getAccessToken, {
const response = await authenticatedFetch(api.config.tenant(tenantId), getAccessToken, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(updatedConfig),
@@ -571,7 +571,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
const redirectUri = `${window.location.origin}/settings/validation/autopilot`;
const response = await authenticatedFetch(
`${API_BASE_URL}/api/config/${tenantId}/autopilot-device-validation/consent-url?redirectUri=${encodeURIComponent(redirectUri)}`,
api.config.autopilotConsentUrl(tenantId, redirectUri),
getAccessToken,
);
@@ -633,7 +633,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
setAutopilotConsentInProgress(true);
const statusResponse = await authenticatedFetch(
`${API_BASE_URL}/api/config/${tenantId}/autopilot-device-validation/consent-status`,
api.config.autopilotConsentStatus(tenantId),
getAccessToken,
);
@@ -677,7 +677,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
setTestingWebhook(true);
setTestWebhookResult(null);
try {
const response = await authenticatedFetch(`${API_BASE_URL}/api/config/${tenantId}/test-notification`, getAccessToken, {
const response = await authenticatedFetch(api.config.testNotification(tenantId), getAccessToken, {
method: "POST",
});
const data = await response.json();
@@ -780,7 +780,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
setError(null);
setSuccessMessage(null);
const response = await authenticatedFetch(`${API_BASE_URL}/api/tenants/${tenantId}/admins`, getAccessToken, {
const response = await authenticatedFetch(api.tenants.admins(tenantId), getAccessToken, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ upn: newAdminEmail.trim(), role: newMemberRole }),
@@ -820,7 +820,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
setError(null);
setSuccessMessage(null);
const response = await authenticatedFetch(`${API_BASE_URL}/api/tenants/${tenantId}/admins/${encodeURIComponent(adminUpn)}`, getAccessToken, {
const response = await authenticatedFetch(api.tenants.admin(tenantId, adminUpn), getAccessToken, {
method: "DELETE",
});
@@ -857,7 +857,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
setSuccessMessage(null);
const response = await authenticatedFetch(
`${API_BASE_URL}/api/tenants/${tenantId}/admins/${encodeURIComponent(adminUpn)}/${action}`,
api.tenants.adminAction(tenantId, adminUpn, action),
getAccessToken,
{ method: "PATCH" },
);
@@ -894,7 +894,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
setSuccessMessage(null);
const response = await authenticatedFetch(
`${API_BASE_URL}/api/tenants/${tenantId}/admins/${encodeURIComponent(adminUpn)}/permissions`,
api.tenants.adminPermissions(tenantId, adminUpn),
getAccessToken,
{
method: "PATCH",
@@ -932,7 +932,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
const createBootstrapSession = useCallback(async (validityHours: number, label: string): Promise<string | null> => {
if (!tenantId) return null;
try {
const response = await authenticatedFetch(`${API_BASE_URL}/api/bootstrap/sessions`, getAccessToken, {
const response = await authenticatedFetch(api.bootstrap.sessions(), getAccessToken, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ tenantId, validityHours, label }),
@@ -961,7 +961,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
if (!tenantId) return;
try {
const response = await authenticatedFetch(
`${API_BASE_URL}/api/bootstrap/sessions/${code}?tenantId=${tenantId}`,
api.bootstrap.session(code, tenantId),
getAccessToken,
{ method: "DELETE" },
);
@@ -991,7 +991,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
setOffboarding(true);
setOffboardError(null);
const response = await authenticatedFetch(`${API_BASE_URL}/api/tenants/${tenantId}/offboard`, getAccessToken, {
const response = await authenticatedFetch(api.tenants.offboard(tenantId), getAccessToken, {
method: 'DELETE',
});
@@ -1,7 +1,7 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { API_BASE_URL } from "@/lib/config";
import { api } from "@/lib/api";
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
interface ApiKey {
@@ -43,7 +43,7 @@ export default function ApiKeysSection({
const loadKeys = useCallback(async () => {
try {
setLoading(true);
const endpoint = isGlobalAdmin ? `${API_BASE_URL}/api/global/api-keys` : `${API_BASE_URL}/api/api-keys`;
const endpoint = isGlobalAdmin ? api.apiKeys.globalList() : api.apiKeys.list();
const res = await authenticatedFetch(endpoint, getAccessToken);
if (!res.ok) throw new Error(`Failed to load API keys: ${res.statusText}`);
const data = await res.json();
@@ -66,7 +66,7 @@ export default function ApiKeysSection({
const body: Record<string, unknown> = { label: newLabel.trim(), scope: newScope };
if (newExpiryDays && parseInt(newExpiryDays) > 0) body.expiresInDays = parseInt(newExpiryDays);
if (newScope === "tenant" && newTenantId) body.tenantIdOverride = newTenantId;
const res = await authenticatedFetch(`${API_BASE_URL}/api/api-keys`, getAccessToken, {
const res = await authenticatedFetch(api.apiKeys.list(), getAccessToken, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
@@ -91,7 +91,7 @@ export default function ApiKeysSection({
try {
setRevokingKey(keyId);
setError(null);
const res = await authenticatedFetch(`${API_BASE_URL}/api/api-keys/${keyId}`, getAccessToken, { method: "DELETE" });
const res = await authenticatedFetch(api.apiKeys.delete(keyId), getAccessToken, { method: "DELETE" });
if (!res.ok) throw new Error(`Failed to revoke: ${res.statusText}`);
setSuccessMessage("API key revoked successfully");
await loadKeys();
@@ -6,8 +6,9 @@ import { useTenant } from '../../contexts/TenantContext';
import { useAuth } from '../../contexts/AuthContext';
import { useNotifications } from '../../contexts/NotificationContext';
import { ProtectedRoute } from '../../components/ProtectedRoute';
import { API_BASE_URL } from '@/lib/config';
import { api } from "@/lib/api";
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
import { useAdminMode } from "@/hooks/useAdminMode";
interface TenantInfo {
tenantId: string;
@@ -87,12 +88,7 @@ export default function UsageMetricsPage() {
const [refreshing, setRefreshing] = useState(false);
// Global admin mode
const [globalAdminMode] = useState(() => {
if (typeof window !== 'undefined') {
return localStorage.getItem('globalAdminMode') === 'true';
}
return false;
});
const { globalAdminMode } = useAdminMode();
const [tenants, setTenants] = useState<TenantInfo[]>([]);
const [selectedTenantId, setSelectedTenantId] = useState<string>('');
@@ -101,7 +97,7 @@ export default function UsageMetricsPage() {
if (!globalAdminMode || !user?.isGlobalAdmin) return;
const fetchTenants = async () => {
try {
const response = await authenticatedFetch(`${API_BASE_URL}/api/config/all`, getAccessToken);
const response = await authenticatedFetch(api.config.all(), getAccessToken);
if (response.ok) {
const data = await response.json();
const mapped: TenantInfo[] = data.map((t: { tenantId: string; domainName: string }) => ({
@@ -143,8 +139,8 @@ export default function UsageMetricsPage() {
// Global admin viewing another tenant → use global endpoint
const url = isGlobalOverride
? `${API_BASE_URL}/api/global/metrics/usage?tenantId=${effectiveTenantId}`
: `${API_BASE_URL}/api/metrics/usage?tenantId=${effectiveTenantId}`;
? api.metrics.globalUsage(effectiveTenantId)
: api.metrics.usage(effectiveTenantId);
const response = await authenticatedFetch(url, getAccessToken);
@@ -2,7 +2,7 @@
import { useCallback, useEffect, useState } from "react";
import { useAuth } from "@/contexts/AuthContext";
import { API_BASE_URL } from "@/lib/config";
import { api } from "@/lib/api";
import { authenticatedFetch } from "@/lib/authenticatedFetch";
type Phase = "loading" | "bubble" | "form" | "thankyou" | "hidden";
@@ -24,7 +24,7 @@ export default function FeedbackBubble() {
const checkEligibility = async () => {
try {
const response = await authenticatedFetch(
`${API_BASE_URL}/api/feedback/status`,
api.feedback.status(),
getAccessToken
);
if (cancelled) return;
@@ -54,7 +54,7 @@ export default function FeedbackBubble() {
const handleDismiss = useCallback(async () => {
setPhase("hidden");
try {
await authenticatedFetch(`${API_BASE_URL}/api/feedback`, getAccessToken, {
await authenticatedFetch(api.feedback.submit(), getAccessToken, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ dismissed: true }),
@@ -69,7 +69,7 @@ export default function FeedbackBubble() {
setSubmitting(true);
try {
await authenticatedFetch(`${API_BASE_URL}/api/feedback`, getAccessToken, {
await authenticatedFetch(api.feedback.submit(), getAccessToken, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ rating, comment: comment.trim() || null, dismissed: false }),
@@ -9,6 +9,7 @@ import { CollapseState } from "../hooks/useSidebarState";
import { DefaultSectionIcon, BookOpenIcon, RocketLaunchIcon, InformationCircleIcon, DocumentTextIcon, ShieldCheckIcon } from "../lib/sidebarIcons";
import { DASHBOARD_ITEM, NAV_GROUPS, EXPANDABLE_NAV_GROUPS, REGULAR_USER_ITEMS, NavItem, NavGroup, ExpandableNavGroup, ExpandableNavItem } from "../lib/globalNavConfig";
import { PublicSiteNavbar } from "./PublicSiteNavbar";
import { useAdminMode } from "../hooks/useAdminMode";
// Sidebar pixel widths
export const SIDEBAR_PX: Record<CollapseState, number> = {
@@ -45,19 +46,8 @@ export function GlobalSidebar({ children }: { children: ReactNode }) {
return () => mql.removeEventListener("change", handler);
}, []);
// Global admin mode from localStorage (same pattern as Navbar)
const [globalAdminMode, setGlobalAdminMode] = useState(false);
useEffect(() => {
if (typeof window === "undefined") return;
const read = () => setGlobalAdminMode(localStorage.getItem("globalAdminMode") === "true");
read();
window.addEventListener("localStorageChange", read);
window.addEventListener("storage", read);
return () => {
window.removeEventListener("localStorageChange", read);
window.removeEventListener("storage", read);
};
}, []);
// Global admin mode from hook
const { globalAdminMode } = useAdminMode();
// --- Scroll-spy for page sections ---
const [activeSectionId, setActiveSectionId] = useState("");
@@ -6,9 +6,9 @@ import { useGlobalNotifications } from '@/contexts/GlobalNotificationContext';
import { useTheme } from '@/contexts/ThemeContext';
import { useState, useRef, useEffect } from 'react';
import Link from 'next/link';
import { API_BASE_URL } from '@/lib/config';
import { usePathname } from 'next/navigation';
import { trackEvent } from '@/lib/appInsights';
import { useAdminMode } from '@/hooks/useAdminMode';
export default function Navbar() {
const { isAuthenticated, user, logout } = useAuth();
@@ -21,18 +21,7 @@ export default function Navbar() {
const [showUserMenu, setShowUserMenu] = useState(false);
const [showSettings, setShowSettings] = useState(false);
const [showHelp, setShowHelp] = useState(false);
const [adminMode, setAdminMode] = useState(() => {
if (typeof window !== 'undefined') {
return localStorage.getItem('adminMode') === 'true';
}
return false;
});
const [globalAdminMode, setGlobalAdminMode] = useState(() => {
if (typeof window !== 'undefined') {
return localStorage.getItem('globalAdminMode') === 'true';
}
return false;
});
const { adminMode, setAdminMode, globalAdminMode, setGlobalAdminMode } = useAdminMode();
const [previewMode, setPreviewMode] = useState(() => {
if (typeof window !== 'undefined') {
return localStorage.getItem('previewMode') === 'true';
@@ -45,21 +34,6 @@ export default function Navbar() {
const settingsRef = useRef<HTMLDivElement>(null);
const helpRef = useRef<HTMLDivElement>(null);
// Update localStorage when modes change and dispatch custom event
useEffect(() => {
if (typeof window !== 'undefined') {
localStorage.setItem('adminMode', adminMode.toString());
window.dispatchEvent(new Event('localStorageChange'));
}
}, [adminMode]);
useEffect(() => {
if (typeof window !== 'undefined') {
localStorage.setItem('globalAdminMode', globalAdminMode.toString());
window.dispatchEvent(new Event('localStorageChange'));
}
}, [globalAdminMode]);
useEffect(() => {
if (typeof window !== 'undefined') {
localStorage.setItem('previewMode', previewMode.toString());
@@ -4,7 +4,7 @@ import React, { createContext, useContext, useEffect, useState, useCallback, use
import { PublicClientApplication, AccountInfo, InteractionStatus, InteractionRequiredAuthError, BrowserAuthError } from '@azure/msal-browser';
import { MsalProvider, useMsal, useIsAuthenticated } from '@azure/msal-react';
import { msalConfig, loginRequest, apiRequest } from '@/lib/msalConfig';
import { API_BASE_URL } from '@/lib/config';
import { api } from '@/lib/api';
// Initialize MSAL instance
const msalInstance = new PublicClientApplication(msalConfig);
@@ -124,7 +124,7 @@ function AuthProviderInternal({ children }: { children: React.ReactNode }) {
const authMeTimeout = setTimeout(() => authMeController.abort(), 8000);
let response: Response;
try {
response = await fetch(`${API_BASE_URL}/api/auth/me`, {
response = await fetch(api.auth.me(), {
headers: {
'Authorization': `Bearer ${tokenResponse.accessToken}`,
},
@@ -269,10 +269,10 @@ function AuthProviderInternal({ children }: { children: React.ReactNode }) {
try {
await instance.loginRedirect(loginRequest);
} catch (error: any) {
} catch (error: unknown) {
// Ignore interaction_in_progress errors - this can happen if user clicks button multiple times
// or if another part of the app already triggered a redirect.
if (error?.errorCode === 'interaction_in_progress') {
if (error instanceof Error && 'errorCode' in error && error.errorCode === 'interaction_in_progress') {
console.log('[Auth] Interaction already in progress, ignoring duplicate login attempt');
return;
}
@@ -2,7 +2,7 @@
import React, { createContext, useContext, useState, useCallback, useEffect, useRef } from 'react';
import { useAuth } from './AuthContext';
import { API_BASE_URL } from '@/lib/config';
import { api } from '@/lib/api';
import { authenticatedFetch } from '@/lib/authenticatedFetch';
export interface GlobalNotification {
@@ -41,7 +41,7 @@ export function GlobalNotificationProvider({ children }: { children: React.React
try {
const response = await authenticatedFetch(
`${API_BASE_URL}/api/global/notifications`,
api.notifications.list(),
getAccessToken,
);
if (response.ok) {
@@ -89,7 +89,7 @@ export function GlobalNotificationProvider({ children }: { children: React.React
try {
await authenticatedFetch(
`${API_BASE_URL}/api/global/notifications/${id}/dismiss`,
api.notifications.dismiss(id),
getAccessToken,
{ method: 'POST' },
);
@@ -103,7 +103,7 @@ export function GlobalNotificationProvider({ children }: { children: React.React
try {
await authenticatedFetch(
`${API_BASE_URL}/api/global/notifications/dismiss-all`,
api.notifications.dismissAll(),
getAccessToken,
{ method: 'POST' },
);
@@ -2,7 +2,7 @@
import React, { createContext, useContext, useEffect, useRef, useState, useCallback } from 'react';
import * as signalR from '@microsoft/signalr';
import { API_BASE_URL } from '@/lib/config';
import { api } from '@/lib/api';
import { authenticatedFetch } from '@/lib/authenticatedFetch';
import { trackEvent } from '@/lib/appInsights';
import { useAuth } from './AuthContext';
@@ -47,7 +47,7 @@ export function SignalRProvider({ children }: { children: React.ReactNode }) {
return;
}
const hubUrl = `${API_BASE_URL}/api/realtime`;
const hubUrl = api.realtime.hub();
const newConnection = new signalR.HubConnectionBuilder()
.withUrl(hubUrl, {
accessTokenFactory: async () => {
@@ -94,7 +94,7 @@ export function SignalRProvider({ children }: { children: React.ReactNode }) {
for (const groupName of previousGroups) {
try {
const response = await authenticatedFetch(
`${API_BASE_URL}/api/realtime/groups/join`,
api.realtime.joinGroup(),
getAccessToken,
{
method: 'POST',
@@ -196,7 +196,7 @@ export function SignalRProvider({ children }: { children: React.ReactNode }) {
}
const response = await authenticatedFetch(
`${API_BASE_URL}/api/realtime/groups/join`,
api.realtime.joinGroup(),
getAccessToken,
{
method: 'POST',
@@ -239,7 +239,7 @@ export function SignalRProvider({ children }: { children: React.ReactNode }) {
}
const response = await authenticatedFetch(
`${API_BASE_URL}/api/realtime/groups/leave`,
api.realtime.leaveGroup(),
getAccessToken,
{
method: 'POST',
@@ -1,3 +1,4 @@
export { useAdminMode } from "./useAdminMode";
export { useAuthenticatedFetch } from "./useAuthenticatedFetch";
export type {
UseAuthenticatedFetchOptions,
@@ -0,0 +1,81 @@
"use client";
import { useState, useEffect } from "react";
interface UseAdminModeReturn {
adminMode: boolean;
setAdminMode: (value: boolean) => void;
globalAdminMode: boolean;
setGlobalAdminMode: (value: boolean) => void;
}
/**
* Manages adminMode and globalAdminMode state with localStorage persistence.
* Changes are persisted to localStorage and broadcast via a 'localStorageChange'
* custom event so all components on the same page stay in sync.
* Cross-tab synchronization uses the native 'storage' event.
*/
export function useAdminMode(): UseAdminModeReturn {
const [adminMode, setAdminModeState] = useState<boolean>(() => {
if (typeof window !== "undefined") {
return localStorage.getItem("adminMode") === "true";
}
return false;
});
const [globalAdminMode, setGlobalAdminModeState] = useState<boolean>(() => {
if (typeof window !== "undefined") {
return localStorage.getItem("globalAdminMode") === "true";
}
return false;
});
// Persist to localStorage and notify same-tab listeners on change
useEffect(() => {
localStorage.setItem("adminMode", adminMode.toString());
window.dispatchEvent(new Event("localStorageChange"));
}, [adminMode]);
useEffect(() => {
localStorage.setItem("globalAdminMode", globalAdminMode.toString());
window.dispatchEvent(new Event("localStorageChange"));
}, [globalAdminMode]);
// Sync from external changes (cross-tab via 'storage', same-tab via 'localStorageChange')
useEffect(() => {
const handleStorageChange = (e: StorageEvent) => {
if (e.key === "adminMode" && e.newValue !== null) {
setAdminModeState(e.newValue === "true");
}
if (e.key === "globalAdminMode" && e.newValue !== null) {
setGlobalAdminModeState(e.newValue === "true");
}
};
const handleCustomStorageChange = () => {
const newAdminMode = localStorage.getItem("adminMode") === "true";
const newGlobalMode = localStorage.getItem("globalAdminMode") === "true";
setAdminModeState((prev) => (prev !== newAdminMode ? newAdminMode : prev));
setGlobalAdminModeState((prev) =>
prev !== newGlobalMode ? newGlobalMode : prev
);
};
window.addEventListener("storage", handleStorageChange);
window.addEventListener("localStorageChange", handleCustomStorageChange);
return () => {
window.removeEventListener("storage", handleStorageChange);
window.removeEventListener(
"localStorageChange",
handleCustomStorageChange
);
};
}, []);
return {
adminMode,
setAdminMode: setAdminModeState,
globalAdminMode,
setGlobalAdminMode: setGlobalAdminModeState,
};
}
+238
View File
@@ -0,0 +1,238 @@
/**
* Typed API URL builder
* All backend endpoint URLs are defined here for type-safety and maintainability.
*/
import { API_BASE_URL } from "./config";
function qs(params: Record<string, string | undefined>): string {
const p = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== "") p.set(key, value);
}
const str = p.toString();
return str ? `?${str}` : "";
}
export const api = {
// ── Auth ──────────────────────────────────────────────────────────────────
auth: {
me: () => `${API_BASE_URL}/api/auth/me`,
},
// ── Sessions ──────────────────────────────────────────────────────────────
sessions: {
list: (tenantId?: string) =>
`${API_BASE_URL}/api/sessions${qs({ tenantId })}`,
get: (sessionId: string, tenantId?: string) =>
`${API_BASE_URL}/api/sessions/${sessionId}${qs({ tenantId })}`,
events: (sessionId: string, tenantId?: string) =>
`${API_BASE_URL}/api/sessions/${sessionId}/events${qs({ tenantId })}`,
delete: (sessionId: string, tenantId: string) =>
`${API_BASE_URL}/api/sessions/${sessionId}${qs({ tenantId })}`,
analysis: (sessionId: string, tenantId?: string, reanalyze?: boolean) =>
`${API_BASE_URL}/api/sessions/${sessionId}/analysis${qs({ tenantId, ...(reanalyze ? { reanalyze: "true" } : {}) })}`,
vulnerabilityReport: (sessionId: string, tenantId?: string, rescan?: boolean) =>
`${API_BASE_URL}/api/sessions/${sessionId}/vulnerability-report${qs({ tenantId, ...(rescan ? { rescan: "true" } : {}) })}`,
markFailed: (sessionId: string, tenantId?: string) =>
`${API_BASE_URL}/api/sessions/${sessionId}/mark-failed${qs({ tenantId })}`,
markSucceeded: (sessionId: string, tenantId?: string) =>
`${API_BASE_URL}/api/sessions/${sessionId}/mark-succeeded${qs({ tenantId })}`,
report: (sessionId: string, tenantId?: string) =>
`${API_BASE_URL}/api/sessions/${sessionId}/report${qs({ tenantId })}`,
},
// ── Global Sessions (global admin) ────────────────────────────────────────
globalSessions: {
list: (tenantId?: string) =>
`${API_BASE_URL}/api/global/sessions${qs({ tenantId })}`,
},
// ── Config ────────────────────────────────────────────────────────────────
config: {
all: () => `${API_BASE_URL}/api/config/all`,
tenant: (tenantId: string) => `${API_BASE_URL}/api/config/${tenantId}`,
autopilotConsentUrl: (tenantId: string, redirectUri: string) =>
`${API_BASE_URL}/api/config/${tenantId}/autopilot-device-validation/consent-url${qs({ redirectUri })}`,
autopilotConsentStatus: (tenantId: string) =>
`${API_BASE_URL}/api/config/${tenantId}/autopilot-device-validation/consent-status`,
testNotification: (tenantId: string) =>
`${API_BASE_URL}/api/config/${tenantId}/test-notification`,
},
// ── Global Config (global admin) ──────────────────────────────────────────
globalConfig: {
get: () => `${API_BASE_URL}/api/global/config`,
tenant: (tenantId: string) => `${API_BASE_URL}/api/global/config/${tenantId}`,
},
// ── Tenants ───────────────────────────────────────────────────────────────
tenants: {
admins: (tenantId: string) =>
`${API_BASE_URL}/api/tenants/${tenantId}/admins`,
admin: (tenantId: string, adminUpn: string) =>
`${API_BASE_URL}/api/tenants/${tenantId}/admins/${encodeURIComponent(adminUpn)}`,
adminAction: (tenantId: string, adminUpn: string, action: string) =>
`${API_BASE_URL}/api/tenants/${tenantId}/admins/${encodeURIComponent(adminUpn)}/${action}`,
adminPermissions: (tenantId: string, adminUpn: string) =>
`${API_BASE_URL}/api/tenants/${tenantId}/admins/${encodeURIComponent(adminUpn)}/permissions`,
offboard: (tenantId: string) =>
`${API_BASE_URL}/api/tenants/${tenantId}/offboard`,
},
// ── Devices ───────────────────────────────────────────────────────────────
devices: {
blocked: (tenantId: string) =>
`${API_BASE_URL}/api/devices/blocked${qs({ tenantId })}`,
block: () => `${API_BASE_URL}/api/devices/block`,
unblock: (serialNumber: string, tenantId: string) =>
`${API_BASE_URL}/api/devices/block/${encodeURIComponent(serialNumber)}${qs({ tenantId })}`,
allBlocked: () => `${API_BASE_URL}/api/global/devices/blocked`,
},
// ── Versions ──────────────────────────────────────────────────────────────
versions: {
blocked: () => `${API_BASE_URL}/api/versions/blocked`,
block: () => `${API_BASE_URL}/api/versions/block`,
unblock: (pattern: string) =>
`${API_BASE_URL}/api/versions/block/${encodeURIComponent(pattern)}`,
},
// ── Rules ─────────────────────────────────────────────────────────────────
rules: {
analyze: () => `${API_BASE_URL}/api/rules/analyze`,
analyzeRule: (ruleId: string) =>
`${API_BASE_URL}/api/rules/analyze/${encodeURIComponent(ruleId)}`,
globalAnalyze: (tenantId?: string) =>
`${API_BASE_URL}/api/global/rules/analyze${qs({ tenantId })}`,
gather: (tenantId?: string) =>
`${API_BASE_URL}/api/rules/gather${qs({ tenantId })}`,
gatherRule: (ruleId: string, tenantId?: string) =>
`${API_BASE_URL}/api/rules/gather/${ruleId}${qs({ tenantId })}`,
globalGather: (tenantId?: string) =>
`${API_BASE_URL}/api/global/rules/gather${qs({ tenantId })}`,
reseedFromGitHub: (type: "analyze" | "gather" | "ime" | "all") =>
`${API_BASE_URL}/api/rules/reseed-from-github${qs({ type })}`,
imeLogPatterns: () => `${API_BASE_URL}/api/rules/ime-log-patterns`,
imeLogPattern: (patternId: string) =>
`${API_BASE_URL}/api/rules/ime-log-patterns/${encodeURIComponent(patternId)}${qs({ global: "true" })}`,
},
// ── Metrics ───────────────────────────────────────────────────────────────
metrics: {
usage: (tenantId?: string) =>
`${API_BASE_URL}/api/metrics/usage${qs({ tenantId })}`,
globalUsage: (tenantId?: string) =>
`${API_BASE_URL}/api/global/metrics/usage${qs({ tenantId })}`,
app: (tenantId: string, days: number) =>
`${API_BASE_URL}/api/metrics/app${qs({ tenantId, days: String(days) })}`,
globalApp: (days: number) =>
`${API_BASE_URL}/api/global/metrics/app${qs({ days: String(days) })}`,
geographic: (tenantId: string, days: number, groupBy: string) =>
`${API_BASE_URL}/api/metrics/geographic${qs({ tenantId, days: String(days), groupBy })}`,
globalGeographic: (days: number, groupBy: string) =>
`${API_BASE_URL}/api/global/metrics/geographic${qs({ days: String(days), groupBy })}`,
geographicSessions: (tenantId: string, days: number, groupBy: string, locationKey: string) =>
`${API_BASE_URL}/api/metrics/geographic/sessions${qs({ tenantId, days: String(days), groupBy, locationKey })}`,
globalGeographicSessions: (days: number, groupBy: string, locationKey: string) =>
`${API_BASE_URL}/api/global/metrics/geographic/sessions${qs({ days: String(days), groupBy, locationKey })}`,
platform: () => `${API_BASE_URL}/api/global/metrics/platform`,
},
// ── Diagnostics ───────────────────────────────────────────────────────────
diagnostics: {
downloadUrl: (tenantId: string, blobName: string) =>
`${API_BASE_URL}/api/diagnostics/download-url${qs({ tenantId, blobName })}`,
},
// ── Progress ──────────────────────────────────────────────────────────────
progress: {
sessions: (tenantId: string) =>
`${API_BASE_URL}/api/progress/sessions${qs({ tenantId })}`,
sessionEvents: (sessionId: string, tenantId: string) =>
`${API_BASE_URL}/api/progress/sessions/${sessionId}/events${qs({ tenantId })}`,
},
// ── Bootstrap ─────────────────────────────────────────────────────────────
bootstrap: {
sessions: (tenantId?: string) =>
`${API_BASE_URL}/api/bootstrap/sessions${qs({ tenantId })}`,
session: (code: string, tenantId: string) =>
`${API_BASE_URL}/api/bootstrap/sessions/${code}${qs({ tenantId })}`,
},
// ── Reports ───────────────────────────────────────────────────────────────
reports: {
list: () => `${API_BASE_URL}/api/global/session-reports`,
downloadUrl: (blobName: string) =>
`${API_BASE_URL}/api/global/session-reports/download-url${qs({ blobName })}`,
note: (reportId: string) =>
`${API_BASE_URL}/api/global/session-reports/${reportId}/note`,
},
// ── Notifications ─────────────────────────────────────────────────────────
notifications: {
list: () => `${API_BASE_URL}/api/global/notifications`,
dismiss: (id: string) => `${API_BASE_URL}/api/global/notifications/${id}/dismiss`,
dismissAll: () => `${API_BASE_URL}/api/global/notifications/dismiss-all`,
},
// ── Audit ─────────────────────────────────────────────────────────────────
audit: {
logs: () => `${API_BASE_URL}/api/audit/logs`,
globalLogs: () => `${API_BASE_URL}/api/global/audit/logs`,
},
// ── API Keys ──────────────────────────────────────────────────────────────
apiKeys: {
list: () => `${API_BASE_URL}/api/api-keys`,
globalList: () => `${API_BASE_URL}/api/global/api-keys`,
delete: (keyId: string) => `${API_BASE_URL}/api/api-keys/${keyId}`,
},
// ── Feedback ──────────────────────────────────────────────────────────────
feedback: {
status: () => `${API_BASE_URL}/api/feedback/status`,
submit: () => `${API_BASE_URL}/api/feedback`,
all: () => `${API_BASE_URL}/api/feedback/all`,
},
// ── Preview ───────────────────────────────────────────────────────────────
preview: {
whitelist: () => `${API_BASE_URL}/api/preview/whitelist`,
whitelistTenant: (tenantId: string) =>
`${API_BASE_URL}/api/preview/whitelist/${tenantId}`,
sendWelcomeEmail: (tenantId: string) =>
`${API_BASE_URL}/api/preview/send-welcome-email/${tenantId}`,
notificationEmail: () => `${API_BASE_URL}/api/preview/notification-email`,
notificationEmailTenant: (tenantId: string) =>
`${API_BASE_URL}/api/preview/notification-email/${tenantId}`,
},
// ── Vulnerability ─────────────────────────────────────────────────────────
vulnerability: {
sync: () => `${API_BASE_URL}/api/vulnerability/sync`,
syncReseed: (type: string) =>
`${API_BASE_URL}/api/vulnerability/sync${qs({ reseed: type })}`,
cpeMappings: () => `${API_BASE_URL}/api/vulnerability/cpe-mappings`,
cpeMapping: () => `${API_BASE_URL}/api/vulnerability/cpe-mapping`,
unmatchedSoftware: () => `${API_BASE_URL}/api/vulnerability/unmatched-software`,
},
// ── Maintenance ───────────────────────────────────────────────────────────
maintenance: {
trigger: (date?: string) =>
`${API_BASE_URL}/api/maintenance/trigger${qs({ date })}`,
},
// ── Health ────────────────────────────────────────────────────────────────
health: {
detailed: () => `${API_BASE_URL}/api/health/detailed`,
},
// ── Realtime (SignalR) ────────────────────────────────────────────────────
realtime: {
hub: () => `${API_BASE_URL}/api/realtime`,
joinGroup: () => `${API_BASE_URL}/api/realtime/groups/join`,
leaveGroup: () => `${API_BASE_URL}/api/realtime/groups/leave`,
},
};
File diff suppressed because one or more lines are too long