mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
added tenant search, block management, and kill device
This commit is contained in:
@@ -859,6 +859,21 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core
|
||||
|
||||
var response = await _apiClient.IngestEventsAsync(request);
|
||||
|
||||
if (response.DeviceKillSignal)
|
||||
{
|
||||
_logger.Warning("=== REMOTE KILL SIGNAL received from administrator. Initiating self-destruct... ===");
|
||||
|
||||
// Stop all timers — no further uploads
|
||||
_uploadTimer?.Change(Timeout.Infinite, Timeout.Infinite);
|
||||
_debounceTimer?.Change(Timeout.Infinite, Timeout.Infinite);
|
||||
|
||||
// Force self-destruct regardless of current config, respecting KeepLogFile from tenant config
|
||||
_configuration.SelfDestructOnComplete = true;
|
||||
_cleanupService.ExecuteSelfDestruct();
|
||||
Environment.Exit(0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.DeviceBlocked)
|
||||
{
|
||||
var unblockMsg = response.UnblockAt.HasValue
|
||||
|
||||
@@ -1 +1 @@
|
||||
183
|
||||
185
|
||||
|
||||
@@ -86,6 +86,7 @@ namespace AutopilotMonitor.Functions.Functions
|
||||
var serialNumber = json["serialNumber"]?.ToString();
|
||||
var durationHours = json["durationHours"]?.Value<int>() ?? 12;
|
||||
var reason = json["reason"]?.ToString();
|
||||
var action = json["action"]?.ToString() ?? "Block";
|
||||
|
||||
if (string.IsNullOrEmpty(tenantId) || string.IsNullOrEmpty(serialNumber))
|
||||
return await BadRequestAsync(req, "tenantId and serialNumber are required");
|
||||
@@ -93,18 +94,29 @@ namespace AutopilotMonitor.Functions.Functions
|
||||
if (durationHours < 1 || durationHours > 720) // max 30 days
|
||||
return await BadRequestAsync(req, "durationHours must be between 1 and 720");
|
||||
|
||||
await _blockedDeviceService.BlockDeviceAsync(tenantId, serialNumber, durationHours, userIdentifier, reason);
|
||||
if (!string.Equals(action, "Block", StringComparison.OrdinalIgnoreCase) &&
|
||||
!string.Equals(action, "Kill", StringComparison.OrdinalIgnoreCase))
|
||||
return await BadRequestAsync(req, "action must be 'Block' or 'Kill'");
|
||||
|
||||
// Normalize casing
|
||||
action = string.Equals(action, "Kill", StringComparison.OrdinalIgnoreCase) ? "Kill" : "Block";
|
||||
|
||||
await _blockedDeviceService.BlockDeviceAsync(tenantId, serialNumber, durationHours, userIdentifier, reason, action);
|
||||
|
||||
var isKill = action == "Kill";
|
||||
_logger.LogWarning(
|
||||
"Galactic Admin {User} blocked device {SerialNumber} in tenant {TenantId} for {Hours}h. Reason: {Reason}",
|
||||
userIdentifier, serialNumber, tenantId, durationHours, reason);
|
||||
"Galactic Admin {User} {Action} device {SerialNumber} in tenant {TenantId} for {Hours}h. Reason: {Reason}",
|
||||
userIdentifier, isKill ? "issued KILL signal to" : "blocked", serialNumber, tenantId, durationHours, reason);
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new
|
||||
{
|
||||
success = true,
|
||||
message = $"Device {serialNumber} blocked for {durationHours} hours.",
|
||||
unblockAt = DateTime.UtcNow.AddHours(durationHours)
|
||||
message = isKill
|
||||
? $"Device {serialNumber} issued remote kill signal for {durationHours} hours."
|
||||
: $"Device {serialNumber} blocked for {durationHours} hours.",
|
||||
unblockAt = DateTime.UtcNow.AddHours(durationHours),
|
||||
action
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -94,20 +94,25 @@ namespace AutopilotMonitor.Functions.Functions
|
||||
|
||||
if (!string.IsNullOrEmpty(serialNumberHeader))
|
||||
{
|
||||
var (isBlocked, unblockAt) = await _blockedDeviceService.IsBlockedAsync(tenantIdHeader, serialNumberHeader);
|
||||
var (isBlocked, unblockAt, blockAction) = await _blockedDeviceService.IsBlockedAsync(tenantIdHeader, serialNumberHeader);
|
||||
if (isBlocked)
|
||||
{
|
||||
var isKill = string.Equals(blockAction, "Kill", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
_logger.LogWarning(
|
||||
"Rejected ingest from blocked device: TenantId={TenantId}, SerialNumber={SerialNumber}, UnblockAt={UnblockAt}",
|
||||
tenantIdHeader, serialNumberHeader, unblockAt);
|
||||
"{Action} ingest from device: TenantId={TenantId}, SerialNumber={SerialNumber}, UnblockAt={UnblockAt}",
|
||||
isKill ? "KILL signal for" : "Rejected", tenantIdHeader, serialNumberHeader, unblockAt);
|
||||
|
||||
var blockedHttpResponse = req.CreateResponse(System.Net.HttpStatusCode.OK);
|
||||
await blockedHttpResponse.WriteAsJsonAsync(new IngestEventsResponse
|
||||
{
|
||||
Success = false,
|
||||
DeviceBlocked = true,
|
||||
DeviceKillSignal = isKill,
|
||||
UnblockAt = unblockAt,
|
||||
Message = "Device is temporarily blocked by an administrator.",
|
||||
Message = isKill
|
||||
? "Device has been issued a remote kill signal."
|
||||
: "Device is temporarily blocked by an administrator.",
|
||||
ProcessedAt = DateTime.UtcNow
|
||||
});
|
||||
return new IngestEventsOutput
|
||||
|
||||
@@ -20,8 +20,8 @@ namespace AutopilotMonitor.Functions.Services
|
||||
private readonly ILogger<BlockedDeviceService> _logger;
|
||||
|
||||
// Cache key: "tenantId|serialNumber" (lower-cased serial number for case-insensitive matching)
|
||||
// Cache value: UnblockAt (UTC). Expired entries are treated as unblocked.
|
||||
private readonly ConcurrentDictionary<string, DateTime> _cache = new(StringComparer.OrdinalIgnoreCase);
|
||||
// Cache value: BlockCacheEntry with UnblockAt and Action. Expired entries are treated as unblocked.
|
||||
private readonly ConcurrentDictionary<string, BlockCacheEntry> _cache = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Tracks which tenants have had their block list loaded into the cache.
|
||||
// Lazy loading: populated on first lookup per tenant.
|
||||
@@ -36,11 +36,12 @@ namespace AutopilotMonitor.Functions.Services
|
||||
/// <summary>
|
||||
/// Checks whether a device is currently blocked.
|
||||
/// Fast path: in-memory cache (loaded lazily per tenant from Table Storage).
|
||||
/// Returns the action type ("Block" or "Kill") so callers can differentiate.
|
||||
/// </summary>
|
||||
public async Task<(bool isBlocked, DateTime? unblockAt)> IsBlockedAsync(string tenantId, string serialNumber)
|
||||
public async Task<(bool isBlocked, DateTime? unblockAt, string action)> IsBlockedAsync(string tenantId, string serialNumber)
|
||||
{
|
||||
if (string.IsNullOrEmpty(tenantId) || string.IsNullOrEmpty(serialNumber))
|
||||
return (false, null);
|
||||
return (false, null, "Block");
|
||||
|
||||
// Lazy-load block list for this tenant if not yet done
|
||||
if (!_loadedTenants.ContainsKey(tenantId))
|
||||
@@ -49,22 +50,23 @@ namespace AutopilotMonitor.Functions.Services
|
||||
}
|
||||
|
||||
var cacheKey = BuildCacheKey(tenantId, serialNumber);
|
||||
if (_cache.TryGetValue(cacheKey, out var unblockAt))
|
||||
if (_cache.TryGetValue(cacheKey, out var entry))
|
||||
{
|
||||
if (DateTime.UtcNow < unblockAt)
|
||||
return (true, unblockAt);
|
||||
if (DateTime.UtcNow < entry.UnblockAt)
|
||||
return (true, entry.UnblockAt, entry.Action);
|
||||
|
||||
// Block has expired — remove from cache
|
||||
_cache.TryRemove(cacheKey, out _);
|
||||
}
|
||||
|
||||
return (false, null);
|
||||
return (false, null, "Block");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Blocks a device for the specified duration. Updates both Table Storage and the in-memory cache.
|
||||
/// <paramref name="action"/> is "Block" (stop uploads) or "Kill" (remote self-destruct).
|
||||
/// </summary>
|
||||
public async Task BlockDeviceAsync(string tenantId, string serialNumber, int durationHours, string blockedByEmail, string? reason = null)
|
||||
public async Task BlockDeviceAsync(string tenantId, string serialNumber, int durationHours, string blockedByEmail, string? reason = null, string action = "Block")
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var unblockAt = now.AddHours(durationHours);
|
||||
@@ -76,7 +78,8 @@ namespace AutopilotMonitor.Functions.Services
|
||||
["UnblockAt"] = unblockAt,
|
||||
["BlockedByEmail"] = blockedByEmail ?? string.Empty,
|
||||
["DurationHours"] = durationHours,
|
||||
["Reason"] = reason ?? string.Empty
|
||||
["Reason"] = reason ?? string.Empty,
|
||||
["Action"] = action ?? "Block"
|
||||
};
|
||||
|
||||
var tableClient = _storageService.GetTableServiceClient().GetTableClient(Constants.TableNames.BlockedDevices);
|
||||
@@ -84,11 +87,11 @@ namespace AutopilotMonitor.Functions.Services
|
||||
|
||||
// Update cache immediately
|
||||
var cacheKey = BuildCacheKey(tenantId, serialNumber);
|
||||
_cache[cacheKey] = unblockAt;
|
||||
_cache[cacheKey] = new BlockCacheEntry { UnblockAt = unblockAt, Action = action ?? "Block" };
|
||||
|
||||
_logger.LogWarning(
|
||||
"Device blocked: TenantId={TenantId}, SerialNumber={SerialNumber}, BlockedBy={BlockedBy}, Until={UnblockAt}, Reason={Reason}",
|
||||
tenantId, serialNumber, blockedByEmail, unblockAt, reason);
|
||||
"Device {Action}: TenantId={TenantId}, SerialNumber={SerialNumber}, BlockedBy={BlockedBy}, Until={UnblockAt}, Reason={Reason}",
|
||||
action, tenantId, serialNumber, blockedByEmail, unblockAt, reason);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -143,7 +146,8 @@ namespace AutopilotMonitor.Functions.Services
|
||||
UnblockAt = unblockAt,
|
||||
BlockedByEmail = entity.GetString("BlockedByEmail"),
|
||||
DurationHours = entity.GetInt32("DurationHours") ?? 12,
|
||||
Reason = entity.GetString("Reason")
|
||||
Reason = entity.GetString("Reason"),
|
||||
Action = entity.GetString("Action") ?? "Block"
|
||||
});
|
||||
}
|
||||
|
||||
@@ -174,7 +178,11 @@ namespace AutopilotMonitor.Functions.Services
|
||||
if (unblockAt <= now) continue; // Skip expired
|
||||
|
||||
var serialNumber = entity.GetString("SerialNumber") ?? DecodeRowKey(entity.RowKey);
|
||||
_cache[BuildCacheKey(tenantId, serialNumber)] = unblockAt;
|
||||
_cache[BuildCacheKey(tenantId, serialNumber)] = new BlockCacheEntry
|
||||
{
|
||||
UnblockAt = unblockAt,
|
||||
Action = entity.GetString("Action") ?? "Block"
|
||||
};
|
||||
}
|
||||
|
||||
_logger.LogDebug("Loaded block list for tenant {TenantId}", tenantId);
|
||||
@@ -196,6 +204,12 @@ namespace AutopilotMonitor.Functions.Services
|
||||
}
|
||||
}
|
||||
|
||||
private class BlockCacheEntry
|
||||
{
|
||||
public DateTime UnblockAt { get; set; }
|
||||
public string Action { get; set; } = "Block";
|
||||
}
|
||||
|
||||
private static string BuildCacheKey(string tenantId, string serialNumber)
|
||||
=> $"{tenantId}|{serialNumber.ToUpperInvariant()}";
|
||||
|
||||
@@ -219,5 +233,6 @@ namespace AutopilotMonitor.Functions.Services
|
||||
public string BlockedByEmail { get; set; } = string.Empty;
|
||||
public int DurationHours { get; set; }
|
||||
public string? Reason { get; set; }
|
||||
public string Action { get; set; } = "Block";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,7 +259,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
if (string.IsNullOrEmpty(session.SerialNumber))
|
||||
continue;
|
||||
|
||||
var (isBlocked, _) = await _blockedDeviceService.IsBlockedAsync(tenantId, session.SerialNumber);
|
||||
var (isBlocked, _, _) = await _blockedDeviceService.IsBlockedAsync(tenantId, session.SerialNumber);
|
||||
if (isBlocked)
|
||||
continue;
|
||||
|
||||
|
||||
@@ -68,6 +68,12 @@ namespace AutopilotMonitor.Shared.Models
|
||||
/// When the block expires (only populated if DeviceBlocked is true)
|
||||
/// </summary>
|
||||
public DateTime? UnblockAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the device has been issued a remote kill signal (graceful self-destruct).
|
||||
/// The agent should execute its self-destruct routine and exit.
|
||||
/// </summary>
|
||||
public bool DeviceKillSignal { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
+60
-24
@@ -3,6 +3,7 @@
|
||||
import { useState } from "react";
|
||||
import { API_BASE_URL } from "@/lib/config";
|
||||
import { TenantConfiguration } from "./TenantManagementSection";
|
||||
import { TenantSearchSelect } from "./TenantSearchSelect";
|
||||
|
||||
interface BlockedDevice {
|
||||
tenantId: string;
|
||||
@@ -12,6 +13,7 @@ interface BlockedDevice {
|
||||
blockedByEmail: string;
|
||||
durationHours: number;
|
||||
reason?: string;
|
||||
action?: string;
|
||||
}
|
||||
|
||||
interface DeviceBlockSectionProps {
|
||||
@@ -49,6 +51,7 @@ export function DeviceBlockSection({
|
||||
const [blockedDevices, setBlockedDevices] = useState<BlockedDevice[]>([]);
|
||||
const [loadingBlockedDevices, setLoadingBlockedDevices] = useState(false);
|
||||
const [unblockingDevice, setUnblockingDevice] = useState<string | null>(null);
|
||||
const [blockAction, setBlockAction] = useState<"Block" | "Kill">("Block");
|
||||
const [blockListTenantId, setBlockListTenantId] = useState("");
|
||||
|
||||
const fetchBlockedDevices = async (tenantId: string) => {
|
||||
@@ -72,6 +75,11 @@ export function DeviceBlockSection({
|
||||
|
||||
const handleBlockDevice = async () => {
|
||||
if (!blockSerialNumber.trim() || !blockTenantId.trim()) return;
|
||||
|
||||
if (blockAction === "Kill" && !confirm(
|
||||
`REMOTE KILL: This will permanently shut down the agent on device "${blockSerialNumber.trim()}" and remove all agent files. This cannot be undone. Continue?`
|
||||
)) return;
|
||||
|
||||
try {
|
||||
setBlockingDevice(true);
|
||||
setError(null);
|
||||
@@ -85,14 +93,20 @@ export function DeviceBlockSection({
|
||||
serialNumber: blockSerialNumber.trim(),
|
||||
durationHours: blockDurationHours,
|
||||
reason: blockReason.trim() || undefined,
|
||||
action: blockAction,
|
||||
}),
|
||||
});
|
||||
const result = await response.json();
|
||||
if (!response.ok) throw new Error(result.message || response.statusText);
|
||||
setSuccessMessage(`Device ${blockSerialNumber.trim()} blocked for ${blockDurationHours}h.`);
|
||||
setSuccessMessage(
|
||||
blockAction === "Kill"
|
||||
? `Device ${blockSerialNumber.trim()} issued remote kill signal for ${blockDurationHours}h.`
|
||||
: `Device ${blockSerialNumber.trim()} blocked for ${blockDurationHours}h.`
|
||||
);
|
||||
setTimeout(() => setSuccessMessage(null), 4000);
|
||||
setBlockSerialNumber("");
|
||||
setBlockReason("");
|
||||
setBlockAction("Block");
|
||||
if (blockListTenantId === blockTenantId) await fetchBlockedDevices(blockTenantId);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to block device");
|
||||
@@ -191,9 +205,9 @@ export function DeviceBlockSection({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Block a device form */}
|
||||
{/* Block / Kill a device form */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-red-900 dark:text-red-100 mb-3">Block a Device</h3>
|
||||
<h3 className="text-sm font-semibold text-red-900 dark:text-red-100 mb-3">Block or Kill a Device</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Serial Number</label>
|
||||
@@ -207,15 +221,22 @@ export function DeviceBlockSection({
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Tenant ID</label>
|
||||
<select
|
||||
<TenantSearchSelect
|
||||
tenants={tenants}
|
||||
value={blockTenantId}
|
||||
onChange={(e) => setBlockTenantId(e.target.value)}
|
||||
onChange={setBlockTenantId}
|
||||
focusRingClass="focus:ring-red-500 focus:border-red-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Action</label>
|
||||
<select
|
||||
value={blockAction}
|
||||
onChange={(e) => setBlockAction(e.target.value as "Block" | "Kill")}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-sm text-gray-900 dark:text-gray-100 bg-white dark:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-red-500"
|
||||
>
|
||||
<option value="">— select tenant —</option>
|
||||
{tenants.map((t) => (
|
||||
<option key={t.tenantId} value={t.tenantId}>{t.domainName ? `${t.domainName} (${t.tenantId})` : t.tenantId}</option>
|
||||
))}
|
||||
<option value="Block">Block (stop uploads)</option>
|
||||
<option value="Kill">Kill (remote shutdown)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
@@ -229,7 +250,7 @@ export function DeviceBlockSection({
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-sm text-gray-900 dark:text-gray-100 bg-white dark:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-red-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="sm:col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Reason (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
@@ -240,15 +261,26 @@ export function DeviceBlockSection({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{blockAction === "Kill" && (
|
||||
<div className="mt-3 p-3 bg-red-100 dark:bg-red-900/40 border border-red-300 dark:border-red-700 rounded-lg">
|
||||
<p className="text-xs text-red-800 dark:text-red-200 font-medium">
|
||||
The agent will execute its self-destruct routine: remove the Scheduled Task and delete all agent files. This is irreversible once the agent picks up the signal.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={handleBlockDevice}
|
||||
disabled={blockingDevice || !blockSerialNumber.trim() || !blockTenantId}
|
||||
className="mt-4 px-4 py-2 bg-red-600 text-white rounded-lg text-sm font-medium hover:bg-red-700 disabled:opacity-50 flex items-center space-x-2"
|
||||
className={`mt-4 px-4 py-2 text-white rounded-lg text-sm font-medium disabled:opacity-50 flex items-center space-x-2 ${
|
||||
blockAction === "Kill"
|
||||
? "bg-red-800 hover:bg-red-900 dark:bg-red-700 dark:hover:bg-red-800"
|
||||
: "bg-red-600 hover:bg-red-700"
|
||||
}`}
|
||||
>
|
||||
{blockingDevice ? (
|
||||
<><div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div><span>Blocking...</span></>
|
||||
<><div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div><span>{blockAction === "Kill" ? "Sending Kill..." : "Blocking..."}</span></>
|
||||
) : (
|
||||
<span>Block Device</span>
|
||||
<span>{blockAction === "Kill" ? "Send Kill Signal" : "Block Device"}</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
@@ -259,16 +291,12 @@ export function DeviceBlockSection({
|
||||
<div className="flex items-end gap-3 mb-4">
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Tenant</label>
|
||||
<select
|
||||
<TenantSearchSelect
|
||||
tenants={tenants}
|
||||
value={blockListTenantId}
|
||||
onChange={(e) => setBlockListTenantId(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-sm text-gray-900 dark:text-gray-100 bg-white dark:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-red-500"
|
||||
>
|
||||
<option value="">— select tenant —</option>
|
||||
{tenants.map((t) => (
|
||||
<option key={t.tenantId} value={t.tenantId}>{t.domainName ? `${t.domainName} (${t.tenantId})` : t.tenantId}</option>
|
||||
))}
|
||||
</select>
|
||||
onChange={setBlockListTenantId}
|
||||
focusRingClass="focus:ring-red-500 focus:border-red-500"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => fetchBlockedDevices(blockListTenantId)}
|
||||
@@ -284,8 +312,9 @@ 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>
|
||||
<th className="px-3 py-2">Type</th>
|
||||
<th className="px-3 py-2">Blocked Since</th>
|
||||
<th className="px-3 py-2">Unblocks At</th>
|
||||
<th className="px-3 py-2">Expires At</th>
|
||||
<th className="px-3 py-2">Blocked By</th>
|
||||
<th className="px-3 py-2">Reason</th>
|
||||
<th className="px-3 py-2">Action</th>
|
||||
@@ -295,6 +324,13 @@ export function DeviceBlockSection({
|
||||
{blockedDevices.map((d) => (
|
||||
<tr key={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>
|
||||
<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>
|
||||
) : (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold bg-orange-200 dark:bg-orange-800 text-orange-800 dark:text-orange-200">Block</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-gray-600 dark:text-gray-400">{new Date(d.blockedAt).toLocaleString()}</td>
|
||||
<td className="px-3 py-2 text-orange-600 dark:text-orange-400 font-medium">{new Date(d.unblockAt).toLocaleString()}</td>
|
||||
<td className="px-3 py-2 text-gray-600 dark:text-gray-400">{d.blockedByEmail}</td>
|
||||
@@ -305,7 +341,7 @@ export function DeviceBlockSection({
|
||||
disabled={unblockingDevice === d.serialNumber}
|
||||
className="px-3 py-1 bg-green-600 text-white rounded text-xs font-medium hover:bg-green-700 disabled:opacity-50"
|
||||
>
|
||||
{unblockingDevice === d.serialNumber ? "Unblocking..." : "Unblock"}
|
||||
{unblockingDevice === d.serialNumber ? "Removing..." : "Remove"}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
+7
-31
@@ -3,6 +3,7 @@
|
||||
import { useState } from "react";
|
||||
import { API_BASE_URL } from "@/lib/config";
|
||||
import { TenantConfiguration } from "./TenantManagementSection";
|
||||
import { TenantSearchSelect } from "./TenantSearchSelect";
|
||||
import { SessionExportEvent, generateCsvExport, generateUiExport } from "@/lib/sessionExportUtils";
|
||||
|
||||
function downloadFile(content: string, filename: string, mimeType: string) {
|
||||
@@ -88,37 +89,12 @@ export function SessionExportSection({
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-teal-900 dark:text-teal-100 mb-1">Tenant ID</label>
|
||||
{tenants.length > 0 ? (
|
||||
<div className="space-y-1.5">
|
||||
<select
|
||||
value={tenants.some(t => t.tenantId === exportTenantId) ? exportTenantId : ""}
|
||||
onChange={e => { if (e.target.value) setExportTenantId(e.target.value); }}
|
||||
className="w-full px-3 py-2 border border-teal-300 dark:border-teal-600 rounded-lg text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-teal-500"
|
||||
>
|
||||
<option value="">— select tenant —</option>
|
||||
{[...tenants].sort((a, b) => (a.domainName || a.tenantId).localeCompare(b.domainName || b.tenantId)).map(t => (
|
||||
<option key={t.tenantId} value={t.tenantId}>
|
||||
{t.domainName ? `${t.domainName} (${t.tenantId})` : t.tenantId}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="or enter Tenant ID directly"
|
||||
value={exportTenantId}
|
||||
onChange={e => setExportTenantId(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-teal-300 dark:border-teal-600 rounded-lg text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-teal-500"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
|
||||
value={exportTenantId}
|
||||
onChange={e => setExportTenantId(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-teal-300 dark:border-teal-600 rounded-lg text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-teal-500"
|
||||
/>
|
||||
)}
|
||||
<TenantSearchSelect
|
||||
tenants={tenants}
|
||||
value={exportTenantId}
|
||||
onChange={setExportTenantId}
|
||||
focusRingClass="focus:ring-teal-500 focus:border-teal-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect, useMemo } from "react";
|
||||
import { TenantConfiguration } from "./TenantManagementSection";
|
||||
|
||||
interface TenantSearchSelectProps {
|
||||
tenants: TenantConfiguration[];
|
||||
value: string;
|
||||
onChange: (tenantId: string) => void;
|
||||
placeholder?: string;
|
||||
focusRingClass?: string;
|
||||
}
|
||||
|
||||
export function TenantSearchSelect({
|
||||
tenants,
|
||||
value,
|
||||
onChange,
|
||||
placeholder = "Search tenant by name or ID\u2026",
|
||||
focusRingClass = "focus:ring-blue-500 focus:border-blue-500",
|
||||
}: TenantSearchSelectProps) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [open, setOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Resolve the selected tenant for display
|
||||
const selectedTenant = tenants.find((t) => t.tenantId === value);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!query) return tenants;
|
||||
const q = query.toLowerCase();
|
||||
return tenants.filter(
|
||||
(t) =>
|
||||
t.domainName?.toLowerCase().includes(q) ||
|
||||
t.tenantId.toLowerCase().includes(q)
|
||||
);
|
||||
}, [tenants, query]);
|
||||
|
||||
// Close dropdown on click outside
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, []);
|
||||
|
||||
const handleSelect = (tenantId: string) => {
|
||||
onChange(tenantId);
|
||||
setQuery("");
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
onChange("");
|
||||
setQuery("");
|
||||
setOpen(false);
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
const handleFocus = () => {
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
setOpen(false);
|
||||
inputRef.current?.blur();
|
||||
}
|
||||
};
|
||||
|
||||
const label = (t: TenantConfiguration) =>
|
||||
t.domainName ? `${t.domainName} (${t.tenantId})` : t.tenantId;
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative">
|
||||
<div className="relative">
|
||||
<svg
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 pointer-events-none"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={open ? query : selectedTenant ? label(selectedTenant) : query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
if (!open) setOpen(true);
|
||||
}}
|
||||
onFocus={handleFocus}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={selectedTenant ? label(selectedTenant) : placeholder}
|
||||
className={`w-full pl-9 pr-8 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-sm text-gray-900 dark:text-gray-100 bg-white dark:bg-gray-700 placeholder-gray-400 focus:outline-none focus:ring-2 ${focusRingClass}`}
|
||||
/>
|
||||
{value && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<ul className="absolute z-50 mt-1 w-full max-h-52 overflow-auto bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg">
|
||||
{filtered.length === 0 ? (
|
||||
<li className="px-3 py-2 text-sm text-gray-500 dark:text-gray-400 italic">
|
||||
No tenants found
|
||||
</li>
|
||||
) : (
|
||||
filtered.map((t) => (
|
||||
<li
|
||||
key={t.tenantId}
|
||||
onMouseDown={() => handleSelect(t.tenantId)}
|
||||
className={`px-3 py-2 text-sm cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 ${
|
||||
t.tenantId === value
|
||||
? "bg-gray-100 dark:bg-gray-700 font-medium"
|
||||
: "text-gray-900 dark:text-gray-100"
|
||||
}`}
|
||||
>
|
||||
{t.domainName ? (
|
||||
<>
|
||||
<span className="font-medium">{t.domainName}</span>
|
||||
<span className="ml-1.5 text-gray-400 dark:text-gray-500 text-xs">{t.tenantId}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="font-mono">{t.tenantId}</span>
|
||||
)}
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -48,6 +48,7 @@ export default function Home() {
|
||||
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';
|
||||
@@ -102,6 +103,7 @@ export default function Home() {
|
||||
const data = await response.json();
|
||||
const allSessions = data.sessions || [];
|
||||
setSessions(allSessions);
|
||||
fetchBlockedDevices(allSessions);
|
||||
} else {
|
||||
addNotification('error', 'Backend Error', `Failed to fetch sessions: ${response.statusText}`, 'backend-error');
|
||||
}
|
||||
@@ -113,6 +115,48 @@ export default function Home() {
|
||||
}
|
||||
};
|
||||
|
||||
const fetchBlockedDevices = async (currentSessions: Session[]) => {
|
||||
if (!adminMode || !user?.isGalacticAdmin) {
|
||||
setBlockedDevicesSet(new Set());
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const token = await getAccessToken();
|
||||
if (!token) return;
|
||||
|
||||
const tenantIds = galacticAdminMode
|
||||
? [...new Set(currentSessions.map(s => s.tenantId))]
|
||||
: tenantId ? [tenantId] : [];
|
||||
|
||||
if (tenantIds.length === 0) {
|
||||
setBlockedDevicesSet(new Set());
|
||||
return;
|
||||
}
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
tenantIds.map(tid =>
|
||||
fetch(`${API_BASE_URL}/api/devices/blocked?tenantId=${encodeURIComponent(tid)}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
}).then(res => res.ok ? res.json() : { blocked: [] })
|
||||
)
|
||||
);
|
||||
|
||||
const newSet = new Set<string>();
|
||||
results.forEach(result => {
|
||||
if (result.status === "fulfilled" && result.value?.blocked) {
|
||||
for (const device of result.value.blocked) {
|
||||
newSet.add(`${device.tenantId}:${device.serialNumber}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
setBlockedDevicesSet(newSet);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch blocked devices:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// Redirect regular users (non-admin) to progress portal – they must never see the session list
|
||||
useEffect(() => {
|
||||
if (user && !user.isTenantAdmin && !user.isGalacticAdmin) {
|
||||
@@ -293,6 +337,13 @@ export default function Home() {
|
||||
localStorage.setItem('galacticAdminMode', galacticAdminMode.toString());
|
||||
}, [galacticAdminMode]);
|
||||
|
||||
// Clear blocked devices set when admin mode or galactic admin status changes
|
||||
useEffect(() => {
|
||||
if (!adminMode || !user?.isGalacticAdmin) {
|
||||
setBlockedDevicesSet(new Set());
|
||||
}
|
||||
}, [adminMode, user?.isGalacticAdmin]);
|
||||
|
||||
// Disable galactic admin mode if user is not a galactic admin
|
||||
useEffect(() => {
|
||||
if (user && !user.isGalacticAdmin && galacticAdminMode) {
|
||||
@@ -432,6 +483,11 @@ export default function Home() {
|
||||
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'}`);
|
||||
@@ -489,7 +545,8 @@ export default function Home() {
|
||||
session.status,
|
||||
session.sessionId,
|
||||
new Date(session.startedAt).toLocaleString(),
|
||||
`${Math.round(session.durationSeconds / 60)} min`
|
||||
`${Math.round(session.durationSeconds / 60)} min`,
|
||||
blockedDevicesSet.has(`${session.tenantId}:${session.serialNumber}`) ? "blocked" : ""
|
||||
].join(" ").toLowerCase();
|
||||
|
||||
return searchableText.includes(query);
|
||||
@@ -866,7 +923,20 @@ export default function Home() {
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<StatusBadge status={session.status} failureReason={session.failureReason} />
|
||||
<div className="flex items-center gap-1.5">
|
||||
<StatusBadge status={session.status} failureReason={session.failureReason} />
|
||||
{blockedDevicesSet.has(`${session.tenantId}:${session.serialNumber}`) && (
|
||||
<span
|
||||
className="px-2 inline-flex items-center gap-1 text-xs leading-5 font-semibold rounded-full bg-orange-100 text-orange-800"
|
||||
title="Device is currently blocked"
|
||||
>
|
||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
|
||||
</svg>
|
||||
Blocked
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{session.eventCount}
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user