enhanced device block management

This commit is contained in:
Oliver Kieselbach
2026-03-02 22:54:41 +01:00
parent c06205e5c7
commit 209f080b43
5 changed files with 165 additions and 5 deletions
@@ -1 +1 @@
190
191
@@ -0,0 +1,68 @@
using System;
using System.Net;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Services;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions
{
public class GetAllBlockedDevicesFunction
{
private readonly ILogger<GetAllBlockedDevicesFunction> _logger;
private readonly BlockedDeviceService _blockedDeviceService;
private readonly GalacticAdminService _galacticAdminService;
public GetAllBlockedDevicesFunction(
ILogger<GetAllBlockedDevicesFunction> logger,
BlockedDeviceService blockedDeviceService,
GalacticAdminService galacticAdminService)
{
_logger = logger;
_blockedDeviceService = blockedDeviceService;
_galacticAdminService = galacticAdminService;
}
/// <summary>GET /api/galactic/devices/blocked — list all active blocks across all tenants</summary>
[Function("GetAllBlockedDevices")]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "galactic/devices/blocked")] HttpRequestData req)
{
_logger.LogInformation("GetAllBlockedDevices function processing request (Galactic Admin Mode)");
try
{
if (!TenantHelper.IsAuthenticated(req))
{
var unauthorizedResponse = req.CreateResponse(HttpStatusCode.Unauthorized);
await unauthorizedResponse.WriteAsJsonAsync(new { success = false, message = "Authentication required." });
return unauthorizedResponse;
}
var userEmail = TenantHelper.GetUserIdentifier(req);
if (!await _galacticAdminService.IsGalacticAdminAsync(userEmail))
{
_logger.LogWarning("Non-Galactic Admin user {User} attempted to access GetAllBlockedDevices", userEmail);
var forbiddenResponse = req.CreateResponse(HttpStatusCode.Forbidden);
await forbiddenResponse.WriteAsJsonAsync(new { success = false, message = "Access denied. Galactic Admin role required." });
return forbiddenResponse;
}
var blocked = await _blockedDeviceService.GetAllBlockedDevicesAsync();
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new { success = true, blocked });
return response;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting all blocked devices");
var errorResponse = req.CreateResponse(HttpStatusCode.InternalServerError);
await errorResponse.WriteAsJsonAsync(new { success = false, message = "Internal server error" });
return errorResponse;
}
}
}
}
@@ -157,6 +157,50 @@ namespace AutopilotMonitor.Functions.Services
return result;
}
/// <summary>
/// Returns all currently active (non-expired) blocked devices across ALL tenants.
/// Also cleans up expired entries from Table Storage as a side-effect.
/// </summary>
public async Task<List<BlockedDeviceEntry>> GetAllBlockedDevicesAsync()
{
var tableClient = _storageService.GetTableServiceClient().GetTableClient(Constants.TableNames.BlockedDevices);
var result = new List<BlockedDeviceEntry>();
var expiredKeys = new List<(string partitionKey, string rowKey)>();
var now = DateTime.UtcNow;
await foreach (var entity in tableClient.QueryAsync<TableEntity>())
{
var unblockAt = entity.GetDateTimeOffset("UnblockAt")?.UtcDateTime ?? DateTime.MinValue;
if (now >= unblockAt)
{
expiredKeys.Add((entity.PartitionKey, entity.RowKey));
continue;
}
result.Add(new BlockedDeviceEntry
{
TenantId = entity.PartitionKey,
SerialNumber = entity.GetString("SerialNumber") ?? DecodeRowKey(entity.RowKey),
BlockedAt = entity.GetDateTimeOffset("BlockedAt")?.UtcDateTime ?? now,
UnblockAt = unblockAt,
BlockedByEmail = entity.GetString("BlockedByEmail"),
DurationHours = entity.GetInt32("DurationHours") ?? 12,
Reason = entity.GetString("Reason"),
Action = entity.GetString("Action") ?? "Block"
});
}
// Clean up expired entries (fire-and-forget, best effort)
foreach (var (pk, rk) in expiredKeys)
{
try { await tableClient.DeleteEntityAsync(pk, rk); }
catch { /* best effort */ }
}
return result;
}
// -----------------------------------------------------------------------
// Private helpers
// -----------------------------------------------------------------------
@@ -54,6 +54,7 @@ export function DeviceBlockSection({
const [blockAction, setBlockAction] = useState<"Block" | "Kill">("Block");
const [killingDevice, setKillingDevice] = useState<string | null>(null);
const [blockListTenantId, setBlockListTenantId] = useState("");
const [loadingAllBlocked, setLoadingAllBlocked] = useState(false);
const fetchBlockedDevices = async (tenantId: string) => {
if (!tenantId) return;
@@ -74,6 +75,25 @@ export function DeviceBlockSection({
}
};
const fetchAllBlockedDevices = async () => {
try {
setLoadingAllBlocked(true);
const token = await getAccessToken();
if (!token) throw new Error("Failed to get access token");
const response = await fetch(`${API_BASE_URL}/api/galactic/devices/blocked`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!response.ok) throw new Error(`Failed to load blocked devices: ${response.statusText}`);
const data = await response.json();
setBlockedDevices(data.blocked ?? []);
setBlockListTenantId("");
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load all blocked devices");
} finally {
setLoadingAllBlocked(false);
}
};
const handleBlockDevice = async () => {
if (!blockSerialNumber.trim() || !blockTenantId.trim()) return;
@@ -335,10 +355,26 @@ export function DeviceBlockSection({
</div>
<button
onClick={() => fetchBlockedDevices(blockListTenantId)}
disabled={!blockListTenantId || loadingBlockedDevices}
disabled={!blockListTenantId || loadingBlockedDevices || loadingAllBlocked}
className="px-4 py-2 bg-red-100 dark:bg-red-900/40 text-red-800 dark:text-red-200 border border-red-300 dark:border-red-600 rounded-lg text-sm font-medium hover:bg-red-200 disabled:opacity-50"
>
{loadingBlockedDevices ? "Loading..." : "Load"}
{loadingBlockedDevices && !loadingAllBlocked ? "Loading..." : "Load"}
</button>
<button
onClick={fetchAllBlockedDevices}
disabled={loadingAllBlocked || loadingBlockedDevices}
className="px-4 py-2 bg-purple-100 dark:bg-purple-900/40 text-purple-800 dark:text-purple-200 border border-purple-300 dark:border-purple-600 rounded-lg text-sm font-medium hover:bg-purple-200 disabled:opacity-50 flex items-center gap-1.5"
>
{loadingAllBlocked ? (
<><div className="animate-spin rounded-full h-3.5 w-3.5 border-b-2 border-purple-600"></div><span>Loading...</span></>
) : (
<>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>All Tenants</span>
</>
)}
</button>
</div>
{blockedDevices.length > 0 ? (
@@ -347,6 +383,7 @@ export function DeviceBlockSection({
<thead className="text-xs text-red-800 dark:text-red-200 uppercase bg-red-100 dark:bg-red-900/30">
<tr>
<th className="px-3 py-2">Serial Number</th>
{!blockListTenantId && <th className="px-3 py-2">Tenant</th>}
<th className="px-3 py-2">Type</th>
<th className="px-3 py-2">Blocked Since</th>
<th className="px-3 py-2">Expires At</th>
@@ -357,8 +394,19 @@ export function DeviceBlockSection({
</thead>
<tbody className="divide-y divide-red-100 dark:divide-red-900/30">
{blockedDevices.map((d) => (
<tr key={d.serialNumber} className="bg-white dark:bg-gray-800">
<tr key={`${d.tenantId}:${d.serialNumber}`} className="bg-white dark:bg-gray-800">
<td className="px-3 py-2 font-mono font-medium text-gray-900 dark:text-gray-100">{d.serialNumber}</td>
{!blockListTenantId && (
<td className="px-3 py-2">
<button
onClick={() => navigator.clipboard.writeText(d.tenantId)}
className="text-xs font-mono text-gray-500 hover:text-blue-600 transition-colors"
title={d.tenantId}
>
{d.tenantId.split("-").slice(0, 2).join("-")}...
</button>
</td>
)}
<td className="px-3 py-2">
{d.action === "Kill" ? (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold bg-red-700 text-white">Kill</span>
File diff suppressed because one or more lines are too long