web: fix 13 dashboard criticals from the UI/UX audit
- confirm-deps used a raw fetch with no auth headers -> API layer - retry/cancel had two copies with divergent invalidation (one hit a dead ['active'] key); unified in useCommands, bulk-approve now refreshes counts - Docker stat-card filter was sent server-side where it mapped to the severity column and matched nothing; moved client-side with two distinct values - wired dead Quick Actions buttons, notification deep-links, ws reconnect+backoff - useMemo side-effect -> useEffect, agent-events dedup, stale-closure toast - dropped dead agent memory block, labeled security health as fleet-wide - maintenance banner claimed installs are "blocked"; they wait for the window
This commit is contained in:
parent
cc19854941
commit
f04fbe8b1f
16 changed files with 137 additions and 132 deletions
|
|
@ -545,7 +545,12 @@ export function AgentHealth({ agentId }: AgentHealthProps) {
|
|||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Shield className="h-5 w-5 text-blue-600" />
|
||||
<h3 className="text-base font-semibold text-gray-900">Security Health</h3>
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-gray-900">Security Health</h3>
|
||||
{/* This surface reads the fleet-wide security overview, not this
|
||||
agent's status — label it so it isn't mistaken for per-agent. */}
|
||||
<p className="text-xs text-gray-500">Fleet-wide — not specific to this agent</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => queryClient.invalidateQueries({ queryKey: ['security-overview'] })}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import { useQuery } from '@tanstack/react-query';
|
|||
import {
|
||||
HardDrive,
|
||||
RefreshCw,
|
||||
MemoryStick,
|
||||
} from 'lucide-react';
|
||||
import { formatBytes, formatRelativeTime } from '@/lib/utils';
|
||||
import { agentApi } from '@/lib/api';
|
||||
|
|
@ -233,32 +232,11 @@ export function AgentStorage({ agentId }: AgentStorageProps) {
|
|||
</button>
|
||||
</div>
|
||||
|
||||
{/* Memory & Disk - matching Overview styling */}
|
||||
{/* Disk usage. Memory/CPU are not carried by the storage-metrics endpoint
|
||||
(they live in the system-metrics feed) — the dead memory block that
|
||||
read hardcoded zeros was removed. Wiring real memory here is a separate
|
||||
task against /agents/:id/metrics/system. */}
|
||||
<div className="space-y-4">
|
||||
{/* Memory - GREEN to differentiate from disks */}
|
||||
{storageMetrics && storageMetrics.memory_total_gb > 0 && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-gray-600 flex items-center">
|
||||
<MemoryStick className="h-4 w-4 mr-1" />
|
||||
Memory
|
||||
</p>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{storageMetrics.memory_used_gb.toFixed(1)} GB / {storageMetrics.memory_total_gb.toFixed(1)} GB
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2 mt-1">
|
||||
<div
|
||||
className="bg-green-600 h-2 rounded-full transition-all"
|
||||
style={{ width: `${Math.min(storageMetrics.memory_percent, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{storageMetrics.memory_percent.toFixed(0)}% used
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick Overview - Simple disk bars for at-a-glance view */}
|
||||
{disks.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
|
|
|
|||
|
|
@ -37,7 +37,9 @@ export function AgentUpdate({ agent, onUpdateComplete, className }: AgentUpdateP
|
|||
|
||||
if (hasUpdate && availableVersion) {
|
||||
setShowConfirmDialog(true);
|
||||
} else if (!hasUpdate && hasChecked) {
|
||||
} else if (!hasUpdate) {
|
||||
// We're already inside the post-check path, so no need to re-read
|
||||
// hasChecked (its closure value is still the pre-setState false).
|
||||
toast('Agent is already at latest version');
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -313,6 +313,9 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
|||
onClick={() => {
|
||||
markNotificationRead(notification.id);
|
||||
setIsNotificationDropdownOpen(false);
|
||||
if (notification.href) {
|
||||
navigate(notification.href);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start space-x-3">
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ import { securityHealthColor } from '@/components/primitives/statusColors';
|
|||
const SecurityStatusCard: React.FC<SecurityStatusCardProps> = ({
|
||||
status,
|
||||
onRefresh,
|
||||
onViewLogs,
|
||||
onMonitorEvents,
|
||||
loading = false,
|
||||
}) => {
|
||||
const getStatusIcon = () => {
|
||||
|
|
@ -199,15 +201,27 @@ const SecurityStatusCard: React.FC<SecurityStatusCardProps> = ({
|
|||
Quick Actions
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button className="px-3 py-1.5 text-sm bg-white border border-gray-300 rounded-lg hover:border-gray-400 flex items-center gap-2">
|
||||
<button
|
||||
onClick={onViewLogs}
|
||||
disabled={!onViewLogs}
|
||||
className="px-3 py-1.5 text-sm bg-white border border-gray-300 rounded-lg hover:border-gray-400 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
<Eye className="w-3 h-3" />
|
||||
View Security Logs
|
||||
</button>
|
||||
<button className="px-3 py-1.5 text-sm bg-white border border-gray-300 rounded-lg hover:border-gray-400 flex items-center gap-2">
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
disabled={!onRefresh || loading}
|
||||
className="px-3 py-1.5 text-sm bg-white border border-gray-300 rounded-lg hover:border-gray-400 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
<Shield className="w-3 h-3" />
|
||||
Run Security Check
|
||||
</button>
|
||||
<button className="px-3 py-1.5 text-sm bg-white border border-gray-300 rounded-lg hover:border-gray-400 flex items-center gap-2">
|
||||
<button
|
||||
onClick={onMonitorEvents}
|
||||
disabled={!onMonitorEvents}
|
||||
className="px-3 py-1.5 text-sm bg-white border border-gray-300 rounded-lg hover:border-gray-400 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
<Activity className="w-3 h-3" />
|
||||
Monitor Events
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect } from 'react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import api from '@/lib/api';
|
||||
import { useRealtimeStore } from '@/lib/store';
|
||||
|
|
@ -31,6 +31,8 @@ export const useAgentEvents = (
|
|||
options: UseAgentEventsOptions = {}
|
||||
) => {
|
||||
const { addNotification } = useRealtimeStore();
|
||||
// Track seen event IDs so we notify once per event, not once per poll tick.
|
||||
const seenRef = useRef<Set<string>>(new Set());
|
||||
const {
|
||||
severity = 'error,critical,warning',
|
||||
limit = 50,
|
||||
|
|
@ -62,6 +64,10 @@ export const useAgentEvents = (
|
|||
if (data?.events && data.events.length > 0) {
|
||||
// Map system events to notification format and add to notification store
|
||||
data.events.forEach((event) => {
|
||||
// Only push events we haven't seen this session.
|
||||
if (seenRef.current.has(event.id)) return;
|
||||
seenRef.current.add(event.id);
|
||||
|
||||
// Map severity to notification type
|
||||
const type =
|
||||
event.severity === 'critical'
|
||||
|
|
|
|||
|
|
@ -32,16 +32,26 @@ export const useRecentCommands = (limit?: number): UseQueryResult<{ commands: Ac
|
|||
});
|
||||
};
|
||||
|
||||
// Canonical owner of the command retry/cancel mutations. useUpdates re-exports
|
||||
// these so the Updates page and LiveOperations share one invalidation set —
|
||||
// a retry/cancel from either surface must refresh both views plus dashboard
|
||||
// counts. (Previously a divergent copy in useUpdates invalidated a dead
|
||||
// ['active'] key and skipped activeCommands, so LiveOperations went stale.)
|
||||
const invalidateCommandViews = (queryClient: ReturnType<typeof useQueryClient>) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['activeCommands'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['recentCommands'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['updates'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['update'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['logs'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['dashboard-stats'] });
|
||||
};
|
||||
|
||||
export const useRetryCommand = (): UseMutationResult<{ message: string; command_id: string; new_id: string }, Error, string, unknown> => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: updateApi.retryCommand,
|
||||
onSuccess: () => {
|
||||
// Invalidate active and recent commands queries
|
||||
queryClient.invalidateQueries({ queryKey: ['activeCommands'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['recentCommands'] });
|
||||
},
|
||||
onSuccess: () => invalidateCommandViews(queryClient),
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -50,11 +60,7 @@ export const useCancelCommand = (): UseMutationResult<{ message: string }, Error
|
|||
|
||||
return useMutation({
|
||||
mutationFn: updateApi.cancelCommand,
|
||||
onSuccess: () => {
|
||||
// Invalidate active and recent commands queries
|
||||
queryClient.invalidateQueries({ queryKey: ['activeCommands'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['recentCommands'] });
|
||||
},
|
||||
onSuccess: () => invalidateCommandViews(queryClient),
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useMemo } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { useFilterUrl, type FilterConfig } from './useFilterUrl';
|
||||
import { useQueryParser, type ParsedPill } from './useQueryParser';
|
||||
import { buildQueryString } from '@/lib/queryParser';
|
||||
|
|
@ -55,17 +55,17 @@ export function useMultimodalFilter<C extends FilterConfig>(
|
|||
const url = useFilterUrl(config);
|
||||
const parser = useQueryParser(knownKeys, opts);
|
||||
|
||||
// When parsed filters change, sync to URL
|
||||
// (only for keys that are in the config)
|
||||
const lastSynced = useMemo(() => ({} as Record<string, string>), []);
|
||||
// When parsed filters change, sync to URL (only for keys that are in the config).
|
||||
// This writes to the URL, so it's a side effect — must live in useEffect, not
|
||||
// useMemo (which React may run twice in StrictMode / skip arbitrarily).
|
||||
const lastSynced = useRef<Record<string, string>>({});
|
||||
|
||||
// Sync parsed filters → URL (debounced via parser)
|
||||
useMemo(() => {
|
||||
useEffect(() => {
|
||||
for (const key of knownKeys) {
|
||||
const parsedVal = parser.parsed.filters[key] ?? '';
|
||||
const currentUrlVal = (url.values as any)[key] ?? '';
|
||||
if (parsedVal !== lastSynced[key]) {
|
||||
lastSynced[key] = parsedVal;
|
||||
if (parsedVal !== lastSynced.current[key]) {
|
||||
lastSynced.current[key] = parsedVal;
|
||||
if (parsedVal !== currentUrlVal) {
|
||||
url.setFilter(key, parsedVal);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -344,48 +344,57 @@ export const useSecurityWebSocket = () => {
|
|||
const ws = React.useRef<WebSocket | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
// Initialize WebSocket connection
|
||||
const wsUrl = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/api/v1/security/ws`;
|
||||
|
||||
ws.current = new WebSocket(wsUrl, []);
|
||||
let reconnectAttempts = 0;
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
// Set on unmount so a pending close handler doesn't resurrect the socket.
|
||||
let stopped = false;
|
||||
|
||||
ws.current.onopen = () => {
|
||||
setConnected(true);
|
||||
clientLogger.debug('Security WebSocket connected');
|
||||
};
|
||||
const connect = () => {
|
||||
if (stopped) return;
|
||||
const socket = new WebSocket(wsUrl, []);
|
||||
ws.current = socket;
|
||||
|
||||
ws.current.onmessage = (event) => {
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
if (message.type === 'security_event') {
|
||||
setEvents(prev => [message.data, ...prev.slice(0, 999)]); // Keep last 1000 events
|
||||
socket.onopen = () => {
|
||||
reconnectAttempts = 0;
|
||||
setConnected(true);
|
||||
clientLogger.debug('Security WebSocket connected');
|
||||
};
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
if (message.type === 'security_event') {
|
||||
setEvents(prev => [message.data, ...prev.slice(0, 999)]); // Keep last 1000 events
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to parse WebSocket message:', error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to parse WebSocket message:', error);
|
||||
}
|
||||
};
|
||||
|
||||
socket.onerror = (error) => {
|
||||
console.error('Security WebSocket error:', error);
|
||||
setConnected(false);
|
||||
};
|
||||
|
||||
socket.onclose = () => {
|
||||
setConnected(false);
|
||||
clientLogger.debug('Security WebSocket disconnected');
|
||||
if (stopped) return;
|
||||
// Exponential backoff capped at 30s: 1s, 2s, 4s, 8s, 16s, 30s…
|
||||
const delay = Math.min(30000, 1000 * 2 ** reconnectAttempts);
|
||||
reconnectAttempts += 1;
|
||||
reconnectTimer = setTimeout(connect, delay);
|
||||
};
|
||||
};
|
||||
|
||||
ws.current.onerror = (error) => {
|
||||
console.error('Security WebSocket error:', error);
|
||||
setConnected(false);
|
||||
};
|
||||
|
||||
ws.current.onclose = () => {
|
||||
setConnected(false);
|
||||
clientLogger.debug('Security WebSocket disconnected');
|
||||
|
||||
// Attempt to reconnect after 5 seconds
|
||||
setTimeout(() => {
|
||||
if (!ws.current || ws.current.readyState === WebSocket.CLOSED) {
|
||||
// Re-initialize connection
|
||||
}
|
||||
}, 5000);
|
||||
};
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
if (ws.current) {
|
||||
ws.current.close();
|
||||
}
|
||||
stopped = true;
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||
if (ws.current) ws.current.close();
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
|
|
|||
|
|
@ -106,10 +106,12 @@ export const useApproveMultipleUpdates = (): UseMutationResult<void, Error, Upda
|
|||
return useMutation({
|
||||
mutationFn: (request: UpdateApprovalRequest) => updateApi.approveUpdates(request),
|
||||
onSuccess: () => {
|
||||
// Invalidate all updates queries to trigger refetch
|
||||
// Match useApproveUpdate's set — bulk approve must refresh dashboard
|
||||
// counts and LiveOperations too, not just the updates list.
|
||||
queryClient.invalidateQueries({ queryKey: ['updates'] });
|
||||
// Also invalidate specific update queries
|
||||
queryClient.invalidateQueries({ queryKey: ['update'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['dashboard-stats'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['activeCommands'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -144,22 +146,11 @@ export const useInstallUpdate = (): UseMutationResult<void, Error, string, unkno
|
|||
});
|
||||
};
|
||||
|
||||
export const useRetryCommand = (): UseMutationResult<{ message: string; command_id: string; new_id: string }, Error, string, unknown> => {
|
||||
const queryClient = useQueryClient();
|
||||
// Command retry/cancel live in useCommands (canonical, shared invalidation set).
|
||||
// Re-exported here so existing Updates-page imports keep resolving.
|
||||
export { useRetryCommand, useCancelCommand } from './useCommands';
|
||||
|
||||
return useMutation({
|
||||
mutationFn: updateApi.retryCommand,
|
||||
onSuccess: () => {
|
||||
// Invalidate all updates queries to trigger refetch
|
||||
queryClient.invalidateQueries({ queryKey: ['updates'] });
|
||||
// Also invalidate logs and active operations queries
|
||||
queryClient.invalidateQueries({ queryKey: ['logs'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['active'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useReopenUpdate = (): UseMutationResult<{ message: string }, Error, string, unknown> => {
|
||||
export const useReopenUpdate =(): UseMutationResult<{ message: string }, Error, string, unknown> => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
|
|
@ -185,21 +176,6 @@ export const useResolveUpdate = (): UseMutationResult<{ message: string }, Error
|
|||
});
|
||||
};
|
||||
|
||||
export const useCancelCommand = (): UseMutationResult<{ message: string }, Error, string, unknown> => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: updateApi.cancelCommand,
|
||||
onSuccess: () => {
|
||||
// Invalidate all updates queries to trigger refetch
|
||||
queryClient.invalidateQueries({ queryKey: ['updates'] });
|
||||
// Also invalidate logs and active operations queries
|
||||
queryClient.invalidateQueries({ queryKey: ['logs'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['active'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Capability token status (LIFECYCLE-005)
|
||||
export const useCapabilityTokenStatus = (
|
||||
agentId: string,
|
||||
|
|
|
|||
|
|
@ -179,6 +179,8 @@ interface RealtimeState {
|
|||
message: string;
|
||||
timestamp: string;
|
||||
read: boolean;
|
||||
// Optional deep-link target; clicking the notification navigates here.
|
||||
href?: string;
|
||||
}>;
|
||||
setConnected: (connected: boolean) => void;
|
||||
setLastUpdate: (timestamp: string) => void;
|
||||
|
|
|
|||
|
|
@ -73,7 +73,9 @@ const Docker: React.FC = () => {
|
|||
|
||||
const { data: dockerData, isPending, error } = useDockerContainers({
|
||||
search: searchText || undefined,
|
||||
status: values.status || undefined,
|
||||
// NOTE: `status` is filtered client-side below (see filteredUnified). The server
|
||||
// reused the `status` query param as a severity filter, so sending update-state
|
||||
// values here matched nothing.
|
||||
page: 1,
|
||||
page_size: 50,
|
||||
});
|
||||
|
|
@ -166,6 +168,12 @@ const Docker: React.FC = () => {
|
|||
);
|
||||
}
|
||||
|
||||
if (values.status === 'update-available') {
|
||||
rows = rows.filter(r => r.availableVersion != null);
|
||||
} else if (values.status === 'pending-approval') {
|
||||
rows = rows.filter(r => r.status === 'update-available');
|
||||
}
|
||||
|
||||
if (values.severity) {
|
||||
rows = rows.filter(r => r.severity === values.severity);
|
||||
}
|
||||
|
|
@ -336,8 +344,8 @@ const Docker: React.FC = () => {
|
|||
<FilterCountButton
|
||||
count={pendingApproval}
|
||||
label="Pending Approval"
|
||||
isSelected={values.status === 'update-available'}
|
||||
onClick={() => setFilter('status', values.status === 'update-available' ? '' : 'update-available')}
|
||||
isSelected={values.status === 'pending-approval'}
|
||||
onClick={() => setFilter('status', values.status === 'pending-approval' ? '' : 'pending-approval')}
|
||||
color="orange"
|
||||
icon={<AlertTriangle className="h-8 w-8 text-orange-400" />}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -382,6 +382,8 @@ const SecuritySettings: React.FC = () => {
|
|||
last_updated: new Date().toISOString(),
|
||||
}}
|
||||
onRefresh={refetch}
|
||||
onViewLogs={() => setActiveTab('audit')}
|
||||
onMonitorEvents={() => setActiveTab('events')}
|
||||
loading={loading}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -252,16 +252,8 @@ const Updates: React.FC = () => {
|
|||
const handleConfirmDependencies = async (updateId: string) => {
|
||||
setDependencyLoading(true);
|
||||
try {
|
||||
const response = await fetch(`/api/v1/updates/${updateId}/confirm-dependencies`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to confirm dependencies');
|
||||
}
|
||||
// Route through the API layer so auth headers are injected (was a raw fetch).
|
||||
await updateApi.confirmDependencies(updateId);
|
||||
|
||||
toast.success('Dependency installation confirmed');
|
||||
setShowDependencyModal(false);
|
||||
|
|
@ -270,7 +262,7 @@ const Updates: React.FC = () => {
|
|||
|
||||
// Refresh the update data
|
||||
queryClient.invalidateQueries({ queryKey: ['updates'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['update', dependencyUpdateId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['update', updateId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['activeCommands'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['dashboard-stats'] });
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -104,7 +104,7 @@ const MaintenanceWindowsPage: React.FC = () => {
|
|||
<span className={`text-sm font-medium ${check?.inside_window ? 'text-green-800' : 'text-gray-600'}`}>
|
||||
{check?.inside_window
|
||||
? 'Currently inside a maintenance window — installations will proceed'
|
||||
: 'Outside maintenance windows — installations will be blocked during scheduled windows'}
|
||||
: 'Outside maintenance windows — approved installs are held until the next scheduled window'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -134,6 +134,8 @@ export interface SecurityFeatureStatus {
|
|||
export interface SecurityStatusCardProps {
|
||||
status: SecurityStatus;
|
||||
onRefresh?: () => void;
|
||||
onViewLogs?: () => void;
|
||||
onMonitorEvents?: () => void;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue