v0.1.16: Security overhaul and systematic deployment preparation

Breaking changes for clean alpha releases:
- JWT authentication with user-provided secrets (no more development defaults)
- Registration token system for secure agent enrollment
- Rate limiting with user-adjustable settings
- Enhanced agent configuration with proxy support
- Interactive server setup wizard (--setup flag)
- Heartbeat architecture separation for better UX
- Package status synchronization fixes
- Accurate timestamp tracking for RMM features

Setup process for new installations:
1. docker-compose up -d postgres
2. ./redflag-server --setup
3. ./redflag-server --migrate
4. ./redflag-server
5. Generate tokens via admin UI
6. Deploy agents with registration tokens
This commit is contained in:
Fimeg
2025-10-29 10:38:18 -04:00
parent b3e1b9e52f
commit 03fee29760
50 changed files with 5807 additions and 466 deletions

View File

@@ -10,6 +10,8 @@ import Docker from '@/pages/Docker';
import LiveOperations from '@/pages/LiveOperations';
import History from '@/pages/History';
import Settings from '@/pages/Settings';
import TokenManagement from '@/pages/TokenManagement';
import RateLimiting from '@/pages/RateLimiting';
import Login from '@/pages/Login';
// Protected route component
@@ -98,6 +100,8 @@ const App: React.FC = () => {
<Route path="/live" element={<LiveOperations />} />
<Route path="/history" element={<History />} />
<Route path="/settings" element={<Settings />} />
<Route path="/settings/tokens" element={<TokenManagement />} />
<Route path="/settings/rate-limiting" element={<RateLimiting />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Layout>

View File

@@ -18,6 +18,7 @@ import {
} from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { logApi } from '@/lib/api';
import { useRetryCommand } from '@/hooks/useCommands';
import { cn } from '@/lib/utils';
import toast from 'react-hot-toast';
import { Highlight, themes } from 'prism-react-renderer';
@@ -47,11 +48,80 @@ interface ChatTimelineProps {
externalSearch?: string; // external search query from parent
}
// Helper function to create smart summaries for package operations
const createPackageOperationSummary = (entry: HistoryEntry): string => {
const action = entry.action.replace(/_/g, ' ');
const result = entry.result || 'unknown';
// Extract package name from stdout or params
let packageName = 'unknown package';
if (entry.params?.package_name) {
packageName = entry.params.package_name as string;
} else if (entry.stdout) {
// Look for package patterns in stdout
const packageMatch = entry.stdout.match(/(?:Upgrading|Installing|Package):\s+(\S+)/i);
if (packageMatch) {
packageName = packageMatch[1];
} else {
// Look for "Packages installed: [pkg]" pattern
const installedMatch = entry.stdout.match(/Packages installed:\s*\[([^\]]+)\]/i);
if (installedMatch) {
packageName = installedMatch[1];
}
}
}
// Extract duration if available
let durationInfo = '';
if (entry.logged_at) {
try {
const loggedTime = new Date(entry.logged_at).toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit'
});
durationInfo = ` at ${loggedTime}`;
if (entry.duration_seconds) {
durationInfo += ` (${entry.duration_seconds}s)`;
}
} catch (e) {
// Ignore date parsing errors
}
}
// Create action-specific summaries
switch (entry.action) {
case 'upgrade':
case 'install':
case 'confirm_dependencies':
if (result === 'success' || result === 'completed') {
return `Successfully ${action}d ${packageName}${durationInfo}`;
} else if (result === 'failed' || result === 'error') {
return `Failed to ${action} ${packageName}${durationInfo}`;
} else {
return `${action.charAt(0).toUpperCase() + action.slice(1)} ${packageName}${durationInfo}`;
}
case 'dry_run_update':
if (result === 'success' || result === 'completed') {
return `Dry run completed for ${packageName}${durationInfo}`;
} else {
return `Dry run for ${packageName}${durationInfo}`;
}
default:
return `${action} ${packageName}${durationInfo}`;
}
};
const ChatTimeline: React.FC<ChatTimelineProps> = ({ agentId, className, isScopedView = false, externalSearch }) => {
const [statusFilter, setStatusFilter] = useState('all'); // 'all', 'success', 'failed', 'pending', 'completed', 'running', 'timed_out'
const [expandedEntries, setExpandedEntries] = useState<Set<string>>(new Set());
const [selectedAgents, setSelectedAgents] = useState<string[]>([]);
// Retry command hook
const retryCommandMutation = useRetryCommand();
// Query parameters for API
const [queryParams, setQueryParams] = useState({
page: 1,
@@ -440,18 +510,23 @@ const ChatTimeline: React.FC<ChatTimelineProps> = ({ agentId, className, isScope
sentence = `System log: ${entry.action}`;
}
} catch {
const lines = entry.stdout.split('\n');
const firstLine = lines[0]?.trim();
// Clean up common prefixes for more elegant system thoughts
if (firstLine) {
sentence = firstLine
.replace(/^(INFO|WARN|ERROR|DEBUG):\s*/i, '')
.replace(/^Step \d+:\s*/i, '')
.replace(/^Command:\s*/i, '')
.replace(/^Output:\s*/i, '')
.trim() || `System log: ${entry.action}`;
// Create smart summary for package management operations
if (['upgrade', 'install', 'confirm_dependencies', 'dry_run_update'].includes(entry.action)) {
sentence = createPackageOperationSummary(entry);
} else {
sentence = `System log: ${entry.action}`;
const lines = entry.stdout.split('\n');
const firstLine = lines[0]?.trim();
// Clean up common prefixes for more elegant system thoughts
if (firstLine) {
sentence = firstLine
.replace(/^(INFO|WARN|ERROR|DEBUG):\s*/i, '')
.replace(/^Step \d+:\s*/i, '')
.replace(/^Command:\s*/i, '')
.replace(/^Output:\s*/i, '')
.trim() || `System log: ${entry.action}`;
} else {
sentence = `System log: ${entry.action}`;
}
}
}
} else {
@@ -564,8 +639,8 @@ const ChatTimeline: React.FC<ChatTimelineProps> = ({ agentId, className, isScope
)}
{narrative.statusType === 'pending' && (
<>
<Clock className="h-3 w-3 text-purple-600" />
<span className="font-mono text-xs bg-purple-100 text-purple-800 px-1.5 py-0.5 rounded">
<Clock className="h-3 w-3 text-amber-600" />
<span className="font-mono text-xs bg-amber-100 text-amber-800 px-1.5 py-0.5 rounded">
PENDING
</span>
</>
@@ -862,15 +937,24 @@ const ChatTimeline: React.FC<ChatTimelineProps> = ({ agentId, className, isScope
{(entry.result === 'failed' || entry.result === 'timed_out') && (
<button
onClick={(e) => {
onClick={async (e) => {
e.stopPropagation();
// Handle retry logic - would integrate with API
toast.success(`Retry command sent to ${entry.hostname || 'agent'}`);
try {
await retryCommandMutation.mutateAsync(entry.id);
toast.success(`Retry command sent to ${entry.hostname || 'agent'}`);
} catch (error: any) {
toast.error(`Failed to retry command: ${error.message || 'Unknown error'}`);
}
}}
disabled={retryCommandMutation.isPending}
className="inline-flex items-center px-2.5 py-1.5 bg-amber-50 text-amber-700 rounded-md hover:bg-amber-100 transition-colors font-medium"
>
<RefreshCw className="h-3 w-3 mr-1" />
Retry Command
{retryCommandMutation.isPending ? (
<RefreshCw className="h-3 w-3 mr-1 animate-spin" />
) : (
<RefreshCw className="h-3 w-3 mr-1" />
)}
{retryCommandMutation.isPending ? 'Retrying...' : 'Retry Command'}
</button>
)}
</div>
@@ -988,10 +1072,11 @@ const ChatTimeline: React.FC<ChatTimelineProps> = ({ agentId, className, isScope
</p>
</div>
) : (
<div className="bg-gray-50 rounded-lg border border-gray-200 p-4">
<div className="space-y-4">
{createTimelineWithDividers(filteredEntries)}
</div>
<div className={cn(
isScopedView ? "bg-gray-50 rounded-lg border border-gray-200 p-4" : "",
"space-y-4"
)}>
{createTimelineWithDividers(filteredEntries)}
</div>
)}

View File

@@ -7,8 +7,10 @@ export const useAgents = (params?: ListQueryParams): UseQueryResult<AgentListRes
return useQuery({
queryKey: ['agents', params],
queryFn: () => agentApi.getAgents(params),
staleTime: 30 * 1000, // Consider data stale after 30 seconds
refetchInterval: 60 * 1000, // Auto-refetch every minute
staleTime: 30 * 1000, // Consider data fresh for 30 seconds
refetchInterval: 60 * 1000, // Poll every 60 seconds
refetchIntervalInBackground: false, // Don't poll when tab is inactive
refetchOnWindowFocus: true, // Refresh when window gains focus
});
};
@@ -17,6 +19,10 @@ export const useAgent = (id: string, enabled: boolean = true): UseQueryResult<Ag
queryKey: ['agent', id],
queryFn: () => agentApi.getAgent(id),
enabled: enabled && !!id,
staleTime: 30 * 1000, // Consider data fresh for 30 seconds
refetchInterval: 30 * 1000, // Poll every 30 seconds for selected agent
refetchIntervalInBackground: false, // Don't poll when tab is inactive
refetchOnWindowFocus: true, // Refresh when window gains focus
});
};

View File

@@ -15,11 +15,12 @@ interface ActiveCommand {
package_type: string;
}
export const useActiveCommands = (): UseQueryResult<{ commands: ActiveCommand[]; count: number }, Error> => {
export const useActiveCommands = (autoRefresh: boolean = true): UseQueryResult<{ commands: ActiveCommand[]; count: number }, Error> => {
return useQuery({
queryKey: ['activeCommands'],
queryFn: () => updateApi.getActiveCommands(),
refetchInterval: 5000, // Auto-refresh every 5 seconds
refetchInterval: autoRefresh ? 5000 : false, // Auto-refresh every 5 seconds when enabled
staleTime: 0, // Override global staleTime to allow refetchInterval to work
});
};
@@ -54,4 +55,21 @@ export const useCancelCommand = (): UseMutationResult<void, Error, string, unkno
queryClient.invalidateQueries({ queryKey: ['recentCommands'] });
},
});
};
export const useClearFailedCommands = (): UseMutationResult<{ message: string; count: number; cheeky_warning?: string }, Error, {
olderThanDays?: number;
onlyRetried?: boolean;
allFailed?: boolean;
}, unknown> => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: updateApi.clearFailedCommands,
onSuccess: () => {
// Invalidate active and recent commands queries to refresh the UI
queryClient.invalidateQueries({ queryKey: ['activeCommands'] });
queryClient.invalidateQueries({ queryKey: ['recentCommands'] });
},
});
};

View File

@@ -0,0 +1,63 @@
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { agentApi } from '@/lib/api';
import type { UseQueryResult } from '@tanstack/react-query';
export interface HeartbeatStatus {
enabled: boolean;
until: string | null;
active: boolean;
duration_minutes: number;
}
export const useHeartbeatStatus = (agentId: string, enabled: boolean = true): UseQueryResult<HeartbeatStatus, Error> => {
const queryClient = useQueryClient();
return useQuery({
queryKey: ['heartbeat', agentId],
queryFn: () => agentApi.getHeartbeatStatus(agentId),
enabled: enabled && !!agentId,
staleTime: 5000, // Consider data stale after 5 seconds
refetchInterval: (query) => {
// Smart polling: only poll when heartbeat is active
const data = query.state.data as HeartbeatStatus | undefined;
// If heartbeat is enabled and still active, poll every 5 seconds
if (data?.enabled && data?.active) {
return 5000; // 5 seconds
}
// If heartbeat is not active, don't poll
return false;
},
refetchOnWindowFocus: false, // Don't refresh when window gains focus
refetchOnMount: true, // Always refetch when component mounts
});
};
// Hook to manually invalidate heartbeat cache (used after commands)
export const useInvalidateHeartbeat = () => {
const queryClient = useQueryClient();
return (agentId: string) => {
// Invalidate heartbeat cache
queryClient.invalidateQueries({ queryKey: ['heartbeat', agentId] });
// Also invalidate agent cache to synchronize data
queryClient.invalidateQueries({ queryKey: ['agent', agentId] });
queryClient.invalidateQueries({ queryKey: ['agents'] });
};
};
// Hook to synchronize agent data when heartbeat status changes
export const useHeartbeatAgentSync = (agentId: string, heartbeatStatus?: HeartbeatStatus) => {
const queryClient = useQueryClient();
// Sync agent data when heartbeat status changes
return () => {
if (agentId && heartbeatStatus) {
// Invalidate agent cache to get updated last_seen and status
queryClient.invalidateQueries({ queryKey: ['agent', agentId] });
queryClient.invalidateQueries({ queryKey: ['agents'] });
}
};
};

