mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
Refactorings for many reusables, API URL builder, CollectorBaseClass, CorrelationID Middleware, added Polly for external APIs
This commit is contained in:
+74
-113
@@ -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); }
|
||||
}
|
||||
}
|
||||
}
|
||||
+174
-205
@@ -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>
|
||||
|
||||
|
||||
+5
-20
@@ -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);
|
||||
|
||||
+32
-66
@@ -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);
|
||||
|
||||
+7
-13
@@ -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
|
||||
|
||||
+7
-13
@@ -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
|
||||
|
||||
+7
-13
@@ -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
|
||||
|
||||
+12
-19
@@ -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}");
|
||||
|
||||
+5
-11
@@ -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 ||
|
||||
|
||||
+6
-14
@@ -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)
|
||||
|
||||
+7
-13
@@ -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);
|
||||
|
||||
+7
-13
@@ -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);
|
||||
|
||||
+17
-22
@@ -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);
|
||||
}
|
||||
|
||||
+4
-9
@@ -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)
|
||||
{
|
||||
|
||||
+19
-31
@@ -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)
|
||||
{
|
||||
|
||||
+1
-3
@@ -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)
|
||||
{
|
||||
|
||||
+1
-3
@@ -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)
|
||||
{
|
||||
|
||||
+10
-15
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+7
-42
@@ -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}`);
|
||||
|
||||
+3
-3
@@ -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
|
||||
);
|
||||
|
||||
|
||||
+31
-20
@@ -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>
|
||||
|
||||
+2
-2
@@ -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.');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
+34
-29
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user