added tenant search, block management, and kill device

This commit is contained in:
Oliver Kieselbach
2026-03-02 20:56:13 +01:00
parent da2ea3fb3a
commit d6dcbe0681
12 changed files with 371 additions and 84 deletions
@@ -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>
@@ -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="">&mdash; select tenant &mdash;</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="">&mdash; select tenant &mdash;</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>
@@ -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="">&mdash; select tenant &mdash;</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>
@@ -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