View File

@@ -0,0 +1,131 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'react-hot-toast';
import { adminApi } from '@/lib/api';
import {
RateLimitConfig,
RateLimitStats,
RateLimitUsage,
RateLimitSummary
} from '@/types';
// Query keys
export const rateLimitKeys = {
all: ['rate-limits'] as const,
configs: () => [...rateLimitKeys.all, 'configs'] as const,
stats: () => [...rateLimitKeys.all, 'stats'] as const,
usage: () => [...rateLimitKeys.all, 'usage'] as const,
summary: () => [...rateLimitKeys.all, 'summary'] as const,
};
// Hooks
export const useRateLimitConfigs = () => {
return useQuery({
queryKey: rateLimitKeys.configs(),
queryFn: () => adminApi.rateLimits.getConfigs(),
staleTime: 1000 * 60 * 5, // 5 minutes
});
};
export const useRateLimitStats = () => {
return useQuery({
queryKey: rateLimitKeys.stats(),
queryFn: () => adminApi.rateLimits.getStats(),
staleTime: 1000 * 30, // 30 seconds
refetchInterval: 1000 * 30, // Refresh every 30 seconds for real-time monitoring
});
};
export const useRateLimitUsage = () => {
return useQuery({
queryKey: rateLimitKeys.usage(),
queryFn: () => adminApi.rateLimits.getUsage(),
staleTime: 1000 * 15, // 15 seconds
refetchInterval: 1000 * 15, // Refresh every 15 seconds for live usage
});
};
export const useRateLimitSummary = () => {
return useQuery({
queryKey: rateLimitKeys.summary(),
queryFn: () => adminApi.rateLimits.getSummary(),
staleTime: 1000 * 60, // 1 minute
refetchInterval: 1000 * 60, // Refresh every minute
});
};
export const useUpdateRateLimitConfig = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ endpoint, config }: { endpoint: string; config: Partial<RateLimitConfig> }) =>
adminApi.rateLimits.updateConfig(endpoint, config),
onSuccess: (_, { endpoint }) => {
toast.success(`Rate limit configuration for ${endpoint} updated successfully`);
queryClient.invalidateQueries({ queryKey: rateLimitKeys.configs() });
queryClient.invalidateQueries({ queryKey: rateLimitKeys.stats() });
queryClient.invalidateQueries({ queryKey: rateLimitKeys.usage() });
queryClient.invalidateQueries({ queryKey: rateLimitKeys.summary() });
},
onError: (error: any, { endpoint }) => {
console.error(`Failed to update rate limit config for ${endpoint}:`, error);
toast.error(error.response?.data?.message || `Failed to update rate limit configuration for ${endpoint}`);
},
});
};
export const useUpdateAllRateLimitConfigs = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (configs: RateLimitConfig[]) =>
adminApi.rateLimits.updateAllConfigs(configs),
onSuccess: () => {
toast.success('All rate limit configurations updated successfully');
queryClient.invalidateQueries({ queryKey: rateLimitKeys.configs() });
queryClient.invalidateQueries({ queryKey: rateLimitKeys.stats() });
queryClient.invalidateQueries({ queryKey: rateLimitKeys.usage() });
queryClient.invalidateQueries({ queryKey: rateLimitKeys.summary() });
},
onError: (error: any) => {
console.error('Failed to update rate limit configurations:', error);
toast.error(error.response?.data?.message || 'Failed to update rate limit configurations');
},
});
};
export const useResetRateLimitConfigs = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => adminApi.rateLimits.resetConfigs(),
onSuccess: () => {
toast.success('Rate limit configurations reset to defaults successfully');
queryClient.invalidateQueries({ queryKey: rateLimitKeys.configs() });
queryClient.invalidateQueries({ queryKey: rateLimitKeys.stats() });
queryClient.invalidateQueries({ queryKey: rateLimitKeys.usage() });
queryClient.invalidateQueries({ queryKey: rateLimitKeys.summary() });
},
onError: (error: any) => {
console.error('Failed to reset rate limit configurations:', error);
toast.error(error.response?.data?.message || 'Failed to reset rate limit configurations');
},
});
};
export const useCleanupRateLimits = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => adminApi.rateLimits.cleanup(),
onSuccess: (result) => {
toast.success(`Cleaned up ${result.cleaned} expired rate limit entries`);
queryClient.invalidateQueries({ queryKey: rateLimitKeys.stats() });
queryClient.invalidateQueries({ queryKey: rateLimitKeys.usage() });
queryClient.invalidateQueries({ queryKey: rateLimitKeys.summary() });
},
onError: (error: any) => {
console.error('Failed to cleanup rate limits:', error);
toast.error(error.response?.data?.message || 'Failed to cleanup rate limits');
},
});
};

View File

@@ -0,0 +1,103 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'react-hot-toast';
import { adminApi } from '@/lib/api';
import {
RegistrationToken,
CreateRegistrationTokenRequest,
RegistrationTokenStats
} from '@/types';
// Query keys
export const registrationTokenKeys = {
all: ['registration-tokens'] as const,
lists: () => [...registrationTokenKeys.all, 'list'] as const,
list: (params: any) => [...registrationTokenKeys.lists(), params] as const,
details: () => [...registrationTokenKeys.all, 'detail'] as const,
detail: (id: string) => [...registrationTokenKeys.details(), id] as const,
stats: () => [...registrationTokenKeys.all, 'stats'] as const,
};
// Hooks
export const useRegistrationTokens = (params?: {
page?: number;
page_size?: number;
is_active?: boolean;
label?: string;
}) => {
return useQuery({
queryKey: registrationTokenKeys.list(params),
queryFn: () => adminApi.tokens.getTokens(params),
staleTime: 1000 * 60, // 1 minute
});
};
export const useRegistrationToken = (id: string) => {
return useQuery({
queryKey: registrationTokenKeys.detail(id),
queryFn: () => adminApi.tokens.getToken(id),
enabled: !!id,
staleTime: 1000 * 60, // 1 minute
});
};
export const useRegistrationTokenStats = () => {
return useQuery({
queryKey: registrationTokenKeys.stats(),
queryFn: () => adminApi.tokens.getStats(),
staleTime: 1000 * 60, // 1 minute
refetchInterval: 1000 * 60 * 5, // Refresh every 5 minutes
});
};
export const useCreateRegistrationToken = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateRegistrationTokenRequest) =>
adminApi.tokens.createToken(data),
onSuccess: (newToken) => {
toast.success(`Registration token "${newToken.label}" created successfully`);
queryClient.invalidateQueries({ queryKey: registrationTokenKeys.lists() });
queryClient.invalidateQueries({ queryKey: registrationTokenKeys.stats() });
},
onError: (error: any) => {
console.error('Failed to create registration token:', error);
toast.error(error.response?.data?.message || 'Failed to create registration token');
},
});
};
export const useRevokeRegistrationToken = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => adminApi.tokens.revokeToken(id),
onSuccess: (_, tokenId) => {
toast.success('Registration token revoked successfully');
queryClient.invalidateQueries({ queryKey: registrationTokenKeys.lists() });
queryClient.invalidateQueries({ queryKey: registrationTokenKeys.detail(tokenId) });
queryClient.invalidateQueries({ queryKey: registrationTokenKeys.stats() });
},
onError: (error: any) => {
console.error('Failed to revoke registration token:', error);
toast.error(error.response?.data?.message || 'Failed to revoke registration token');
},
});
};
export const useCleanupRegistrationTokens = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => adminApi.tokens.cleanup(),
onSuccess: (result) => {
toast.success(`Cleaned up ${result.cleaned} expired tokens`);
queryClient.invalidateQueries({ queryKey: registrationTokenKeys.lists() });
queryClient.invalidateQueries({ queryKey: registrationTokenKeys.stats() });
},
onError: (error: any) => {
console.error('Failed to cleanup registration tokens:', error);
toast.error(error.response?.data?.message || 'Failed to cleanup registration tokens');
},
});
};

View File

@@ -14,7 +14,14 @@ import {
DockerContainerListResponse,
DockerStats,
DockerUpdateRequest,
BulkDockerUpdateRequest
BulkDockerUpdateRequest,
RegistrationToken,
CreateRegistrationTokenRequest,
RegistrationTokenStats,
RateLimitConfig,
RateLimitStats,
RateLimitUsage,
RateLimitSummary
} from '@/types';
// Base URL for API
@@ -75,6 +82,21 @@ export const agentApi = {
await api.post(`/agents/${id}/scan`);
},
// Trigger heartbeat toggle on single agent
toggleHeartbeat: async (id: string, enabled: boolean, durationMinutes: number = 10): Promise<{ message: string; command_id: string; enabled: boolean }> => {
const response = await api.post(`/agents/${id}/heartbeat`, {
enabled: enabled,
duration_minutes: durationMinutes,
});
return response.data;
},
// Get heartbeat status for single agent
getHeartbeatStatus: async (id: string): Promise<{ enabled: boolean; until: string | null; active: boolean; duration_minutes: number }> => {
const response = await api.get(`/agents/${id}/heartbeat`);
return response.data;
},
// Unregister/remove agent
unregisterAgent: async (id: string): Promise<void> => {
await api.delete(`/agents/${id}`);
@@ -147,6 +169,28 @@ export const updateApi = {
});
return response.data;
},
// Clear failed commands with filtering options
clearFailedCommands: async (options?: {
olderThanDays?: number;
onlyRetried?: boolean;
allFailed?: boolean;
}): Promise<{ message: string; count: number; cheeky_warning?: string }> => {
const params = new URLSearchParams();
if (options?.olderThanDays !== undefined) {
params.append('older_than_days', options.olderThanDays.toString());
}
if (options?.onlyRetried) {
params.append('only_retried', 'true');
}
if (options?.allFailed) {
params.append('all_failed', 'true');
}
const response = await api.delete(`/commands/failed${params.toString() ? '?' + params.toString() : ''}`);
return response.data;
},
};
export const statsApi = {
@@ -351,4 +395,144 @@ export const dockerApi = {
},
};
// Admin API endpoints
export const adminApi = {
// Registration Token Management
tokens: {
// Get all registration tokens
getTokens: async (params?: {
page?: number;
page_size?: number;
is_active?: boolean;
label?: string;
}): Promise<{ tokens: RegistrationToken[]; total: number; page: number; page_size: number }> => {
const response = await api.get('/admin/registration-tokens', { params });
return response.data;
},
// Get single registration token
getToken: async (id: string): Promise<RegistrationToken> => {
const response = await api.get(`/admin/registration-tokens/${id}`);
return response.data;
},
// Create new registration token
createToken: async (request: CreateRegistrationTokenRequest): Promise<RegistrationToken> => {
const response = await api.post('/admin/registration-tokens', request);
return response.data;
},
// Revoke registration token
revokeToken: async (id: string): Promise<void> => {
await api.delete(`/admin/registration-tokens/${id}`);
},
// Get registration token statistics
getStats: async (): Promise<RegistrationTokenStats> => {
const response = await api.get('/admin/registration-tokens/stats');
return response.data;
},
// Cleanup expired tokens
cleanup: async (): Promise<{ cleaned: number }> => {
const response = await api.post('/admin/registration-tokens/cleanup');
return response.data;
},
},
// Rate Limiting Management
rateLimits: {
// Get all rate limit configurations
getConfigs: async (): Promise<RateLimitConfig[]> => {
const response = await api.get('/admin/rate-limits');
return response.data;
},
// Update rate limit configuration
updateConfig: async (endpoint: string, config: Partial<RateLimitConfig>): Promise<RateLimitConfig> => {
const response = await api.put(`/admin/rate-limits/${endpoint}`, config);
return response.data;
},
// Update all rate limit configurations
updateAllConfigs: async (configs: RateLimitConfig[]): Promise<RateLimitConfig[]> => {
const response = await api.put('/admin/rate-limits', { configs });
return response.data;
},
// Reset rate limit configurations to defaults
resetConfigs: async (): Promise<RateLimitConfig[]> => {
const response = await api.post('/admin/rate-limits/reset');
return response.data;
},
// Get rate limit statistics
getStats: async (): Promise<RateLimitStats[]> => {
const response = await api.get('/admin/rate-limits/stats');
return response.data;
},
// Get rate limit usage
getUsage: async (): Promise<RateLimitUsage[]> => {
const response = await api.get('/admin/rate-limits/usage');
return response.data;
},
// Get rate limit summary
getSummary: async (): Promise<RateLimitSummary> => {
const response = await api.get('/admin/rate-limits/summary');
return response.data;
},
// Cleanup expired rate limit data
cleanup: async (): Promise<{ cleaned: number }> => {
const response = await api.post('/admin/rate-limits/cleanup');
return response.data;
},
},
// System Administration
system: {
// Get system health and status
getHealth: async (): Promise<{
status: 'healthy' | 'degraded' | 'unhealthy';
uptime: number;
version: string;
database_status: 'connected' | 'disconnected';
active_agents: number;
active_tokens: number;
rate_limits_enabled: boolean;
}> => {
const response = await api.get('/admin/system/health');
return response.data;
},
// Get active agents
getActiveAgents: async (): Promise<{
agents: Array<{
id: string;
hostname: string;
last_seen: string;
status: string;
}>;
count: number;
}> => {
const response = await api.get('/admin/system/active-agents');
return response.data;
},
// Get system configuration
getConfig: async (): Promise<Record<string, any>> => {
const response = await api.get('/admin/system/config');
return response.data;
},
// Update system configuration
updateConfig: async (config: Record<string, any>): Promise<Record<string, any>> => {
const response = await api.put('/admin/system/config', config);
return response.data;
},
},
};
export default api;

View File

@@ -18,6 +18,19 @@ export const formatDate = (dateString: string): string => {
});
};
export const formatDateTime = (dateString: string | null): string => {
if (!dateString) return 'Never';
const date = new Date(dateString);
return date.toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
export const formatRelativeTime = (dateString: string): string => {
if (!dateString) return 'Never';

View File

@@ -10,7 +10,8 @@ const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 2,
staleTime: 10 * 1000, // 10 seconds
staleTime: 0, // Data is always stale to allow real-time updates
refetchOnWindowFocus: false, // Don't refetch on window focus to avoid unnecessary requests
},
},
})

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import {
Computer,
@@ -6,6 +6,7 @@ import {
Search,
Filter,
ChevronRight as ChevronRightIcon,
ChevronDown,
Activity,
Calendar,
Package,
@@ -23,6 +24,9 @@ import {
} from 'lucide-react';
import { useAgents, useAgent, useScanAgent, useScanMultipleAgents, useUnregisterAgent } from '@/hooks/useAgents';
import { useActiveCommands, useCancelCommand } from '@/hooks/useCommands';
import { useHeartbeatStatus, useInvalidateHeartbeat, useHeartbeatAgentSync } from '@/hooks/useHeartbeat';
import { agentApi } from '@/lib/api';
import { useQueryClient } from '@tanstack/react-query';
import { getStatusColor, formatRelativeTime, isOnline, formatBytes } from '@/lib/utils';
import { cn } from '@/lib/utils';
import toast from 'react-hot-toast';
@@ -32,6 +36,7 @@ import ChatTimeline from '@/components/ChatTimeline';
const Agents: React.FC = () => {
const { id } = useParams<{ id?: string }>();
const navigate = useNavigate();
const queryClient = useQueryClient();
const [searchQuery, setSearchQuery] = useState('');
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState('');
const [statusFilter, setStatusFilter] = useState<string>('all');
@@ -39,6 +44,40 @@ const Agents: React.FC = () => {
const [showFilters, setShowFilters] = useState(false);
const [selectedAgents, setSelectedAgents] = useState<string[]>([]);
const [activeTab, setActiveTab] = useState<'overview' | 'history'>('overview');
const [currentTime, setCurrentTime] = useState(new Date());
const [heartbeatDuration, setHeartbeatDuration] = useState<number>(10); // Default 10 minutes
const [showDurationDropdown, setShowDurationDropdown] = useState(false);
const [heartbeatLoading, setHeartbeatLoading] = useState(false); // Loading state for heartbeat toggle
const [heartbeatCommandId, setHeartbeatCommandId] = useState<string | null>(null); // Track specific heartbeat command
const dropdownRef = useRef<HTMLDivElement>(null);
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setShowDurationDropdown(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, []);
// Duration options for heartbeat
const durationOptions = [
{ label: '10 minutes', value: 10 },
{ label: '30 minutes', value: 30 },
{ label: '1 hour', value: 60 },
{ label: 'Permanent', value: -1 },
];
// Get duration label for display
const getDurationLabel = (duration: number) => {
const option = durationOptions.find(opt => opt.value === duration);
return option?.label || '10 minutes';
};
// Debounce search query to avoid API calls on every keystroke
useEffect(() => {
@@ -51,6 +90,18 @@ const Agents: React.FC = () => {
};
}, [searchQuery]);
// Update current time every second for countdown timers
useEffect(() => {
const timer = setInterval(() => {
setCurrentTime(new Date());
}, 1000);
return () => {
clearInterval(timer);
};
}, []);
// Helper function to get system metadata from agent
const getSystemMetadata = (agent: any) => {
const metadata = agent.metadata || {};
@@ -124,8 +175,41 @@ const Agents: React.FC = () => {
return { platform, distribution, version: version.trim() };
};
// Helper function to format heartbeat expiration time
const formatHeartExpiration = (untilString: string) => {
const until = new Date(untilString);
const now = new Date();
const diffMs = until.getTime() - now.getTime();
if (diffMs <= 0) {
return 'expired';
}
const diffMinutes = Math.floor(diffMs / (1000 * 60));
if (diffMinutes < 60) {
return `${diffMinutes} minute${diffMinutes !== 1 ? 's' : ''}`;
}
const diffHours = Math.floor(diffMinutes / 60);
const remainingMinutes = diffMinutes % 60;
if (diffHours < 24) {
return remainingMinutes > 0
? `${diffHours} hour${diffHours !== 1 ? 's' : ''} ${remainingMinutes} min`
: `${diffHours} hour${diffHours !== 1 ? 's' : ''}`;
}
const diffDays = Math.floor(diffHours / 24);
const remainingHours = diffHours % 24;
return remainingHours > 0
? `${diffDays} day${diffDays !== 1 ? 's' : ''} ${remainingHours} hour${remainingHours !== 1 ? 's' : ''}`
: `${diffDays} day${diffDays !== 1 ? 's' : ''}`;
};
// Fetch agents list
const { data: agentsData, isPending, error } = useAgents({
const { data: agentsData, isPending, error, refetch } = useAgents({
search: debouncedSearchQuery || undefined,
status: statusFilter !== 'all' ? statusFilter : undefined,
});
@@ -141,9 +225,31 @@ const Agents: React.FC = () => {
const { data: activeCommandsData, refetch: refetchActiveCommands } = useActiveCommands();
const cancelCommandMutation = useCancelCommand();
const agents = agentsData?.agents || [];
const selectedAgent = selectedAgentData || agents.find(a => a.id === id);
// Get heartbeat status for selected agent (smart polling - only when active)
const { data: heartbeatStatus } = useHeartbeatStatus(selectedAgent?.id || '', !!selectedAgent);
const invalidateHeartbeat = useInvalidateHeartbeat();
const syncAgentData = useHeartbeatAgentSync(selectedAgent?.id || '', heartbeatStatus);
// Simple completion handling - clear loading state quickly
useEffect(() => {
if (!heartbeatCommandId) return;
// Clear loading state quickly since smart polling will handle UI updates
const timeout = setTimeout(() => {
setHeartbeatCommandId(null);
setHeartbeatLoading(false);
}, 2000); // 2 seconds - enough time for command to process
return () => {
clearTimeout(timeout);
};
}, [heartbeatCommandId]);
// Filter agents based on OS
const filteredAgents = agents.filter(agent => {
if (osFilter === 'all') return true;
@@ -224,6 +330,40 @@ const Agents: React.FC = () => {
}
};
// Handle rapid polling toggle
const handleRapidPollingToggle = async (agentId: string, enabled: boolean, durationMinutes?: number) => {
// Prevent multiple clicks
if (heartbeatLoading) return;
setHeartbeatLoading(true);
try {
const duration = durationMinutes || heartbeatDuration;
const result = await agentApi.toggleHeartbeat(agentId, enabled, duration);
// Immediately invalidate cache to force fresh data
invalidateHeartbeat(agentId);
// Store the command ID for minimal tracking
if (result.command_id) {
setHeartbeatCommandId(result.command_id);
}
if (enabled) {
if (duration === -1) {
toast.success('Heartbeat enabled permanently');
} else {
toast.success(`Heartbeat enabled for ${duration} minutes`);
}
} else {
toast.success('Heartbeat disabled');
}
} catch (error: any) {
toast.error(`Failed to send heartbeat command: ${error.message || 'Unknown error'}`);
setHeartbeatLoading(false);
setHeartbeatCommandId(null);
}
};
// Get agent-specific active commands
const getAgentActiveCommands = () => {
if (!selectedAgent || !activeCommandsData?.commands) return [];
@@ -232,11 +372,19 @@ const Agents: React.FC = () => {
// Helper function to get command display info
const getCommandDisplayInfo = (command: any) => {
// Helper to get package name from command params
const getPackageName = (cmd: any) => {
if (cmd.package_name) return cmd.package_name;
if (cmd.params?.package_name) return cmd.params.package_name;
if (cmd.params?.update_id && cmd.update_name) return cmd.update_name;
return 'unknown package';
};
const actionMap: { [key: string]: { icon: React.ReactNode; label: string } } = {
'scan': { icon: <RefreshCw className="h-4 w-4" />, label: 'System scan' },
'install_updates': { icon: <Package className="h-4 w-4" />, label: `Installing ${command.package_name || 'packages'}` },
'dry_run_update': { icon: <Search className="h-4 w-4" />, label: `Checking dependencies for ${command.package_name || 'packages'}` },
'confirm_dependencies': { icon: <CheckCircle className="h-4 w-4" />, label: `Installing confirmed dependencies` },
'install_updates': { icon: <Package className="h-4 w-4" />, label: `Installing ${getPackageName(command)}` },
'dry_run_update': { icon: <Search className="h-4 w-4" />, label: `Checking dependencies for ${getPackageName(command)}` },
'confirm_dependencies': { icon: <CheckCircle className="h-4 w-4" />, label: `Installing ${getPackageName(command)}` },
};
return actionMap[command.command_type] || {
@@ -386,22 +534,88 @@ const Agents: React.FC = () => {
{isOnline(selectedAgent.last_seen) ? 'Online' : 'Offline'}
</span>
</div>
{/* Heartbeat Status Indicator */}
<div className="flex items-center space-x-2">
{(() => {
// Use dedicated heartbeat status instead of general agent metadata
const isRapidPolling = heartbeatStatus?.enabled && heartbeatStatus?.active;
return (
<button
onClick={() => handleRapidPollingToggle(selectedAgent.id, !isRapidPolling)}
disabled={heartbeatLoading}
className={cn(
'flex items-center space-x-1 px-2 py-1 rounded-md text-xs font-medium transition-colors',
heartbeatLoading
? 'bg-gray-100 text-gray-400 border border-gray-200 cursor-not-allowed'
: isRapidPolling
? 'bg-pink-100 text-pink-800 border border-pink-200 hover:bg-pink-200 cursor-pointer'
: 'bg-gray-100 text-gray-600 border border-gray-200 hover:bg-gray-200 cursor-pointer'
)}
title={heartbeatLoading ? 'Sending command...' : `Click to toggle ${isRapidPolling ? 'normal' : 'heartbeat'} mode`}
>
{heartbeatLoading ? (
<RefreshCw className="h-3 w-3 animate-spin" />
) : (
<Activity className={cn(
'h-3 w-3',
isRapidPolling ? 'text-pink-600 animate-pulse' : 'text-gray-400'
)} />
)}
<span>
{heartbeatLoading ? 'Sending...' : isRapidPolling ? 'Heartbeat (5s)' : 'Normal (5m)'}
</span>
</button>
);
})()}
</div>
</div>
{/* Compact Timeline Display */}
<div className="space-y-2 mb-3">
{(() => {
const agentCommands = getAgentActiveCommands();
const activeCommands = agentCommands.filter(cmd =>
// Separate heartbeat commands from other commands
const heartbeatCommands = agentCommands.filter(cmd =>
cmd.command_type === 'enable_heartbeat' || cmd.command_type === 'disable_heartbeat'
);
const otherCommands = agentCommands.filter(cmd =>
cmd.command_type !== 'enable_heartbeat' && cmd.command_type !== 'disable_heartbeat'
);
// For heartbeat commands: only show the MOST RECENT one, but exclude old completed ones
const recentHeartbeatCommands = heartbeatCommands.filter(cmd => {
const createdTime = new Date(cmd.created_at);
const now = new Date();
const hoursOld = (now.getTime() - createdTime.getTime()) / (1000 * 60 * 60);
// Exclude completed/failed heartbeat commands older than 30 minutes
if ((cmd.status === 'completed' || cmd.status === 'failed' || cmd.status === 'timed_out') && hoursOld > 0.5) {
return false;
}
return true;
});
const latestHeartbeatCommand = recentHeartbeatCommands.length > 0
? [recentHeartbeatCommands.reduce((latest, cmd) =>
new Date(cmd.created_at) > new Date(latest.created_at) ? cmd : latest
)]
: [];
// For other commands: show active ones normally
const activeOtherCommands = otherCommands.filter(cmd =>
cmd.status === 'running' || cmd.status === 'sent' || cmd.status === 'pending'
);
const completedCommands = agentCommands.filter(cmd =>
const completedOtherCommands = otherCommands.filter(cmd =>
cmd.status === 'completed' || cmd.status === 'failed' || cmd.status === 'timed_out'
).slice(0, 1); // Only show last completed
const displayCommands = [
...activeCommands.slice(0, 2), // Max 2 active
...completedCommands.slice(0, 1) // Max 1 completed
...latestHeartbeatCommand.slice(0, 1), // Max 1 heartbeat (latest only)
...activeOtherCommands.slice(0, 2), // Max 2 active other commands
...completedOtherCommands.slice(0, 1) // Max 1 completed other command
].slice(0, 3); // Total max 3 entries
if (displayCommands.length === 0) {
@@ -454,7 +668,18 @@ const Agents: React.FC = () => {
</div>
<div className="flex items-center justify-between mt-1">
<span className="text-xs text-gray-500">
{formatRelativeTime(command.created_at)}
{(() => {
const createdTime = new Date(command.created_at);
const now = new Date();
const hoursOld = (now.getTime() - createdTime.getTime()) / (1000 * 60 * 60);
// Show exact time for commands older than 1 hour, relative time for recent ones
if (hoursOld > 1) {
return createdTime.toLocaleString();
} else {
return formatRelativeTime(command.created_at);
}
})()}
</span>
{isActive && (command.status === 'pending' || command.status === 'sent') && (
<button
@@ -479,6 +704,13 @@ const Agents: React.FC = () => {
<span>Last scan: {selectedAgent.last_scan ? formatRelativeTime(selectedAgent.last_scan) : 'Never'}</span>
</div>
{/* Heartbeat Status Info */}
{heartbeatStatus?.enabled && heartbeatStatus?.active && (
<div className="text-xs text-pink-600 bg-pink-50 px-2 py-1 rounded-md mt-2">
Heartbeat active for {formatHeartExpiration(heartbeatStatus.until)}
</div>
)}
{/* Action Button */}
<div className="flex justify-center mt-3 pt-3 border-t border-gray-200">
<button
@@ -650,6 +882,71 @@ const Agents: React.FC = () => {
View All Updates
</button>
{/* Split button for heartbeat with duration */}
<div className="flex space-x-2">
<button
onClick={() => {
// Use dedicated heartbeat status instead of general agent metadata
const isRapidPolling = heartbeatStatus?.enabled && heartbeatStatus?.active;
handleRapidPollingToggle(selectedAgent.id, !isRapidPolling);
}}
disabled={heartbeatLoading}
className={cn(
'flex-1 btn transition-colors',
heartbeatLoading
? 'opacity-50 cursor-not-allowed'
: heartbeatStatus?.enabled && heartbeatStatus?.active
? 'btn-primary' // Use primary style for active heartbeat
: 'btn-secondary' // Use secondary style for normal mode
)}
>
{heartbeatLoading ? (
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
) : (
<Activity className="h-4 w-4 mr-2" />
)}
{heartbeatLoading
? 'Sending Command...'
: heartbeatStatus?.enabled && heartbeatStatus?.active
? 'Disable Heartbeat'
: 'Enable Heartbeat (5s)'
}
</button>
{/* Duration dropdown */}
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setShowDurationDropdown(!showDurationDropdown)}
className="btn btn-secondary px-3 min-w-[100px]"
>
{getDurationLabel(heartbeatDuration)}
<ChevronDown className="h-4 w-4 ml-1" />
</button>
{showDurationDropdown && (
<div className="absolute right-0 mt-1 w-48 bg-white rounded-lg shadow-lg border border-gray-200 z-10">
<div className="py-1">
{durationOptions.map((option) => (
<button
key={option.value}
onClick={() => {
setHeartbeatDuration(option.value);
setShowDurationDropdown(false);
}}
className={cn(
'w-full px-4 py-2 text-left text-sm hover:bg-gray-100 transition-colors',
heartbeatDuration === option.value ? 'bg-gray-100 font-medium' : 'text-gray-700'
)}
>
{option.label}
</button>
))}
</div>
</div>
)}
</div>
</div>
<button
onClick={() => handleRemoveAgent(selectedAgent.id, selectedAgent.hostname)}
disabled={unregisterAgentMutation.isPending}

View File

@@ -19,9 +19,10 @@ import {
Eye,
RotateCcw,
X,
Archive,
} from 'lucide-react';
import { useAgents, useUpdates } from '@/hooks/useAgents';
import { useActiveCommands, useRetryCommand, useCancelCommand } from '@/hooks/useCommands';
import { useActiveCommands, useRetryCommand, useCancelCommand, useClearFailedCommands } from '@/hooks/useCommands';
import { getStatusColor, formatRelativeTime, isOnline } from '@/lib/utils';
import { cn } from '@/lib/utils';
import toast from 'react-hot-toast';
@@ -34,7 +35,7 @@ interface LiveOperation {
updateId: string;
packageName: string;
action: 'checking_dependencies' | 'installing' | 'pending_dependencies';
status: 'running' | 'completed' | 'failed' | 'waiting';
status: 'running' | 'completed' | 'failed' | 'pending' | 'sent';
startTime: Date;
duration?: number;
progress?: string;
@@ -42,21 +43,32 @@ interface LiveOperation {
error?: string;
commandId: string;
commandStatus: string;
isRetry?: boolean;
hasBeenRetried?: boolean;
retryCount?: number;
retriedFromId?: string;
}
const LiveOperations: React.FC = () => {
const [expandedOperation, setExpandedOperation] = useState<string | null>(null);
const [expandedOperations, setExpandedOperations] = useState<Set<string>>(new Set());
const [autoRefresh, setAutoRefresh] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [statusFilter, setStatusFilter] = useState<string>('all');
const [showFilters, setShowFilters] = useState(false);
const [showCleanupDialog, setShowCleanupDialog] = useState(false);
const [cleanupOptions, setCleanupOptions] = useState({
olderThanDays: 7,
onlyRetried: false,
allFailed: false
});
// Fetch active commands from API
const { data: activeCommandsData, refetch: refetchCommands } = useActiveCommands();
const { data: activeCommandsData, refetch: refetchCommands } = useActiveCommands(autoRefresh);
// Retry and cancel mutations
// Retry, cancel, and cleanup mutations
const retryMutation = useRetryCommand();
const cancelMutation = useCancelCommand();
const clearFailedMutation = useClearFailedCommands();
// Fetch agents for mapping
const { data: agentsData } = useAgents();
@@ -77,7 +89,9 @@ const LiveOperations: React.FC = () => {
if (cmd.status === 'failed' || cmd.status === 'timed_out') {
status = 'failed';
} else if (cmd.status === 'pending') {
status = 'waiting';
status = 'pending';
} else if (cmd.status === 'sent') {
status = 'sent';
} else if (cmd.status === 'completed') {
status = 'completed';
} else {
@@ -105,10 +119,16 @@ const LiveOperations: React.FC = () => {
packageName: cmd.package_name !== 'N/A' ? cmd.package_name : cmd.command_type,
action,
status,
startTime: new Date(cmd.created_at),
startTime: cmd.created_at ? new Date(cmd.created_at) : new Date(),
progress: getStatusText(cmd.command_type, cmd.status),
commandId: cmd.id,
commandStatus: cmd.status,
logOutput: cmd.result?.stdout || cmd.result?.stderr,
error: cmd.result?.error_message,
isRetry: cmd.is_retry || false,
hasBeenRetried: cmd.has_been_retried || false,
retryCount: cmd.retry_count || 0,
retriedFromId: cmd.retried_from_id,
};
});
}, [activeCommandsData, agents]);
@@ -138,6 +158,32 @@ const LiveOperations: React.FC = () => {
}
};
// Handle cleanup failed commands
const handleClearFailedCommands = async () => {
try {
const result = await clearFailedMutation.mutateAsync(cleanupOptions);
toast.success(result.message);
if (result.cheeky_warning) {
// Optional: Show a secondary toast with the cheeky warning
setTimeout(() => {
toast(result.cheeky_warning, {
icon: '⚠️',
style: {
background: '#fef3c7',
color: '#92400e',
},
});
}, 1000);
}
setShowCleanupDialog(false);
} catch (error: any) {
toast.error(`Failed to clear failed commands: ${error.message || 'Unknown error'}`);
}
};
// Count failed operations for display
const failedCount = activeOperations.filter(op => op.status === 'failed').length;
function getStatusText(commandType: string, status: string): string {
if (commandType === 'dry_run_update') {
return status === 'pending' ? 'Pending dependency check...' : 'Checking for required dependencies...';
@@ -172,7 +218,8 @@ const LiveOperations: React.FC = () => {
return <CheckCircle className="h-4 w-4" />;
case 'failed':
return <XCircle className="h-4 w-4" />;
case 'waiting':
case 'pending':
case 'sent':
return <Clock className="h-4 w-4" />;
default:
return <Activity className="h-4 w-4" />;
@@ -235,6 +282,15 @@ const LiveOperations: React.FC = () => {
<RefreshCw className="h-4 w-4" />
<span>Refresh Now</span>
</button>
{failedCount > 0 && (
<button
onClick={() => setShowCleanupDialog(true)}
className="flex items-center space-x-2 px-3 py-2 bg-red-100 text-red-700 rounded-lg hover:bg-red-200 text-sm font-medium transition-colors"
>
<Archive className="h-4 w-4" />
<span>Archive Failed ({failedCount})</span>
</button>
)}
</div>
</div>
@@ -265,9 +321,9 @@ const LiveOperations: React.FC = () => {
<div className="bg-white p-4 rounded-lg border border-amber-200 shadow-sm">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">Waiting</p>
<p className="text-sm font-medium text-gray-600">Pending</p>
<p className="text-2xl font-bold text-amber-600">
{activeOperations.filter(op => op.status === 'waiting').length}
{activeOperations.filter(op => op.status === 'pending' || op.status === 'sent').length}
</p>
</div>
<Clock className="h-8 w-8 text-amber-400" />
@@ -331,7 +387,8 @@ const LiveOperations: React.FC = () => {
>
<option value="all">All Status</option>
<option value="running">Running</option>
<option value="waiting">Waiting</option>
<option value="pending">Pending</option>
<option value="sent">Sent</option>
<option value="completed">Completed</option>
<option value="failed">Failed</option>
</select>
@@ -371,6 +428,17 @@ const LiveOperations: React.FC = () => {
{getStatusIcon(operation.status)}
<span className="ml-1">{operation.status}</span>
</span>
{operation.isRetry && operation.retryCount && operation.retryCount > 0 && (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-800 border border-purple-200">
<RotateCcw className="h-3 w-3 mr-1" />
Retry #{operation.retryCount}
</span>
)}
{operation.hasBeenRetried && (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-700 border border-gray-300">
Retried
</span>
)}
</div>
<div className="text-sm text-gray-600 flex items-center space-x-1">
<Computer className="h-4 w-4" />
@@ -382,7 +450,15 @@ const LiveOperations: React.FC = () => {
<div className="flex items-center space-x-2">
<button
onClick={() => setExpandedOperation(expandedOperation === operation.id ? null : operation.id)}
onClick={() => {
const newExpanded = new Set(expandedOperations);
if (newExpanded.has(operation.id)) {
newExpanded.delete(operation.id);
} else {
newExpanded.add(operation.id);
}
setExpandedOperations(newExpanded);
}}
className="flex items-center space-x-1 px-3 py-1 text-sm text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-md transition-colors"
>
<Eye className="h-4 w-4" />
@@ -390,7 +466,7 @@ const LiveOperations: React.FC = () => {
<ChevronDown
className={cn(
"h-4 w-4 transition-transform",
expandedOperation === operation.id && "rotate-180"
expandedOperations.has(operation.id) && "rotate-180"
)}
/>
</button>
@@ -403,7 +479,7 @@ const LiveOperations: React.FC = () => {
</div>
{/* Expanded details */}
{expandedOperation === operation.id && (
{expandedOperations.has(operation.id) && (
<div className="p-4 bg-gray-50 border-t border-gray-200">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div>
@@ -460,14 +536,21 @@ const LiveOperations: React.FC = () => {
{/* Retry button for failed/timed_out commands */}
{operation.commandStatus === 'failed' || operation.commandStatus === 'timed_out' ? (
<button
onClick={() => handleRetryCommand(operation.commandId)}
disabled={retryMutation.isPending}
className="w-full flex items-center justify-center space-x-2 px-3 py-2 bg-green-100 text-green-700 rounded-md hover:bg-green-200 text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<RotateCcw className="h-4 w-4" />
<span>{retryMutation.isPending ? 'Retrying...' : 'Retry Command'}</span>
</button>
operation.hasBeenRetried ? (
<div className="w-full flex items-center justify-center space-x-2 px-3 py-2 bg-purple-50 text-purple-700 rounded-md border border-purple-200 text-sm font-medium">
<RotateCcw className="h-4 w-4" />
<span>Already Retried</span>
</div>
) : (
<button
onClick={() => handleRetryCommand(operation.commandId)}
disabled={retryMutation.isPending}
className="w-full flex items-center justify-center space-x-2 px-3 py-2 bg-green-100 text-green-700 rounded-md hover:bg-green-200 text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<RotateCcw className="h-4 w-4" />
<span>{retryMutation.isPending ? 'Retrying...' : 'Retry Command'}</span>
</button>
)
) : null}
</div>
</div>
@@ -501,6 +584,114 @@ const LiveOperations: React.FC = () => {
</div>
)}
</div>
{/* Cleanup Confirmation Dialog */}
{showCleanupDialog && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 max-w-md w-full mx-4">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Archive Failed Operations</h3>
<div className="mb-4 p-3 bg-blue-50 border border-blue-200 rounded-md">
<p className="text-sm text-blue-800">
<strong>INFO:</strong> This will remove failed commands from the active operations view, but all history will be preserved in the database for audit trails and continuity.
</p>
</div>
<div className="mb-4 p-3 bg-yellow-50 border border-yellow-200 rounded-md">
<p className="text-sm text-yellow-800">
<strong>WARNING:</strong> This shouldn't be necessary if the retry logic is working properly - you might want to check what's causing commands to fail in the first place!
</p>
</div>
<div className="space-y-4 mb-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Clear operations older than
</label>
<div className="flex items-center space-x-2">
<input
type="number"
min="0"
value={cleanupOptions.olderThanDays}
onChange={(e) => setCleanupOptions(prev => ({
...prev,
olderThanDays: parseInt(e.target.value) || 0
}))}
className="w-20 px-3 py-2 border border-gray-300 rounded-md text-sm"
/>
<span className="text-sm text-gray-600">days</span>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Cleanup scope
</label>
<div className="space-y-2">
<label className="flex items-center">
<input
type="radio"
name="cleanupScope"
checked={!cleanupOptions.onlyRetried && !cleanupOptions.allFailed}
onChange={() => setCleanupOptions(prev => ({
...prev,
onlyRetried: false,
allFailed: false
}))}
className="mr-2"
/>
<span className="text-sm text-gray-700">All failed commands older than specified days</span>
</label>
<label className="flex items-center">
<input
type="radio"
name="cleanupScope"
checked={cleanupOptions.onlyRetried}
onChange={() => setCleanupOptions(prev => ({
...prev,
onlyRetried: true,
allFailed: false
}))}
className="mr-2"
/>
<span className="text-sm text-gray-700">Only failed commands that have been retried</span>
</label>
<label className="flex items-center">
<input
type="radio"
name="cleanupScope"
checked={cleanupOptions.allFailed}
onChange={() => setCleanupOptions(prev => ({
...prev,
onlyRetried: false,
allFailed: true
}))}
className="mr-2"
/>
<span className="text-sm text-red-700 font-medium">All failed commands (most aggressive)</span>
</label>
</div>
</div>
</div>
<div className="flex justify-end space-x-3">
<button
onClick={() => setShowCleanupDialog(false)}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-md hover:bg-gray-200 transition-colors"
>
Cancel
</button>
<button
onClick={handleClearFailedCommands}
disabled={clearFailedMutation.isPending}
className="px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-md hover:bg-red-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{clearFailedMutation.isPending ? 'Archiving...' : 'Archive Failed Commands'}
</button>
</div>
</div>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,601 @@
import React, { useState, useMemo } from 'react';
import {
Shield,
RefreshCw,
Save,
RotateCcw,
Activity,
AlertTriangle,
TrendingUp,
BarChart3,
Settings as SettingsIcon,
Eye,
Users,
Search,
Filter
} from 'lucide-react';
import {
useRateLimitConfigs,
useRateLimitStats,
useRateLimitUsage,
useRateLimitSummary,
useUpdateAllRateLimitConfigs,
useResetRateLimitConfigs,
useCleanupRateLimits
} from '../hooks/useRateLimits';
import { RateLimitConfig, RateLimitStats, RateLimitUsage } from '@/types';
const RateLimiting: React.FC = () => {
const [editingMode, setEditingMode] = useState(false);
const [editingConfigs, setEditingConfigs] = useState<RateLimitConfig[]>([]);
const [showAdvanced, setShowAdvanced] = useState(false);
// Search and filter state
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState<'all' | 'enabled' | 'disabled'>('all');
// Queries
const { data: configs, isLoading: isLoadingConfigs, refetch: refetchConfigs } = useRateLimitConfigs();
const { data: stats, isLoading: isLoadingStats } = useRateLimitStats();
const { data: usage, isLoading: isLoadingUsage } = useRateLimitUsage();
const { data: summary, isLoading: isLoadingSummary } = useRateLimitSummary();
// Mutations
const updateAllConfigs = useUpdateAllRateLimitConfigs();
const resetConfigs = useResetRateLimitConfigs();
const cleanupLimits = useCleanupRateLimits();
React.useEffect(() => {
if (configs) {
setEditingConfigs([...configs]);
}
}, [configs]);
// Filtered configurations for display
const filteredConfigs = useMemo(() => {
if (!configs) return [];
return configs.filter((config) => {
const matchesSearch = searchTerm === '' ||
config.endpoint.toLowerCase().includes(searchTerm.toLowerCase()) ||
config.method.toLowerCase().includes(searchTerm.toLowerCase());
const matchesStatus = statusFilter === 'all' ||
(statusFilter === 'enabled' && config.enabled) ||
(statusFilter === 'disabled' && !config.enabled);
return matchesSearch && matchesStatus;
});
}, [configs, searchTerm, statusFilter]);
const handleConfigChange = (index: number, field: keyof RateLimitConfig, value: any) => {
const updatedConfigs = [...editingConfigs];
updatedConfigs[index] = { ...updatedConfigs[index], [field]: value };
setEditingConfigs(updatedConfigs);
};
const handleSaveAllConfigs = () => {
updateAllConfigs.mutate(editingConfigs, {
onSuccess: () => {
setEditingMode(false);
refetchConfigs();
}
});
};
const handleResetConfigs = () => {
if (confirm('Reset all rate limit configurations to defaults? This will overwrite your custom settings.')) {
resetConfigs.mutate(undefined, {
onSuccess: () => {
setEditingMode(false);
refetchConfigs();
}
});
}
};
const handleCleanup = () => {
if (confirm('Clean up expired rate limit data?')) {
cleanupLimits.mutate(undefined, {
onSuccess: () => {
// Refetch stats and usage after cleanup
}
});
}
};
const getUsagePercentage = (endpoint: string) => {
const endpointUsage = usage?.find(u => u.endpoint === endpoint);
if (!endpointUsage) return 0;
return (endpointUsage.current / endpointUsage.limit) * 100;
};
const getUsageColor = (percentage: number) => {
if (percentage >= 90) return 'text-red-600 bg-red-100';
if (percentage >= 70) return 'text-yellow-600 bg-yellow-100';
return 'text-green-600 bg-green-100';
};
const formatEndpointName = (endpoint: string) => {
return endpoint.split('/').pop()?.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()) || endpoint;
};
return (
<div className="max-w-7xl mx-auto px-6 py-8">
{/* Header */}
<div className="mb-8">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900">Rate Limiting</h1>
<p className="mt-2 text-gray-600">Configure API rate limits and monitor system usage</p>
</div>
<div className="flex gap-3">
<button
onClick={() => setShowAdvanced(!showAdvanced)}
className="inline-flex items-center gap-2 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50"
>
<SettingsIcon className="w-4 h-4" />
{showAdvanced ? 'Simple View' : 'Advanced View'}
</button>
<button
onClick={handleCleanup}
disabled={cleanupLimits.isPending}
className="inline-flex items-center gap-2 px-4 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-700 disabled:opacity-50"
>
<RefreshCw className={`w-4 h-4 ${cleanupLimits.isPending ? 'animate-spin' : ''}`} />
Cleanup Data
</button>
<button
onClick={() => refetchConfigs()}
className="inline-flex items-center gap-2 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50"
>
<RefreshCw className="w-4 h-4" />
Refresh
</button>
</div>
</div>
</div>
{/* Summary Cards */}
{summary && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4 mb-8">
<div className="bg-white rounded-lg border border-gray-200 p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">Active Endpoints</p>
<p className="text-2xl font-bold text-gray-900">{summary.active_endpoints}</p>
<p className="text-xs text-gray-500">of {summary.total_endpoints} total</p>
</div>
<Shield className="w-8 h-8 text-blue-600" />
</div>
</div>
<div className="bg-white rounded-lg border border-gray-200 p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">Total Requests/Min</p>
<p className="text-2xl font-bold text-gray-900">{summary.total_requests_per_minute}</p>
</div>
<Activity className="w-8 h-8 text-green-600" />
</div>
</div>
<div className="bg-white rounded-lg border border-gray-200 p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">Avg Utilization</p>
<p className="text-2xl font-bold text-blue-600">
{Math.round(summary.average_utilization)}%
</p>
</div>
<BarChart3 className="w-8 h-8 text-purple-600" />
</div>
</div>
<div className="bg-white rounded-lg border border-gray-200 p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">Most Active</p>
<p className="text-lg font-bold text-gray-900 truncate">
{formatEndpointName(summary.most_active_endpoint)}
</p>
</div>
<TrendingUp className="w-8 h-8 text-orange-600" />
</div>
</div>
<div className="bg-white rounded-lg border border-gray-200 p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">Status</p>
<p className="text-lg font-bold text-green-600">Enabled</p>
</div>
<Shield className="w-8 h-8 text-green-600" />
</div>
</div>
</div>
)}
{/* Controls */}
{(editingMode || editingConfigs.length > 0) && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
<div className="flex items-center justify-between">
<p className="text-sm text-blue-800">
You have unsaved changes. Click "Save All Changes" to apply them.
</p>
<div className="flex gap-2">
<button
onClick={handleSaveAllConfigs}
disabled={updateAllConfigs.isPending}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
>
<Save className="w-4 h-4 inline mr-1" />
{updateAllConfigs.isPending ? 'Saving...' : 'Save All Changes'}
</button>
<button
onClick={() => {
setEditingConfigs([...configs!]);
setEditingMode(false);
}}
className="px-4 py-2 bg-gray-200 text-gray-800 rounded-lg hover:bg-gray-300"
>
Discard Changes
</button>
</div>
</div>
</div>
)}
{/* Rate Limit Configurations */}
<div className="bg-white rounded-lg border border-gray-200 mb-8">
<div className="px-6 py-4 border-b border-gray-200">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-gray-900">Rate Limit Configurations</h2>
<div className="flex gap-2">
{!editingMode && (
<button
onClick={() => setEditingMode(true)}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
<SettingsIcon className="w-4 h-4 inline mr-1" />
Edit All
</button>
)}
<button
onClick={handleResetConfigs}
disabled={resetConfigs.isPending}
className="px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 disabled:opacity-50"
>
<RotateCcw className="w-4 h-4 inline mr-1" />
Reset to Defaults
</button>
</div>
</div>
</div>
{/* Search and Filter Controls */}
<div className="bg-white border border-gray-200 rounded-lg p-4">
<div className="flex flex-col lg:flex-row gap-4">
<div className="flex-1">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
<input
type="text"
placeholder="Search by endpoint or method..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
<div className="flex gap-2">
<button
onClick={() => setStatusFilter('all')}
className={`px-4 py-2 rounded-lg transition-colors ${
statusFilter === 'all'
? 'bg-gray-100 text-gray-800 border border-gray-300'
: 'bg-white text-gray-600 border border-gray-300 hover:bg-gray-50'
}`}
>
All
</button>
<button
onClick={() => setStatusFilter('enabled')}
className={`px-4 py-2 rounded-lg transition-colors ${
statusFilter === 'enabled'
? 'bg-green-100 text-green-800 border border-green-300'
: 'bg-white text-gray-600 border border-gray-300 hover:bg-gray-50'
}`}
>
Enabled
</button>
<button
onClick={() => setStatusFilter('disabled')}
className={`px-4 py-2 rounded-lg transition-colors ${
statusFilter === 'disabled'
? 'bg-red-100 text-red-800 border border-red-300'
: 'bg-white text-gray-600 border border-gray-300 hover:bg-gray-50'
}`}
>
Disabled
</button>
</div>
</div>
{/* Filter results summary */}
{configs && (
<div className="mt-3 text-sm text-gray-600">
Showing {filteredConfigs.length} of {configs.length} configurations
</div>
)}
</div>
{filteredConfigs.length > 0 ? (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Endpoint
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Current Usage
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Requests/Min
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Window (min)
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Max Requests
</th>
{showAdvanced && (
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Burst Allowance
</th>
)}
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{filteredConfigs.map((config) => {
const originalIndex = editingConfigs.findIndex(c => c.endpoint === config.endpoint);
const usagePercentage = getUsagePercentage(config.endpoint);
const endpointUsage = usage?.find(u => u.endpoint === config.endpoint);
return (
<tr key={config.endpoint} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">
{formatEndpointName(config.endpoint)}
</div>
<div className="text-xs text-gray-500">
{config.endpoint}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
{endpointUsage && (
<div>
<div className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${getUsageColor(usagePercentage)}`}>
<div className={`w-2 h-2 rounded-full mr-1 ${
usagePercentage >= 90 ? 'bg-red-500' :
usagePercentage >= 70 ? 'bg-yellow-500' : 'bg-green-500'
}`}></div>
{endpointUsage.current} / {endpointUsage.limit}
({Math.round(usagePercentage)}%)
</div>
<div className="w-full bg-gray-200 rounded-full h-2 mt-2">
<div
className={`h-2 rounded-full transition-all ${
usagePercentage >= 90 ? 'bg-red-500' :
usagePercentage >= 70 ? 'bg-yellow-500' : 'bg-green-500'
}`}
style={{ width: `${Math.min(usagePercentage, 100)}%` }}
></div>
</div>
{endpointUsage && (
<div className="flex items-center gap-2 mt-1">
<Eye className="w-3 h-3 text-gray-400" />
<span className="text-xs text-gray-500">
Window: {formatDateTime(endpointUsage.window_start)} - {formatDateTime(endpointUsage.window_end)}
</span>
</div>
)}
</div>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
{editingMode ? (
<input
type="number"
min="1"
value={config.requests_per_minute}
onChange={(e) => handleConfigChange(originalIndex, 'requests_per_minute', parseInt(e.target.value))}
className="w-24 px-3 py-1 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
) : (
<span className="text-sm text-gray-900">{config.requests_per_minute}</span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
{editingMode ? (
<input
type="number"
min="1"
value={config.window_minutes}
onChange={(e) => handleConfigChange(originalIndex, 'window_minutes', parseInt(e.target.value))}
className="w-20 px-3 py-1 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
) : (
<span className="text-sm text-gray-900">{config.window_minutes}</span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
{editingMode ? (
<input
type="number"
min="1"
value={config.max_requests}
onChange={(e) => handleConfigChange(originalIndex, 'max_requests', parseInt(e.target.value))}
className="w-24 px-3 py-1 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
) : (
<span className="text-sm text-gray-900">{config.max_requests}</span>
)}
</td>
{showAdvanced && (
<td className="px-6 py-4 whitespace-nowrap">
{editingMode ? (
<input
type="number"
min="0"
value={config.burst_allowance}
onChange={(e) => handleConfigChange(originalIndex, 'burst_allowance', parseInt(e.target.value))}
className="w-24 px-3 py-1 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
) : (
<span className="text-sm text-gray-900">{config.burst_allowance}</span>
)}
</td>
)}
</tr>
);
})}
</tbody>
</table>
</div>
) : configs && configs.length > 0 ? (
<div className="p-12 text-center">
<Activity className="w-16 h-16 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">No configurations found</h3>
<p className="text-gray-600">
{searchTerm || statusFilter !== 'all'
? 'Try adjusting your search or filter criteria'
: 'No rate limit configurations available'}
</p>
</div>
) : (
<div className="p-8 text-center">
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
<p className="mt-2 text-gray-600">Loading rate limit configurations...</p>
</div>
)}
</div>
{/* Rate Limit Statistics */}
{stats && stats.length > 0 && (
<div className="bg-white rounded-lg border border-gray-200">
<div className="px-6 py-4 border-b border-gray-200">
<h2 className="text-lg font-semibold text-gray-900">Rate Limit Statistics</h2>
</div>
<div className="p-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{stats.map((stat) => (
<div key={stat.endpoint} className="border border-gray-200 rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<h4 className="font-medium text-gray-900">
{formatEndpointName(stat.endpoint)}
</h4>
<Activity className="w-4 h-4 text-yellow-500" />
</div>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-600">Current Requests:</span>
<span className="font-medium">{stat.current_requests}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Limit:</span>
<span className="font-medium">{stat.limit}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Blocked:</span>
<span className="font-medium text-red-600">{stat.blocked_requests}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Window:</span>
<span className="font-medium text-xs">
{new Date(stat.window_start).toLocaleTimeString()} - {new Date(stat.window_end).toLocaleTimeString()}
</span>
</div>
</div>
{stat.top_clients && stat.top_clients.length > 0 && (
<div className="mt-4 pt-3 border-t border-gray-200">
<p className="text-xs text-gray-600 mb-2">Top Clients:</p>
<div className="space-y-1">
{stat.top_clients.slice(0, 3).map((client, index) => (
<div key={index} className="flex justify-between text-xs">
<span className="text-gray-500 truncate mr-2">{client.identifier}</span>
<span className="font-medium">{client.request_count}</span>
</div>
))}
</div>
</div>
)}
</div>
))}
</div>
</div>
</div>
)}
{/* Usage Monitoring */}
{usage && usage.length > 0 && (
<div className="bg-white rounded-lg border border-gray-200">
<div className="px-6 py-4 border-b border-gray-200">
<h2 className="text-lg font-semibold text-gray-900">Usage Monitoring</h2>
</div>
<div className="p-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{usage.map((endpointUsage) => (
<div key={endpointUsage.endpoint} className="border border-gray-200 rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<h4 className="font-medium text-gray-900">
{formatEndpointName(endpointUsage.endpoint)}
</h4>
<BarChart3 className="w-4 h-4 text-blue-500" />
</div>
<div className="space-y-3">
<div>
<div className="flex justify-between text-sm mb-1">
<span className="text-gray-600">Usage</span>
<span className="font-medium">
{endpointUsage.current} / {endpointUsage.limit}
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-3">
<div
className={`h-3 rounded-full transition-all ${
(endpointUsage.current / endpointUsage.limit) * 100 >= 90 ? 'bg-red-500' :
(endpointUsage.current / endpointUsage.limit) * 100 >= 70 ? 'bg-yellow-500' : 'bg-green-500'
}`}
style={{ width: `${Math.min((endpointUsage.current / endpointUsage.limit) * 100, 100)}%` }}
></div>
</div>
</div>
<div className="text-xs text-gray-600 space-y-1">
<div>Remaining: {endpointUsage.remaining} requests</div>
<div>Reset: {formatDateTime(endpointUsage.reset_time)}</div>
<div>Window: {endpointUsage.window_minutes} minutes</div>
</div>
</div>
</div>
))}
</div>
</div>
</div>
)}
</div>
);
};
export default RateLimiting;

View File

@@ -1,17 +1,35 @@
import React from 'react';
import { Clock } from 'lucide-react';
import { Link } from 'react-router-dom';
import {
Clock,
User,
Shield,
Server,
Settings as SettingsIcon,
ArrowRight,
AlertTriangle,
CheckCircle,
Activity
} from 'lucide-react';
import { useSettingsStore } from '@/lib/store';
import { useTimezones, useTimezone, useUpdateTimezone } from '../hooks/useSettings';
import { useRegistrationTokenStats } from '../hooks/useRegistrationTokens';
import { useRateLimitSummary } from '../hooks/useRateLimits';
import { formatDateTime } from '@/lib/utils';
const Settings: React.FC = () => {
const { autoRefresh, refreshInterval, setAutoRefresh, setRefreshInterval } = useSettingsStore();
// Timezone settings
const { data: timezones, isLoading: isLoadingTimezones } = useTimezones();
const { data: currentTimezone, isLoading: isLoadingCurrentTimezone } = useTimezone();
const updateTimezone = useUpdateTimezone();
const [selectedTimezone, setSelectedTimezone] = React.useState('');
// Statistics for overview
const { data: tokenStats } = useRegistrationTokenStats();
const { data: rateLimitSummary } = useRateLimitSummary();
React.useEffect(() => {
if (currentTimezone?.timezone) {
setSelectedTimezone(currentTimezone.timezone);
@@ -21,168 +39,302 @@ const Settings: React.FC = () => {
const handleTimezoneChange = async (e: React.ChangeEvent<HTMLSelectElement>) => {
const newTimezone = e.target.value;
setSelectedTimezone(newTimezone);
try {
await updateTimezone.mutateAsync(newTimezone);
} catch (error) {
console.error('Failed to update timezone:', error);
// Revert on error
if (currentTimezone?.timezone) {
setSelectedTimezone(currentTimezone.timezone);
}
}
};
const overviewCards = [
{
title: 'Registration Tokens',
description: 'Create and manage agent registration tokens',
icon: Shield,
href: '/settings/tokens',
stats: tokenStats ? {
total: tokenStats.total_tokens,
active: tokenStats.active_tokens,
used: tokenStats.used_tokens,
color: 'blue'
} : null,
status: 'implemented'
},
{
title: 'Rate Limiting',
description: 'Configure API rate limits and monitor usage',
icon: Activity,
href: '/settings/rate-limiting',
stats: rateLimitSummary ? {
active: rateLimitSummary.active_endpoints,
total: rateLimitSummary.total_endpoints,
utilization: Math.round(rateLimitSummary.average_utilization),
color: 'green'
} : null,
status: 'implemented'
},
{
title: 'System Configuration',
description: 'Server settings and performance tuning',
icon: Server,
href: '/settings/system',
stats: null,
status: 'not-implemented'
},
{
title: 'Agent Management',
description: 'Agent defaults and cleanup policies',
icon: SettingsIcon,
href: '/settings/agents',
stats: null,
status: 'not-implemented'
}
];
return (
<div className="px-4 sm:px-6 lg:px-8 space-y-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">Settings</h1>
<p className="mt-1 text-sm text-gray-600">Configure your RedFlag dashboard preferences</p>
<div className="max-w-6xl mx-auto px-6 py-8">
{/* Header */}
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900">Settings</h1>
<p className="mt-2 text-gray-600">Configure your RedFlag deployment and system preferences</p>
</div>
{/* Timezone Settings */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<div className="flex items-center gap-3 mb-6">
<div className="p-2 bg-gray-100 rounded-lg">
<Clock className="w-5 h-5 text-gray-600" />
{/* Quick Actions */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
<Link
to="/settings/tokens"
className="block p-6 bg-white border border-gray-200 rounded-lg hover:border-blue-300 hover:shadow-sm transition-all"
>
<div className="flex items-center justify-between mb-4">
<Shield className="w-8 h-8 text-blue-600" />
<ArrowRight className="w-5 h-5 text-gray-400" />
</div>
<div>
<h2 className="text-xl font-semibold text-gray-900">Timezone Settings</h2>
<p className="text-gray-600">Configure the timezone used for displaying timestamps</p>
<h3 className="font-semibold text-gray-900">Registration Tokens</h3>
<p className="text-sm text-gray-600 mt-1">Manage agent registration tokens</p>
</Link>
<Link
to="/settings/rate-limiting"
className="block p-6 bg-white border border-gray-200 rounded-lg hover:border-green-300 hover:shadow-sm transition-all"
>
<div className="flex items-center justify-between mb-4">
<Activity className="w-8 h-8 text-green-600" />
<ArrowRight className="w-5 h-5 text-gray-400" />
</div>
<h3 className="font-semibold text-gray-900">Rate Limiting</h3>
<p className="text-sm text-gray-600 mt-1">Configure API rate limits</p>
</Link>
<div className="p-6 bg-gray-50 border border-gray-200 rounded-lg opacity-60">
<div className="flex items-center justify-between mb-4">
<Server className="w-8 h-8 text-gray-400" />
<ArrowRight className="w-5 h-5 text-gray-300" />
</div>
<h3 className="font-semibold text-gray-500">System Configuration</h3>
<p className="text-sm text-gray-400 mt-1">Coming soon</p>
</div>
<div className="space-y-4">
<div className="p-6 bg-gray-50 border border-gray-200 rounded-lg opacity-60">
<div className="flex items-center justify-between mb-4">
<SettingsIcon className="w-8 h-8 text-gray-400" />
<ArrowRight className="w-5 h-5 text-gray-300" />
</div>
<h3 className="font-semibold text-gray-500">Agent Management</h3>
<p className="text-sm text-gray-400 mt-1">Coming soon</p>
</div>
</div>
{/* Overview Statistics */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
{/* Token Overview */}
<div className="bg-white border border-gray-200 rounded-lg p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-gray-900">Token Overview</h2>
<Link
to="/settings/tokens"
className="text-blue-600 hover:text-blue-800 text-sm font-medium"
>
Manage all
</Link>
</div>
{tokenStats ? (
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-2xl font-bold text-gray-900">{tokenStats.total_tokens}</p>
<p className="text-sm text-gray-600">Total Tokens</p>
</div>
<div>
<p className="text-2xl font-bold text-green-600">{tokenStats.active_tokens}</p>
<p className="text-sm text-gray-600">Active</p>
</div>
<div>
<p className="text-2xl font-bold text-blue-600">{tokenStats.used_tokens}</p>
<p className="text-sm text-gray-600">Used</p>
</div>
<div>
<p className="text-2xl font-bold text-gray-600">{tokenStats.expired_tokens}</p>
<p className="text-sm text-gray-600">Expired</p>
</div>
</div>
) : (
<div className="text-center py-4">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600 mx-auto"></div>
<p className="text-sm text-gray-500 mt-2">Loading token statistics...</p>
</div>
)}
</div>
{/* Rate Limiting Overview */}
<div className="bg-white border border-gray-200 rounded-lg p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-gray-900">Rate Limiting Status</h2>
<Link
to="/settings/rate-limiting"
className="text-blue-600 hover:text-blue-800 text-sm font-medium"
>
Configure
</Link>
</div>
{rateLimitSummary ? (
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-2xl font-bold text-gray-900">{rateLimitSummary.active_endpoints}</p>
<p className="text-sm text-gray-600">Active Endpoints</p>
</div>
<div>
<p className="text-2xl font-bold text-green-600">
{rateLimitSummary.total_requests_per_minute}
</p>
<p className="text-sm text-gray-600">Requests/Min</p>
</div>
<div>
<p className="text-2xl font-bold text-blue-600">
{Math.round(rateLimitSummary.average_utilization)}%
</p>
<p className="text-sm text-gray-600">Avg Utilization</p>
</div>
<div>
<div className="flex items-center gap-2">
<CheckCircle className="w-5 h-5 text-green-600" />
<p className="text-lg font-bold text-green-600">Enabled</p>
</div>
<p className="text-sm text-gray-600">System Protected</p>
</div>
</div>
) : (
<div className="text-center py-4">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600 mx-auto"></div>
<p className="text-sm text-gray-500 mt-2">Loading rate limit status...</p>
</div>
)}
</div>
</div>
{/* Account Settings */}
<div className="bg-white border border-gray-200 rounded-lg p-6 mb-8">
<h2 className="text-xl font-semibold text-gray-900 mb-6 pb-2 border-b border-gray-200">Account Settings</h2>
<div className="space-y-8">
{/* Display Preferences */}
<div>
<label htmlFor="timezone" className="block text-sm font-medium text-gray-700 mb-2">
Display Timezone
</label>
<div className="relative">
<h3 className="text-lg font-medium text-gray-900 mb-4">Display Preferences</h3>
<div className="mb-6">
<label className="block text-sm font-medium text-gray-700 mb-2">
Timezone
<span className="ml-1 text-xs text-gray-500">(Note: Changes apply to current session only)</span>
</label>
<select
id="timezone"
value={selectedTimezone}
onChange={handleTimezoneChange}
disabled={isLoadingTimezones || isLoadingCurrentTimezone || updateTimezone.isPending}
className="w-full px-4 py-2 bg-white border border-gray-300 rounded-lg text-gray-900 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent appearance-none cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
disabled={isLoadingTimezones || updateTimezone.isPending}
className="w-full md:w-64 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
{isLoadingTimezones ? (
<option>Loading timezones...</option>
<option>Loading...</option>
) : (
timezones?.map((tz) => (
<option key={tz.value} value={tz.value}>
{tz.label}
</option>
<option key={tz.value} value={tz.value}>{tz.label}</option>
))
)}
</select>
{updateTimezone.isPending && (
<p className="mt-2 text-sm text-blue-600">Updating timezone...</p>
)}
{updateTimezone.isSuccess && (
<p className="mt-2 text-sm text-green-600">Timezone updated successfully</p>
)}
{updateTimezone.isError && (
<p className="mt-2 text-sm text-red-600">Failed to update timezone</p>
)}
</div>
</div>
{/* Custom dropdown arrow */}
<div className="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
<svg className="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
{/* Dashboard Behavior */}
<div>
<h3 className="text-lg font-medium text-gray-900 mb-4">Dashboard Behavior</h3>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<div className="font-medium text-gray-900">Auto-refresh</div>
<div className="text-sm text-gray-600">Automatically refresh dashboard data</div>
</div>
<button
onClick={() => setAutoRefresh(!autoRefresh)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors ${
autoRefresh ? 'bg-blue-600' : 'bg-gray-200'
}`}
>
<span className={`translate-x-${autoRefresh ? '5' : '0'} inline-block h-5 w-5 transform rounded-full bg-white transition`} />
</button>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Refresh Interval</label>
<select
value={refreshInterval}
onChange={(e) => setRefreshInterval(Number(e.target.value))}
disabled={!autoRefresh}
className="w-full md:w-64 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
>
<option value={10000}>10 seconds</option>
<option value={30000}>30 seconds</option>
<option value={60000}>1 minute</option>
<option value={300000}>5 minutes</option>
<option value={600000}>10 minutes</option>
</select>
</div>
</div>
{updateTimezone.isPending && (
<p className="mt-2 text-sm text-yellow-600">Updating timezone...</p>
)}
{updateTimezone.isSuccess && (
<p className="mt-2 text-sm text-green-600">Timezone updated successfully!</p>
)}
{updateTimezone.isError && (
<p className="mt-2 text-sm text-red-600">
Failed to update timezone. Please try again.
</p>
)}
</div>
<div className="pt-4 border-t border-gray-200">
<p className="text-sm text-gray-600">
This setting affects how timestamps are displayed throughout the dashboard, including agent
last check-in times, scan times, and update timestamps.
</p>
</div>
</div>
</div>
{/* Dashboard Settings */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<div className="flex items-center gap-3 mb-6">
<div className="p-2 bg-gray-100 rounded-lg">
<Clock className="w-5 h-5 text-gray-600" />
{/* Implementation Status */}
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-6">
<h2 className="text-lg font-semibold text-yellow-800 mb-4">Implementation Status</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<h3 className="font-medium text-yellow-800 mb-3"> Implemented Features</h3>
<ul className="space-y-1 text-sm text-yellow-700">
<li> Registration token management (full CRUD)</li>
<li> API rate limiting configuration</li>
<li> Real-time usage monitoring</li>
<li> User preferences (timezone, dashboard)</li>
</ul>
</div>
<div>
<h2 className="text-xl font-semibold text-gray-900">Dashboard Settings</h2>
<p className="text-gray-600">Configure how the dashboard behaves and displays information</p>
<h3 className="font-medium text-yellow-800 mb-3">🚧 Planned Features</h3>
<ul className="space-y-1 text-sm text-yellow-700">
<li> System configuration management</li>
<li> Agent management settings</li>
<li> Integration with third-party services</li>
<li> Persistent settings storage</li>
</ul>
</div>
</div>
<div className="space-y-6">
{/* Auto Refresh */}
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-medium text-gray-900">Auto Refresh</h3>
<p className="text-sm text-gray-600">
Automatically refresh dashboard data at regular intervals
</p>
</div>
<button
onClick={() => setAutoRefresh(!autoRefresh)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 ${
autoRefresh ? 'bg-primary-600' : 'bg-gray-200'
}`}
>
<span
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
autoRefresh ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</button>
</div>
{/* Refresh Interval */}
<div>
<h3 className="text-sm font-medium text-gray-900 mb-3">Refresh Interval</h3>
<select
value={refreshInterval}
onChange={(e) => setRefreshInterval(Number(e.target.value))}
disabled={!autoRefresh}
className="w-full px-4 py-2 bg-white border border-gray-300 rounded-lg text-gray-900 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed"
>
<option value={10000}>10 seconds</option>
<option value={30000}>30 seconds</option>
<option value={60000}>1 minute</option>
<option value={300000}>5 minutes</option>
<option value={600000}>10 minutes</option>
</select>
<p className="mt-1 text-xs text-gray-500">
How often to refresh dashboard data when auto-refresh is enabled
</p>
</div>
</div>
</div>
{/* Future Settings Sections */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6 opacity-60">
<div className="flex items-center gap-3 mb-4">
<div className="p-2 bg-gray-100 rounded-lg">
<Clock className="w-5 h-5 text-gray-400" />
</div>
<div>
<h2 className="text-xl font-semibold text-gray-400">Additional Settings</h2>
<p className="text-gray-500">More configuration options coming soon</p>
</div>
</div>
<div className="space-y-3 text-sm text-gray-500">
<div> Notification preferences</div>
<div> Agent monitoring settings</div>
<div> Data retention policies</div>
<div> API access tokens</div>
</div>
<p className="mt-4 text-xs text-yellow-600">
This settings page reflects the current state of the RedFlag backend API.
</p>
</div>
</div>
);

View File

@@ -0,0 +1,524 @@
import React, { useState } from 'react';
import {
Shield,
Plus,
Search,
Filter,
RefreshCw,
Download,
Trash2,
Copy,
Eye,
EyeOff,
AlertTriangle,
CheckCircle,
Clock,
Users
} from 'lucide-react';
import {
useRegistrationTokens,
useCreateRegistrationToken,
useRevokeRegistrationToken,
useRegistrationTokenStats,
useCleanupRegistrationTokens
} from '../hooks/useRegistrationTokens';
import { RegistrationToken, CreateRegistrationTokenRequest } from '@/types';
import { formatDateTime } from '@/lib/utils';
const TokenManagement: React.FC = () => {
// Filters and search
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'used' | 'expired' | 'revoked'>('all');
const [showCreateForm, setShowCreateForm] = useState(false);
const [showToken, setShowToken] = useState<Record<string, boolean>>({});
// Pagination
const [currentPage, setCurrentPage] = useState(1);
const pageSize = 50;
// Token management
const { data: tokensData, isLoading, refetch } = useRegistrationTokens({
page: currentPage,
page_size: pageSize,
is_active: statusFilter === 'all' ? undefined : statusFilter === 'active',
label: searchTerm || undefined,
});
const { data: stats, isLoading: isLoadingStats } = useRegistrationTokenStats();
const createToken = useCreateRegistrationToken();
const revokeToken = useRevokeRegistrationToken();
const cleanupTokens = useCleanupRegistrationTokens();
// Reset page when filters change
React.useEffect(() => {
setCurrentPage(1);
}, [searchTerm, statusFilter]);
// Form state
const [formData, setFormData] = useState<CreateRegistrationTokenRequest>({
label: '',
max_seats: 10,
expires_at: '',
});
const handleCreateToken = (e: React.FormEvent) => {
e.preventDefault();
createToken.mutate(formData, {
onSuccess: () => {
setFormData({ label: '', max_seats: 10, expires_at: '' });
setShowCreateForm(false);
refetch();
},
});
};
const handleRevokeToken = (tokenId: string, tokenLabel: string) => {
if (confirm(`Revoke token "${tokenLabel}"? Agents using it will need to re-register.`)) {
revokeToken.mutate(tokenId, { onSuccess: () => refetch() });
}
};
const handleCleanup = () => {
if (confirm('Clean up all expired tokens? This cannot be undone.')) {
cleanupTokens.mutate(undefined, { onSuccess: () => refetch() });
}
};
const copyToClipboard = async (text: string) => {
await navigator.clipboard.writeText(text);
// Show success feedback
};
const copyInstallCommand = async (token: string) => {
const command = `curl -sSL https://get.redflag.dev | bash -s -- ${token}`;
await navigator.clipboard.writeText(command);
};
const generateInstallCommand = (token: string) => {
return `curl -sSL https://get.redflag.dev | bash -s -- ${token}`;
};
const getStatusColor = (token: RegistrationToken) => {
if (!token.is_active) return 'text-gray-500';
if (token.expires_at && new Date(token.expires_at) < new Date()) return 'text-red-600';
if (token.max_seats && token.current_seats >= token.max_seats) return 'text-yellow-600';
return 'text-green-600';
};
const getStatusText = (token: RegistrationToken) => {
if (!token.is_active) return 'Revoked';
if (token.expires_at && new Date(token.expires_at) < new Date()) return 'Expired';
if (token.max_seats && token.current_seats >= token.max_seats) return 'Full';
return 'Active';
};
const filteredTokens = tokensData?.tokens || [];
return (
<div className="max-w-7xl mx-auto px-6 py-8">
{/* Header */}
<div className="mb-8">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900">Registration Tokens</h1>
<p className="mt-2 text-gray-600">Manage agent registration tokens and monitor their usage</p>
</div>
<div className="flex gap-3">
<button
onClick={handleCleanup}
disabled={cleanupTokens.isPending}
className="inline-flex items-center gap-2 px-4 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-700 disabled:opacity-50"
>
<RefreshCw className={`w-4 h-4 ${cleanupTokens.isPending ? 'animate-spin' : ''}`} />
Cleanup Expired
</button>
<button
onClick={() => refetch()}
className="inline-flex items-center gap-2 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50"
>
<RefreshCw className="w-4 h-4" />
Refresh
</button>
<button
onClick={() => setShowCreateForm(!showCreateForm)}
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
<Plus className="w-4 h-4" />
Create Token
</button>
</div>
</div>
</div>
{/* Statistics Cards */}
{stats && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4 mb-8">
<div className="bg-white rounded-lg border border-gray-200 p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">Total Tokens</p>
<p className="text-2xl font-bold text-gray-900">{stats.total_tokens}</p>
</div>
<Shield className="w-8 h-8 text-blue-600" />
</div>
</div>
<div className="bg-white rounded-lg border border-gray-200 p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">Active</p>
<p className="text-2xl font-bold text-green-600">{stats.active_tokens}</p>
</div>
<CheckCircle className="w-8 h-8 text-green-600" />
</div>
</div>
<div className="bg-white rounded-lg border border-gray-200 p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">Used</p>
<p className="text-2xl font-bold text-blue-600">{stats.used_tokens}</p>
</div>
<Users className="w-8 h-8 text-blue-600" />
</div>
</div>
<div className="bg-white rounded-lg border border-gray-200 p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">Expired</p>
<p className="text-2xl font-bold text-gray-600">{stats.expired_tokens}</p>
</div>
<Clock className="w-8 h-8 text-gray-600" />
</div>
</div>
<div className="bg-white rounded-lg border border-gray-200 p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">Seats Used</p>
<p className="text-2xl font-bold text-purple-600">
{stats.total_seats_used}/{stats.total_seats_available || '∞'}
</p>
</div>
<Users className="w-8 h-8 text-purple-600" />
</div>
</div>
</div>
)}
{/* Create Token Form */}
{showCreateForm && (
<div className="bg-white rounded-lg border border-gray-200 p-6 mb-8">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Create New Registration Token</h3>
<form onSubmit={handleCreateToken} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Label *</label>
<input
type="text"
required
value={formData.label}
onChange={(e) => setFormData({ ...formData, label: e.target.value })}
placeholder="e.g., Production Team"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Max Seats</label>
<input
type="number"
min="1"
value={formData.max_seats}
onChange={(e) => setFormData({ ...formData, max_seats: e.target.value ? parseInt(e.target.value) : undefined })}
placeholder="Leave empty for unlimited"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Expiration Date</label>
<input
type="datetime-local"
value={formData.expires_at}
onChange={(e) => setFormData({ ...formData, expires_at: e.target.value })}
min={new Date().toISOString().slice(0, 16)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
<div className="flex gap-3">
<button
type="submit"
disabled={createToken.isPending}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
>
{createToken.isPending ? 'Creating...' : 'Create Token'}
</button>
<button
type="button"
onClick={() => setShowCreateForm(false)}
className="px-4 py-2 bg-gray-200 text-gray-800 rounded-lg hover:bg-gray-300"
>
Cancel
</button>
</div>
</form>
</div>
)}
{/* Filters and Search */}
<div className="bg-white rounded-lg border border-gray-200 p-6 mb-8">
<div className="flex flex-col lg:flex-row gap-4">
<div className="flex-1">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
<input
type="text"
placeholder="Search by label..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
<div className="flex gap-2">
<button
onClick={() => setStatusFilter('all')}
className={`px-4 py-2 rounded-lg transition-colors ${
statusFilter === 'all'
? 'bg-gray-100 text-gray-800 border border-gray-300'
: 'bg-white text-gray-600 border border-gray-300 hover:bg-gray-50'
}`}
>
All
</button>
<button
onClick={() => setStatusFilter('active')}
className={`px-4 py-2 rounded-lg transition-colors ${
statusFilter === 'active'
? 'bg-green-100 text-green-800 border border-green-300'
: 'bg-white text-gray-600 border border-gray-300 hover:bg-gray-50'
}`}
>
Active
</button>
<button
onClick={() => setStatusFilter('used')}
className={`px-4 py-2 rounded-lg transition-colors ${
statusFilter === 'used'
? 'bg-blue-100 text-blue-800 border border-blue-300'
: 'bg-white text-gray-600 border border-gray-300 hover:bg-gray-50'
}`}
>
Used
</button>
<button
onClick={() => setStatusFilter('expired')}
className={`px-4 py-2 rounded-lg transition-colors ${
statusFilter === 'expired'
? 'bg-red-100 text-red-800 border border-red-300'
: 'bg-white text-gray-600 border border-gray-300 hover:bg-gray-50'
}`}
>
Expired
</button>
</div>
</div>
</div>
{/* Tokens List */}
<div className="bg-white rounded-lg border border-gray-200">
{isLoading ? (
<div className="p-12 text-center">
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
<p className="mt-2 text-gray-600">Loading tokens...</p>
</div>
) : filteredTokens.length === 0 ? (
<div className="p-12 text-center">
<Shield className="w-16 h-16 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">No tokens found</h3>
<p className="text-gray-600">
{searchTerm || statusFilter !== 'all'
? 'Try adjusting your search or filter criteria'
: 'Create your first token to begin registering agents'}
</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Token
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Label
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Seats
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Created
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Expires
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Last Used
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{filteredTokens.map((token) => (
<tr key={token.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center gap-3">
<div className="font-mono text-sm bg-gray-100 px-3 py-2 rounded">
{showToken[token.id] ? token.token : '•••••••••••••••••'}
</div>
<button
onClick={() => setShowToken({ ...showToken, [token.id]: !showToken[token.id] })}
className="text-gray-400 hover:text-gray-600"
>
{showToken[token.id] ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">{token.label}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className={`flex items-center gap-1 px-3 py-1 rounded-full text-xs font-medium ${getStatusColor(token)}`}>
{getStatusText(token)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">
{token.current_seats}
{token.max_seats && ` / ${token.max_seats}`}
</div>
{token.max_seats && (
<div className="w-full bg-gray-200 rounded-full h-2 mt-1">
<div
className="bg-blue-600 h-2 rounded-full"
style={{ width: `${Math.min((token.current_seats / token.max_seats) * 100, 100)}%` }}
></div>
</div>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{formatDateTime(token.created_at)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{formatDateTime(token.expires_at) || 'Never'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{formatDateTime(token.last_used_at) || 'Never'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<div className="flex items-center gap-2">
<button
onClick={() => copyToClipboard(token.token)}
className="text-blue-600 hover:text-blue-800"
title="Copy token"
>
<Copy className="w-4 h-4" />
</button>
<button
onClick={() => copyInstallCommand(token.token)}
className="text-blue-600 hover:text-blue-800"
title="Copy install command"
>
<Download className="w-4 h-4" />
</button>
{token.is_active && (
<button
onClick={() => handleRevokeToken(token.id, token.label)}
disabled={revokeToken.isPending}
className="text-red-600 hover:text-red-800 disabled:opacity-50"
title="Revoke token"
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{/* Pagination */}
{tokensData && tokensData.total > pageSize && (
<div className="mt-6 flex items-center justify-between">
<div className="text-sm text-gray-700">
Showing {((currentPage - 1) * pageSize) + 1}-{Math.min(currentPage * pageSize, tokensData.total)} of {tokensData.total} tokens
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
disabled={currentPage === 1}
className="px-3 py-1 border border-gray-300 rounded text-sm disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
>
Previous
</button>
<div className="flex items-center gap-1">
{Array.from({ length: Math.min(5, Math.ceil(tokensData.total / pageSize)) }, (_, i) => {
const totalPages = Math.ceil(tokensData.total / pageSize);
let pageNum;
if (totalPages <= 5) {
pageNum = i + 1;
} else if (currentPage <= 3) {
pageNum = i + 1;
} else if (currentPage >= totalPages - 2) {
pageNum = totalPages - 4 + i;
} else {
pageNum = currentPage - 2 + i;
}
return (
<button
key={pageNum}
onClick={() => setCurrentPage(pageNum)}
className={`px-3 py-1 border rounded text-sm ${
currentPage === pageNum
? 'bg-blue-600 text-white border-blue-600'
: 'border-gray-300 hover:bg-gray-50'
}`}
>
{pageNum}
</button>
);
})}
</div>
<button
onClick={() => setCurrentPage(Math.min(Math.ceil(tokensData.total / pageSize), currentPage + 1))}
disabled={currentPage >= Math.ceil(tokensData.total / pageSize)}
className="px-3 py-1 border border-gray-300 rounded text-sm disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
>
Next
</button>
</div>
</div>
)}
</div>
);
};
export default TokenManagement;

View File

@@ -419,14 +419,14 @@ const Updates: React.FC = () => {
<div>
<p className="text-sm text-gray-600">Discovered</p>
<p className="text-sm font-medium text-gray-900">
{formatRelativeTime(selectedUpdate.created_at)}
{formatRelativeTime(selectedUpdate.last_discovered_at)}
</p>
</div>
<div>
<p className="text-sm text-gray-600">Last Updated</p>
<p className="text-sm font-medium text-gray-900">
{formatRelativeTime(selectedUpdate.updated_at)}
{formatRelativeTime(selectedUpdate.last_updated_at)}
</p>
</div>
</div>
@@ -521,7 +521,7 @@ const Updates: React.FC = () => {
onClick={() => {
// This would need a way to find the associated command ID
// For now, we'll show a message indicating this needs to be implemented
toast.info('Retry functionality will be available in the command history view');
toast('Retry functionality will be available in the command history view', { icon: '' });
}}
className="w-full btn btn-warning"
>
@@ -1295,11 +1295,11 @@ const Updates: React.FC = () => {
</th>
<th className="table-header">
<button
onClick={() => handleSort('created_at')}
onClick={() => handleSort('last_discovered_at')}
className="flex items-center hover:text-primary-600 font-medium"
>
Discovered
{renderSortIcon('created_at')}
{renderSortIcon('last_discovered_at')}
</button>
</th>
<th className="table-header">Actions</th>
@@ -1376,7 +1376,7 @@ const Updates: React.FC = () => {
</td>
<td className="table-cell">
<div className="text-sm text-gray-900">
{formatRelativeTime(update.created_at)}
{formatRelativeTime(update.last_discovered_at)}
</div>
</td>
<td className="table-cell">

View File

@@ -47,8 +47,9 @@ export interface UpdatePackage {
available_version: string;
severity: 'low' | 'medium' | 'high' | 'critical';
status: 'pending' | 'approved' | 'scheduled' | 'installing' | 'installed' | 'failed' | 'checking_dependencies' | 'pending_dependencies';
created_at: string;
updated_at: string;
// Timestamp fields - matching backend API response
last_discovered_at: string; // When package was first discovered
last_updated_at: string; // When package status was last updated
approved_at: string | null;
scheduled_at: string | null;
installed_at: string | null;
@@ -285,4 +286,76 @@ export interface ApiError {
message: string;
code?: string;
details?: any;
}
// Registration Token types
export interface RegistrationToken {
id: string;
token: string;
label: string;
expires_at: string | null;
max_seats: number | null;
current_seats: number;
is_active: boolean;
created_at: string;
updated_at: string;
last_used_at: string | null;
metadata: Record<string, any>;
}
export interface CreateRegistrationTokenRequest {
label: string;
expires_at?: string;
max_seats?: number;
metadata?: Record<string, any>;
}
export interface RegistrationTokenStats {
total_tokens: number;
active_tokens: number;
used_tokens: number;
expired_tokens: number;
revoked_tokens: number;
total_seats_used: number;
total_seats_available: number;
}
// Rate Limiting types
export interface RateLimitConfig {
endpoint: string;
requests_per_minute: number;
window_minutes: number;
max_requests: number;
burst_allowance: number;
metadata: Record<string, any>;
}
export interface RateLimitStats {
endpoint: string;
current_requests: number;
limit: number;
window_start: string;
window_end: string;
blocked_requests: number;
top_clients: Array<{
identifier: string;
request_count: number;
}>;
}
export interface RateLimitUsage {
endpoint: string;
limit: number;
current: number;
remaining: number;
reset_time: string;
window_minutes: number;
}
export interface RateLimitSummary {
total_endpoints: number;
active_endpoints: number;
total_requests_per_minute: number;
most_active_endpoint: string;
average_utilization: number;
}