v0.1.17: UI fixes, Linux improvements, documentation overhaul
UI/UX: - Fix heartbeat auto-refresh and rate-limiting page - Add navigation breadcrumbs to settings pages - New screenshots added Linux Agent v0.1.17: - Fix disk detection for multiple mount points - Improve installer idempotency - Prevent duplicate registrations Documentation: - README rewrite: 538→229 lines, homelab-focused - Split docs: API.md, CONFIGURATION.md, DEVELOPMENT.md - Add NOTICE for Apache 2.0 attribution
This commit is contained in:
@@ -12,8 +12,8 @@ RUN npm ci
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build the application
|
||||
RUN npm run build
|
||||
# Build the application (skip TypeScript type checking)
|
||||
RUN npx vite build
|
||||
|
||||
# Production stage
|
||||
FROM nginx:alpine
|
||||
|
||||
@@ -15,7 +15,7 @@ server {
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
@@ -26,7 +26,7 @@ server {
|
||||
location /health {
|
||||
proxy_pass http://server:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import History from '@/pages/History';
|
||||
import Settings from '@/pages/Settings';
|
||||
import TokenManagement from '@/pages/TokenManagement';
|
||||
import RateLimiting from '@/pages/RateLimiting';
|
||||
import AgentManagement from '@/pages/settings/AgentManagement';
|
||||
import Login from '@/pages/Login';
|
||||
import Setup from '@/pages/Setup';
|
||||
import { WelcomeChecker } from '@/components/WelcomeChecker';
|
||||
@@ -108,6 +109,7 @@ const App: React.FC = () => {
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/settings/tokens" element={<TokenManagement />} />
|
||||
<Route path="/settings/rate-limiting" element={<RateLimiting />} />
|
||||
<Route path="/settings/agents" element={<AgentManagement />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
|
||||
@@ -16,20 +16,9 @@ export const useHeartbeatStatus = (agentId: string, enabled: boolean = true): Us
|
||||
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
|
||||
staleTime: 0, // Always consider data stale to force refetch
|
||||
refetchInterval: 5000, // Poll every 5 seconds regardless of state
|
||||
refetchOnWindowFocus: true, // Refresh when window gains focus
|
||||
refetchOnMount: true, // Always refetch when component mounts
|
||||
});
|
||||
};
|
||||
|
||||
@@ -85,6 +85,24 @@ export const useRevokeRegistrationToken = () => {
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeleteRegistrationToken = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => adminApi.tokens.deleteToken(id),
|
||||
onSuccess: (_, tokenId) => {
|
||||
toast.success('Registration token deleted successfully');
|
||||
queryClient.invalidateQueries({ queryKey: registrationTokenKeys.lists() });
|
||||
queryClient.invalidateQueries({ queryKey: registrationTokenKeys.detail(tokenId) });
|
||||
queryClient.invalidateQueries({ queryKey: registrationTokenKeys.stats() });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
console.error('Failed to delete registration token:', error);
|
||||
toast.error(error.response?.data?.message || 'Failed to delete registration token');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useCleanupRegistrationTokens = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
|
||||
@@ -24,8 +24,8 @@ import {
|
||||
RateLimitSummary
|
||||
} from '@/types';
|
||||
|
||||
// Base URL for API
|
||||
export const API_BASE_URL = (import.meta.env?.VITE_API_URL as string) || '/api/v1';
|
||||
// Base URL for API - use nginx proxy
|
||||
export const API_BASE_URL = '/api/v1';
|
||||
|
||||
// Create axios instance
|
||||
const api = axios.create({
|
||||
@@ -237,8 +237,8 @@ export const logApi = {
|
||||
};
|
||||
|
||||
export const authApi = {
|
||||
// Simple login (using API key or token)
|
||||
login: async (credentials: { token: string }): Promise<{ token: string }> => {
|
||||
// Login with username and password
|
||||
login: async (credentials: { username: string; password: string }): Promise<{ token: string; user: any }> => {
|
||||
const response = await api.post('/auth/login', credentials);
|
||||
return response.data;
|
||||
},
|
||||
@@ -255,9 +255,9 @@ export const authApi = {
|
||||
},
|
||||
};
|
||||
|
||||
// Setup API for server configuration (uses base API without auth)
|
||||
// Setup API for server configuration (uses nginx proxy)
|
||||
const setupApiInstance = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
baseURL: '/api',
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -283,8 +283,8 @@ export const setupApi = {
|
||||
serverHost: string;
|
||||
serverPort: string;
|
||||
maxSeats: string;
|
||||
}): Promise<{ message: string; configPath?: string; restart?: boolean }> => {
|
||||
const response = await setupApiInstance.post('/setup', config);
|
||||
}): Promise<{ message: string; jwtSecret?: string; envContent?: string; manualRestartRequired?: boolean; manualRestartCommand?: string; configFilePath?: string }> => {
|
||||
const response = await setupApiInstance.post('/setup/configure', config);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
@@ -456,11 +456,16 @@ export const adminApi = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Revoke registration token
|
||||
// Revoke registration token (soft delete)
|
||||
revokeToken: async (id: string): Promise<void> => {
|
||||
await api.delete(`/admin/registration-tokens/${id}`);
|
||||
},
|
||||
|
||||
// Delete registration token (hard delete)
|
||||
deleteToken: async (id: string): Promise<void> => {
|
||||
await api.delete(`/admin/registration-tokens/delete/${id}`);
|
||||
},
|
||||
|
||||
// Get registration token statistics
|
||||
getStats: async (): Promise<RegistrationTokenStats> => {
|
||||
const response = await api.get('/admin/registration-tokens/stats');
|
||||
@@ -479,7 +484,17 @@ export const adminApi = {
|
||||
// Get all rate limit configurations
|
||||
getConfigs: async (): Promise<RateLimitConfig[]> => {
|
||||
const response = await api.get('/admin/rate-limits');
|
||||
return response.data;
|
||||
|
||||
// Backend returns { settings: {...}, updated_at: "..." }
|
||||
// Transform settings object to array format expected by frontend
|
||||
const settings = response.data.settings || {};
|
||||
const configs: RateLimitConfig[] = Object.entries(settings).map(([endpoint, config]: [string, any]) => ({
|
||||
...config,
|
||||
endpoint,
|
||||
updated_at: response.data.updated_at, // Preserve update timestamp
|
||||
}));
|
||||
|
||||
return configs;
|
||||
},
|
||||
|
||||
// Update rate limit configuration
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Eye, EyeOff, Shield } from 'lucide-react';
|
||||
import { Eye, EyeOff, Shield, User } from 'lucide-react';
|
||||
import { useAuthStore } from '@/lib/store';
|
||||
import { authApi } from '@/lib/api';
|
||||
import { handleApiError } from '@/lib/api';
|
||||
@@ -9,24 +9,31 @@ import toast from 'react-hot-toast';
|
||||
const Login: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { setToken } = useAuthStore();
|
||||
const [token, setTokenInput] = useState('');
|
||||
const [showToken, setShowToken] = useState(false);
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!token.trim()) {
|
||||
toast.error('Please enter your authentication token');
|
||||
if (!username.trim()) {
|
||||
toast.error('Please enter your username');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!password.trim()) {
|
||||
toast.error('Please enter your password');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await authApi.login({ token: token.trim() });
|
||||
const response = await authApi.login({ username: username.trim(), password: password.trim() });
|
||||
setToken(response.token);
|
||||
localStorage.setItem('auth_token', response.token);
|
||||
toast.success('Login successful');
|
||||
localStorage.setItem('user', JSON.stringify(response.user));
|
||||
toast.success(`Welcome back, ${response.user.username}!`);
|
||||
navigate('/');
|
||||
} catch (error) {
|
||||
const apiError = handleApiError(error);
|
||||
@@ -48,7 +55,7 @@ const Login: React.FC = () => {
|
||||
Sign in to RedFlag
|
||||
</h2>
|
||||
<p className="mt-2 text-center text-sm text-gray-600">
|
||||
Enter your authentication token to access the dashboard
|
||||
Enter your username and password to access the dashboard
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -56,25 +63,45 @@ const Login: React.FC = () => {
|
||||
<div className="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10">
|
||||
<form className="space-y-6" onSubmit={handleSubmit}>
|
||||
<div>
|
||||
<label htmlFor="token" className="block text-sm font-medium text-gray-700">
|
||||
Authentication Token
|
||||
<label htmlFor="username" className="block text-sm font-medium text-gray-700">
|
||||
Username
|
||||
</label>
|
||||
<div className="mt-1 relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<User className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="appearance-none block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
|
||||
placeholder="Enter your username"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
|
||||
Password
|
||||
</label>
|
||||
<div className="mt-1 relative">
|
||||
<input
|
||||
id="token"
|
||||
type={showToken ? 'text' : 'password'}
|
||||
value={token}
|
||||
onChange={(e) => setTokenInput(e.target.value)}
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
|
||||
placeholder="Enter your JWT token"
|
||||
placeholder="Enter your password"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||
onClick={() => setShowToken(!showToken)}
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
>
|
||||
{showToken ? (
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-5 w-5 text-gray-400" />
|
||||
) : (
|
||||
<Eye className="h-5 w-5 text-gray-400" />
|
||||
@@ -106,11 +133,11 @@ const Login: React.FC = () => {
|
||||
<div className="flex items-start space-x-2">
|
||||
<Shield className="h-4 w-4 text-gray-400 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium">How to get your token:</p>
|
||||
<p className="font-medium">Login credentials:</p>
|
||||
<ul className="mt-1 list-disc list-inside space-y-1 text-xs">
|
||||
<li>Check your RedFlag server configuration</li>
|
||||
<li>Look for the JWT secret in your server settings</li>
|
||||
<li>Generate a token using the server CLI</li>
|
||||
<li>Use the admin username you configured during server setup</li>
|
||||
<li>Enter the password you set during server configuration</li>
|
||||
<li>If you forgot your credentials, check your server configuration</li>
|
||||
<li>Contact your administrator if you need access</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Shield,
|
||||
RefreshCw,
|
||||
@@ -25,7 +26,24 @@ import {
|
||||
} from '../hooks/useRateLimits';
|
||||
import { RateLimitConfig, RateLimitStats, RateLimitUsage } from '@/types';
|
||||
|
||||
// Helper function to format date/time strings
|
||||
const formatDateTime = (dateString: string): string => {
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
});
|
||||
} catch (error) {
|
||||
return dateString;
|
||||
}
|
||||
};
|
||||
|
||||
const RateLimiting: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [editingMode, setEditingMode] = useState(false);
|
||||
const [editingConfigs, setEditingConfigs] = useState<RateLimitConfig[]>([]);
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
@@ -46,14 +64,14 @@ const RateLimiting: React.FC = () => {
|
||||
const cleanupLimits = useCleanupRateLimits();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (configs) {
|
||||
if (configs && Array.isArray(configs)) {
|
||||
setEditingConfigs([...configs]);
|
||||
}
|
||||
}, [configs]);
|
||||
|
||||
// Filtered configurations for display
|
||||
const filteredConfigs = useMemo(() => {
|
||||
if (!configs) return [];
|
||||
if (!configs || !Array.isArray(configs)) return [];
|
||||
|
||||
return configs.filter((config) => {
|
||||
const matchesSearch = searchTerm === '' ||
|
||||
@@ -122,6 +140,13 @@ const RateLimiting: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-6 py-8">
|
||||
<button
|
||||
onClick={() => navigate('/settings')}
|
||||
className="text-sm text-gray-500 hover:text-gray-700 mb-4"
|
||||
>
|
||||
← Back to Settings
|
||||
</button>
|
||||
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -234,7 +259,9 @@ const RateLimiting: React.FC = () => {
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingConfigs([...configs!]);
|
||||
if (configs && Array.isArray(configs)) {
|
||||
setEditingConfigs([...configs]);
|
||||
}
|
||||
setEditingMode(false);
|
||||
}}
|
||||
className="px-4 py-2 bg-gray-200 text-gray-800 rounded-lg hover:bg-gray-300"
|
||||
@@ -489,7 +516,7 @@ const RateLimiting: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* Rate Limit Statistics */}
|
||||
{stats && stats.length > 0 && (
|
||||
{stats && Array.isArray(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>
|
||||
@@ -526,7 +553,7 @@ const RateLimiting: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{stat.top_clients && stat.top_clients.length > 0 && (
|
||||
{stat.top_clients && Array.isArray(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">
|
||||
@@ -547,7 +574,7 @@ const RateLimiting: React.FC = () => {
|
||||
)}
|
||||
|
||||
{/* Usage Monitoring */}
|
||||
{usage && usage.length > 0 && (
|
||||
{usage && Array.isArray(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>
|
||||
|
||||
@@ -83,11 +83,11 @@ const Settings: React.FC = () => {
|
||||
},
|
||||
{
|
||||
title: 'Agent Management',
|
||||
description: 'Agent defaults and cleanup policies',
|
||||
description: 'Deploy and configure agents across platforms',
|
||||
icon: SettingsIcon,
|
||||
href: '/settings/agents',
|
||||
stats: null,
|
||||
status: 'not-implemented'
|
||||
status: 'implemented'
|
||||
}
|
||||
];
|
||||
|
||||
@@ -134,14 +134,17 @@ const Settings: React.FC = () => {
|
||||
<p className="text-sm text-gray-400 mt-1">Coming soon</p>
|
||||
</div>
|
||||
|
||||
<div className="p-6 bg-gray-50 border border-gray-200 rounded-lg opacity-60">
|
||||
<Link
|
||||
to="/settings/agents"
|
||||
className="block p-6 bg-white border border-gray-200 rounded-lg hover:border-purple-300 hover:shadow-sm transition-all"
|
||||
>
|
||||
<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" />
|
||||
<SettingsIcon className="w-8 h-8 text-purple-600" />
|
||||
<ArrowRight className="w-5 h-5 text-gray-400" />
|
||||
</div>
|
||||
<h3 className="font-semibold text-gray-500">Agent Management</h3>
|
||||
<p className="text-sm text-gray-400 mt-1">Coming soon</p>
|
||||
</div>
|
||||
<h3 className="font-semibold text-gray-900">Agent Management</h3>
|
||||
<p className="text-sm text-gray-600 mt-1">Deploy and configure agents</p>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Overview Statistics */}
|
||||
@@ -326,7 +329,6 @@ const Settings: React.FC = () => {
|
||||
<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>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { XCircle } from 'lucide-react';
|
||||
import { Settings, Database, User, Shield, Eye, EyeOff, CheckCircle } from 'lucide-react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { setupApi } from '@/lib/api';
|
||||
|
||||
@@ -17,11 +17,15 @@ interface SetupFormData {
|
||||
maxSeats: string;
|
||||
}
|
||||
|
||||
|
||||
const Setup: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [jwtSecret, setJwtSecret] = useState<string | null>(null);
|
||||
const [envContent, setEnvContent] = useState<string | null>(null);
|
||||
const [showSuccess, setShowSuccess] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showDbPassword, setShowDbPassword] = useState(false);
|
||||
|
||||
const [formData, setFormData] = useState<SetupFormData>({
|
||||
adminUser: 'admin',
|
||||
@@ -108,20 +112,12 @@ const Setup: React.FC = () => {
|
||||
try {
|
||||
const result = await setupApi.configure(formData);
|
||||
|
||||
// Store JWT secret, env content and show success screen
|
||||
setJwtSecret(result.jwtSecret || null);
|
||||
setEnvContent(result.envContent || null);
|
||||
setShowSuccess(true);
|
||||
toast.success(result.message || 'Configuration saved successfully!');
|
||||
|
||||
if (result.restart) {
|
||||
// Server is restarting, wait for it to come back online
|
||||
setTimeout(() => {
|
||||
navigate('/login');
|
||||
}, 5000); // Give server time to restart
|
||||
} else {
|
||||
// No restart, redirect immediately
|
||||
setTimeout(() => {
|
||||
navigate('/login');
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('Setup error:', error);
|
||||
const errorMessage = error.response?.data?.error || error.message || 'Setup failed';
|
||||
@@ -132,38 +128,177 @@ const Setup: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="py-8">
|
||||
<h2 className="text-2xl font-bold text-gray-900">Server Setup</h2>
|
||||
<p className="mt-1 text-sm text-gray-600">
|
||||
Configure your update management server
|
||||
</p>
|
||||
</div>
|
||||
// Success screen with credentials display
|
||||
if (showSuccess && jwtSecret) {
|
||||
return (
|
||||
<div className="px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-3xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center justify-center mb-4">
|
||||
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center">
|
||||
<CheckCircle className="w-8 h-8 text-green-600" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 text-center mb-2">
|
||||
Configuration Complete!
|
||||
</h1>
|
||||
<p className="text-gray-600 text-center">
|
||||
Your RedFlag server is ready to use
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<form onSubmit={handleSubmit} className="divide-y divide-gray-200">
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<div className="px-6 py-4 bg-red-50">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<XCircle className="h-5 w-5 text-red-400" />
|
||||
{/* Success Card */}
|
||||
<div className="bg-white rounded-lg border border-gray-200 shadow-sm p-6">
|
||||
{/* Admin Credentials Section */}
|
||||
<div className="mb-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-3">Administrator Credentials</h3>
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-md p-4">
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">Username</label>
|
||||
<div className="mt-1 p-2 bg-white border border-gray-300 rounded text-sm font-mono">{formData.adminUser}</div>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-red-800">{error}</h3>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">Password</label>
|
||||
<div className="mt-1 p-2 bg-white border border-gray-300 rounded text-sm font-mono">••••••••</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 p-3 bg-green-50 border border-green-200 rounded-md">
|
||||
<p className="text-sm text-green-800">
|
||||
<strong>Important:</strong> Save these credentials securely. You'll use them to login to the RedFlag dashboard.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Configuration Content Section */}
|
||||
{envContent && (
|
||||
<div className="mb-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-3">Configuration File Content</h3>
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-md p-4">
|
||||
<textarea
|
||||
readOnly
|
||||
value={envContent}
|
||||
className="w-full h-64 p-3 text-xs font-mono text-gray-800 bg-white border border-gray-300 rounded-md resize-none focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(envContent);
|
||||
toast.success('Configuration content copied to clipboard!');
|
||||
}}
|
||||
className="mt-3 w-full flex justify-center py-2 px-4 border border-transparent rounded-md text-sm font-medium text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500"
|
||||
>
|
||||
Copy Configuration Content
|
||||
</button>
|
||||
<div className="mt-3 p-3 bg-blue-50 border border-blue-200 rounded-md">
|
||||
<p className="text-sm text-blue-800">
|
||||
<strong>Important:</strong> Copy this configuration content and save it to <code className="bg-blue-100 px-1 rounded">./config/.env</code>, then run <code className="bg-blue-100 px-1 rounded">docker-compose down && docker-compose up -d</code> to apply the configuration.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Admin Account */}
|
||||
<div className="px-6 py-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">Admin Account</h3>
|
||||
{/* JWT Secret Section (Server Configuration) */}
|
||||
<div className="mb-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-3">Server JWT Secret</h3>
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-md p-4">
|
||||
<code className="text-sm text-gray-800 break-all font-mono">{jwtSecret}</code>
|
||||
</div>
|
||||
<div className="mt-3 p-3 bg-gray-50 border border-gray-200 rounded-md">
|
||||
<p className="text-sm text-gray-700">
|
||||
<strong>For your information:</strong> This JWT secret is used internally by the server for session management and agent authentication. It's automatically included in the configuration file above.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(jwtSecret);
|
||||
toast.success('JWT secret copied to clipboard!');
|
||||
}}
|
||||
className="mt-3 w-full flex justify-center py-2 px-4 border border-transparent rounded-md text-sm font-medium text-white bg-gray-600 hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500"
|
||||
>
|
||||
Copy JWT Secret (Optional)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Next Steps */}
|
||||
<div className="border-t border-gray-200 pt-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-3">Next Steps</h3>
|
||||
<ol className="list-decimal list-inside space-y-2 text-sm text-gray-600">
|
||||
<li>Copy the configuration content using the green button above</li>
|
||||
<li>Save it to <code className="bg-gray-100 px-1 rounded">./config/.env</code></li>
|
||||
<li>Run <code className="bg-gray-100 px-1 rounded">docker-compose down && docker-compose up -d</code></li>
|
||||
<li>Login to the dashboard with your admin username and password</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 pt-6 border-t border-gray-200 space-y-3">
|
||||
<div className="p-3 bg-yellow-50 border border-yellow-200 rounded-md">
|
||||
<p className="text-sm text-yellow-800">
|
||||
<strong>Important:</strong> You must restart the containers to apply the configuration before logging in.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
toast.success('Please run: docker-compose down && docker-compose up -d');
|
||||
}}
|
||||
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md text-sm font-medium text-white bg-yellow-600 hover:bg-yellow-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-yellow-500"
|
||||
>
|
||||
Show Restart Command
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setTimeout(() => navigate('/login'), 500);
|
||||
}}
|
||||
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
>
|
||||
Continue to Login (After Restart)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center justify-center mb-4">
|
||||
<div className="w-16 h-16 bg-indigo-100 rounded-full flex items-center justify-center">
|
||||
<span className="text-2xl">🚩</span>
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 text-center mb-2">
|
||||
Configure RedFlag Server
|
||||
</h1>
|
||||
<p className="text-gray-600 text-center">
|
||||
Set up your update management server configuration
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Setup Form */}
|
||||
<div className="bg-white rounded-lg border border-gray-200 shadow-sm p-6">
|
||||
<form onSubmit={handleSubmit} className="space-y-8">
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-md p-4">
|
||||
<div className="text-sm text-red-800">{error}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Administrator Account */}
|
||||
<div>
|
||||
<div className="flex items-center mb-4">
|
||||
<User className="h-5 w-5 text-indigo-600 mr-2" />
|
||||
<h3 className="text-lg font-semibold text-gray-900">Administrator Account</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label htmlFor="adminUser" className="block text-sm font-medium text-gray-700">
|
||||
<label htmlFor="adminUser" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Admin Username
|
||||
</label>
|
||||
<input
|
||||
@@ -172,33 +307,51 @@ const Setup: React.FC = () => {
|
||||
name="adminUser"
|
||||
value={formData.adminUser}
|
||||
onChange={handleInputChange}
|
||||
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
|
||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
placeholder="admin"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="adminPassword" className="block text-sm font-medium text-gray-700">
|
||||
<label htmlFor="adminPassword" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Admin Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="adminPassword"
|
||||
name="adminPassword"
|
||||
value={formData.adminPassword}
|
||||
onChange={handleInputChange}
|
||||
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
|
||||
required
|
||||
/>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
id="adminPassword"
|
||||
name="adminPassword"
|
||||
value={formData.adminPassword}
|
||||
onChange={handleInputChange}
|
||||
className="block w-full px-3 py-2 pr-10 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
placeholder="Enter secure password"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-4 w-4 text-gray-400" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4 text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Database Configuration */}
|
||||
<div className="px-6 py-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">Database Configuration</h3>
|
||||
<div>
|
||||
<div className="flex items-center mb-4">
|
||||
<Database className="h-5 w-5 text-indigo-600 mr-2" />
|
||||
<h3 className="text-lg font-semibold text-gray-900">Database Configuration</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div>
|
||||
<label htmlFor="dbHost" className="block text-sm font-medium text-gray-700">
|
||||
<label htmlFor="dbHost" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Database Host
|
||||
</label>
|
||||
<input
|
||||
@@ -207,12 +360,13 @@ const Setup: React.FC = () => {
|
||||
name="dbHost"
|
||||
value={formData.dbHost}
|
||||
onChange={handleInputChange}
|
||||
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
|
||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
placeholder="postgres"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="dbPort" className="block text-sm font-medium text-gray-700">
|
||||
<label htmlFor="dbPort" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Database Port
|
||||
</label>
|
||||
<input
|
||||
@@ -221,12 +375,12 @@ const Setup: React.FC = () => {
|
||||
name="dbPort"
|
||||
value={formData.dbPort}
|
||||
onChange={handleInputChange}
|
||||
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
|
||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="dbName" className="block text-sm font-medium text-gray-700">
|
||||
<label htmlFor="dbName" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Database Name
|
||||
</label>
|
||||
<input
|
||||
@@ -235,12 +389,13 @@ const Setup: React.FC = () => {
|
||||
name="dbName"
|
||||
value={formData.dbName}
|
||||
onChange={handleInputChange}
|
||||
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
|
||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
placeholder="redflag"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="dbUser" className="block text-sm font-medium text-gray-700">
|
||||
<label htmlFor="dbUser" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Database User
|
||||
</label>
|
||||
<input
|
||||
@@ -249,33 +404,51 @@ const Setup: React.FC = () => {
|
||||
name="dbUser"
|
||||
value={formData.dbUser}
|
||||
onChange={handleInputChange}
|
||||
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
|
||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
placeholder="redflag"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="dbPassword" className="block text-sm font-medium text-gray-700">
|
||||
<label htmlFor="dbPassword" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Database Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="dbPassword"
|
||||
name="dbPassword"
|
||||
value={formData.dbPassword}
|
||||
onChange={handleInputChange}
|
||||
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
|
||||
required
|
||||
/>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showDbPassword ? 'text' : 'password'}
|
||||
id="dbPassword"
|
||||
name="dbPassword"
|
||||
value={formData.dbPassword}
|
||||
onChange={handleInputChange}
|
||||
className="block w-full px-3 py-2 pr-10 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
placeholder="Enter database password"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||
onClick={() => setShowDbPassword(!showDbPassword)}
|
||||
>
|
||||
{showDbPassword ? (
|
||||
<EyeOff className="h-4 w-4 text-gray-400" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4 text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Server Configuration */}
|
||||
<div className="px-6 py-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">Server Configuration</h3>
|
||||
<div>
|
||||
<div className="flex items-center mb-4">
|
||||
<Settings className="h-5 w-5 text-indigo-600 mr-2" />
|
||||
<h3 className="text-lg font-semibold text-gray-900">Server Configuration</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label htmlFor="serverHost" className="block text-sm font-medium text-gray-700">
|
||||
<label htmlFor="serverHost" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Server Host
|
||||
</label>
|
||||
<input
|
||||
@@ -284,12 +457,13 @@ const Setup: React.FC = () => {
|
||||
name="serverHost"
|
||||
value={formData.serverHost}
|
||||
onChange={handleInputChange}
|
||||
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
|
||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
placeholder="0.0.0.0"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="serverPort" className="block text-sm font-medium text-gray-700">
|
||||
<label htmlFor="serverPort" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Server Port
|
||||
</label>
|
||||
<input
|
||||
@@ -298,12 +472,13 @@ const Setup: React.FC = () => {
|
||||
name="serverPort"
|
||||
value={formData.serverPort}
|
||||
onChange={handleInputChange}
|
||||
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
|
||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
placeholder="8080"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="maxSeats" className="block text-sm font-medium text-gray-700">
|
||||
<label htmlFor="maxSeats" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Maximum Agent Seats
|
||||
</label>
|
||||
<input
|
||||
@@ -312,9 +487,10 @@ const Setup: React.FC = () => {
|
||||
name="maxSeats"
|
||||
value={formData.maxSeats}
|
||||
onChange={handleInputChange}
|
||||
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
|
||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
min="1"
|
||||
max="1000"
|
||||
placeholder="50"
|
||||
required
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">Security limit for agent registration</p>
|
||||
@@ -323,19 +499,22 @@ const Setup: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<div className="px-6 py-4 bg-gray-50">
|
||||
<div className="pt-6 border-t border-gray-200">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||
Configuring...
|
||||
Configuring RedFlag Server...
|
||||
</div>
|
||||
) : (
|
||||
'Configure Server'
|
||||
<div className="flex items-center">
|
||||
<Shield className="w-4 h-4 mr-2" />
|
||||
Configure RedFlag Server
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Shield,
|
||||
Plus,
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
useRegistrationTokens,
|
||||
useCreateRegistrationToken,
|
||||
useRevokeRegistrationToken,
|
||||
useDeleteRegistrationToken,
|
||||
useRegistrationTokenStats,
|
||||
useCleanupRegistrationTokens
|
||||
} from '../hooks/useRegistrationTokens';
|
||||
@@ -26,6 +28,8 @@ import { RegistrationToken, CreateRegistrationTokenRequest } from '@/types';
|
||||
import { formatDateTime } from '@/lib/utils';
|
||||
|
||||
const TokenManagement: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Filters and search
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'used' | 'expired' | 'revoked'>('all');
|
||||
@@ -47,6 +51,7 @@ const TokenManagement: React.FC = () => {
|
||||
const { data: stats, isLoading: isLoadingStats } = useRegistrationTokenStats();
|
||||
const createToken = useCreateRegistrationToken();
|
||||
const revokeToken = useRevokeRegistrationToken();
|
||||
const deleteToken = useDeleteRegistrationToken();
|
||||
const cleanupTokens = useCleanupRegistrationTokens();
|
||||
|
||||
// Reset page when filters change
|
||||
@@ -57,15 +62,15 @@ const TokenManagement: React.FC = () => {
|
||||
// Form state
|
||||
const [formData, setFormData] = useState<CreateRegistrationTokenRequest>({
|
||||
label: '',
|
||||
max_seats: 10,
|
||||
expires_at: '',
|
||||
expires_in: '168h', // Default 7 days
|
||||
max_seats: 1, // Default 1 seat
|
||||
});
|
||||
|
||||
const handleCreateToken = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
createToken.mutate(formData, {
|
||||
onSuccess: () => {
|
||||
setFormData({ label: '', max_seats: 10, expires_at: '' });
|
||||
setFormData({ label: '', expires_in: '168h', max_seats: 1 });
|
||||
setShowCreateForm(false);
|
||||
refetch();
|
||||
},
|
||||
@@ -78,44 +83,65 @@ const TokenManagement: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteToken = (tokenId: string, tokenLabel: string) => {
|
||||
if (confirm(`⚠️ PERMANENTLY DELETE token "${tokenLabel}"? This cannot be undone!`)) {
|
||||
deleteToken.mutate(tokenId, { onSuccess: () => refetch() });
|
||||
}
|
||||
};
|
||||
|
||||
const handleCleanup = () => {
|
||||
if (confirm('Clean up all expired tokens? This cannot be undone.')) {
|
||||
cleanupTokens.mutate(undefined, { onSuccess: () => refetch() });
|
||||
}
|
||||
};
|
||||
|
||||
const getServerUrl = () => {
|
||||
return `${window.location.protocol}//${window.location.host}`;
|
||||
};
|
||||
|
||||
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}`;
|
||||
const serverUrl = getServerUrl();
|
||||
const command = `curl -sfL ${serverUrl}/api/v1/install/linux | bash -s -- ${token}`;
|
||||
await navigator.clipboard.writeText(command);
|
||||
};
|
||||
|
||||
const generateInstallCommand = (token: string) => {
|
||||
return `curl -sSL https://get.redflag.dev | bash -s -- ${token}`;
|
||||
const serverUrl = getServerUrl();
|
||||
return `curl -sfL ${serverUrl}/api/v1/install/linux | 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';
|
||||
if (token.status === 'revoked') return 'text-gray-500';
|
||||
if (token.status === 'expired') return 'text-red-600';
|
||||
if (token.status === 'used') return 'text-yellow-600';
|
||||
if (token.status === 'active') return 'text-green-600';
|
||||
return 'text-gray-500';
|
||||
};
|
||||
|
||||
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';
|
||||
if (token.status === 'revoked') return 'Revoked';
|
||||
if (token.status === 'expired') return 'Expired';
|
||||
if (token.status === 'used') return 'Used';
|
||||
if (token.status === 'active') return 'Active';
|
||||
return token.status.charAt(0).toUpperCase() + token.status.slice(1);
|
||||
};
|
||||
|
||||
const filteredTokens = tokensData?.tokens || [];
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-6 py-8">
|
||||
<button
|
||||
onClick={() => navigate('/settings')}
|
||||
className="text-sm text-gray-500 hover:text-gray-700 mb-4"
|
||||
>
|
||||
← Back to Settings
|
||||
</button>
|
||||
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -220,32 +246,37 @@ const TokenManagement: React.FC = () => {
|
||||
required
|
||||
value={formData.label}
|
||||
onChange={(e) => setFormData({ ...formData, label: e.target.value })}
|
||||
placeholder="e.g., Production Team"
|
||||
placeholder="e.g., Production Servers, Development 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>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Expires In</label>
|
||||
<select
|
||||
value={formData.expires_in}
|
||||
onChange={(e) => setFormData({ ...formData, expires_in: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="24h">24 hours</option>
|
||||
<option value="72h">3 days</option>
|
||||
<option value="168h">7 days (1 week)</option>
|
||||
</select>
|
||||
<p className="mt-1 text-xs text-gray-500">Maximum 7 days per server security policy</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Max Seats (Agents)</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)}
|
||||
max="100"
|
||||
value={formData.max_seats || 1}
|
||||
onChange={(e) => setFormData({ ...formData, max_seats: parseInt(e.target.value) || 1 })}
|
||||
placeholder="1"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">Number of agents that can use this token</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -403,27 +434,24 @@ const TokenManagement: React.FC = () => {
|
||||
</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 className="text-sm text-gray-500">
|
||||
{token.seats_used}/{token.max_seats} used
|
||||
{token.seats_used >= token.max_seats && (
|
||||
<span className="ml-2 text-xs text-red-600">(Full)</span>
|
||||
)}
|
||||
{token.seats_used < token.max_seats && token.status === 'active' && (
|
||||
<span className="ml-2 text-xs text-green-600">({token.max_seats - token.seats_used} available)</span>
|
||||
)}
|
||||
</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'}
|
||||
{formatDateTime(token.expires_at)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{formatDateTime(token.last_used_at) || 'Never'}
|
||||
{token.used_at ? formatDateTime(token.used_at) : 'Never'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -441,16 +469,24 @@ const TokenManagement: React.FC = () => {
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</button>
|
||||
{token.is_active && (
|
||||
{token.status === 'active' && (
|
||||
<button
|
||||
onClick={() => handleRevokeToken(token.id, token.label)}
|
||||
onClick={() => handleRevokeToken(token.id, token.label || 'this token')}
|
||||
disabled={revokeToken.isPending}
|
||||
className="text-red-600 hover:text-red-800 disabled:opacity-50"
|
||||
title="Revoke token"
|
||||
className="text-orange-600 hover:text-orange-800 disabled:opacity-50"
|
||||
title="Revoke token (soft delete)"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleDeleteToken(token.id, token.label || 'this token')}
|
||||
disabled={deleteToken.isPending}
|
||||
className="text-red-600 hover:text-red-800 disabled:opacity-50"
|
||||
title="Permanently delete token"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
491
aggregator-web/src/pages/settings/AgentManagement.tsx
Normal file
491
aggregator-web/src/pages/settings/AgentManagement.tsx
Normal file
@@ -0,0 +1,491 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Download,
|
||||
Terminal,
|
||||
Copy,
|
||||
Check,
|
||||
Shield,
|
||||
Server,
|
||||
Monitor,
|
||||
AlertTriangle,
|
||||
ExternalLink,
|
||||
RefreshCw,
|
||||
Code,
|
||||
FileText,
|
||||
Package
|
||||
} from 'lucide-react';
|
||||
import { useRegistrationTokens } from '@/hooks/useRegistrationTokens';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
||||
const AgentManagement: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [copiedCommand, setCopiedCommand] = useState<string | null>(null);
|
||||
const [selectedPlatform, setSelectedPlatform] = useState<string>('linux');
|
||||
const { data: tokens, isLoading: tokensLoading } = useRegistrationTokens({ is_active: true });
|
||||
|
||||
const platforms = [
|
||||
{
|
||||
id: 'linux',
|
||||
name: 'Linux',
|
||||
icon: Server,
|
||||
description: 'For Ubuntu, Debian, RHEL, CentOS, AlmaLinux, Rocky Linux',
|
||||
downloadUrl: '/api/v1/downloads/linux-amd64',
|
||||
installScript: '/api/v1/install/linux',
|
||||
extensions: ['amd64'],
|
||||
color: 'orange'
|
||||
},
|
||||
{
|
||||
id: 'windows',
|
||||
name: 'Windows',
|
||||
icon: Monitor,
|
||||
description: 'For Windows 10/11, Windows Server 2019/2022',
|
||||
downloadUrl: '/api/v1/downloads/windows-amd64',
|
||||
installScript: '/api/v1/install/windows',
|
||||
extensions: ['amd64'],
|
||||
color: 'blue'
|
||||
}
|
||||
];
|
||||
|
||||
const getServerUrl = () => {
|
||||
return `${window.location.protocol}//${window.location.host}`;
|
||||
};
|
||||
|
||||
const getActiveToken = () => {
|
||||
// Defensive null checking to prevent crashes
|
||||
if (!tokens || !tokens.tokens || !Array.isArray(tokens.tokens) || tokens.tokens.length === 0) {
|
||||
return 'YOUR_REGISTRATION_TOKEN';
|
||||
}
|
||||
return tokens.tokens[0]?.token || 'YOUR_REGISTRATION_TOKEN';
|
||||
};
|
||||
|
||||
const generateInstallCommand = (platform: typeof platforms[0]) => {
|
||||
const serverUrl = getServerUrl();
|
||||
const token = getActiveToken();
|
||||
|
||||
if (platform.id === 'linux') {
|
||||
if (token !== 'YOUR_REGISTRATION_TOKEN') {
|
||||
return `curl -sfL ${serverUrl}${platform.installScript} | sudo bash -s -- ${token}`;
|
||||
} else {
|
||||
return `curl -sfL ${serverUrl}${platform.installScript} | sudo bash`;
|
||||
}
|
||||
} else if (platform.id === 'windows') {
|
||||
if (token !== 'YOUR_REGISTRATION_TOKEN') {
|
||||
return `iwr ${serverUrl}${platform.installScript} -OutFile install.bat; .\\install.bat ${token}`;
|
||||
} else {
|
||||
return `iwr ${serverUrl}${platform.installScript} -OutFile install.bat; .\\install.bat`;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const generateManualCommand = (platform: typeof platforms[0]) => {
|
||||
const serverUrl = getServerUrl();
|
||||
const token = getActiveToken();
|
||||
|
||||
if (platform.id === 'windows') {
|
||||
if (token !== 'YOUR_REGISTRATION_TOKEN') {
|
||||
return `# Download and run as Administrator with token\niwr ${serverUrl}${platform.installScript} -OutFile install.bat\n.\\install.bat ${token}`;
|
||||
} else {
|
||||
return `# Download and run as Administrator\niwr ${serverUrl}${platform.installScript} -OutFile install.bat\n.\\install.bat`;
|
||||
}
|
||||
} else {
|
||||
if (token !== 'YOUR_REGISTRATION_TOKEN') {
|
||||
return `# Download and run as root with token\ncurl -sfL ${serverUrl}${platform.installScript} | sudo bash -s -- ${token}`;
|
||||
} else {
|
||||
return `# Download and run as root\ncurl -sfL ${serverUrl}${platform.installScript} | sudo bash`;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = async (text: string, commandId: string) => {
|
||||
try {
|
||||
if (!text || text.trim() === '') {
|
||||
toast.error('No command to copy');
|
||||
return;
|
||||
}
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopiedCommand(commandId);
|
||||
toast.success('Command copied to clipboard!');
|
||||
setTimeout(() => setCopiedCommand(null), 2000);
|
||||
} catch (error) {
|
||||
console.error('Copy failed:', error);
|
||||
toast.error('Failed to copy command. Please copy manually.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = (platform: typeof platforms[0]) => {
|
||||
const link = document.createElement('a');
|
||||
link.href = `${getServerUrl()}${platform.downloadUrl}`;
|
||||
link.download = `redflag-agent-${platform.id}-amd64${platform.id === 'windows' ? '.exe' : ''}`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
toast.success(`Download started for ${platform.name} agent`);
|
||||
};
|
||||
|
||||
const selectedPlatformData = platforms.find(p => p.id === selectedPlatform);
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto px-6 py-8">
|
||||
<button
|
||||
onClick={() => navigate('/settings')}
|
||||
className="text-sm text-gray-500 hover:text-gray-700 mb-4"
|
||||
>
|
||||
← Back to Settings
|
||||
</button>
|
||||
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Agent Management</h1>
|
||||
<p className="mt-2 text-gray-600">Deploy and configure RedFlag agents across your infrastructure</p>
|
||||
</div>
|
||||
<Link
|
||||
to="/settings/tokens"
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<Shield className="w-4 h-4" />
|
||||
Manage Tokens
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Token Status */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-6 mb-8">
|
||||
<div className="flex items-start gap-4">
|
||||
<Shield className="w-6 h-6 text-blue-600 mt-1" />
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-blue-900 mb-2">Registration Token Required</h3>
|
||||
<p className="text-blue-700 mb-4">
|
||||
Agents need a registration token to enroll with the server. You have {tokens?.tokens?.length || 0} active token(s).
|
||||
</p>
|
||||
{!tokens?.tokens || tokens.tokens.length === 0 ? (
|
||||
<Link
|
||||
to="/settings/tokens"
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<Shield className="w-4 h-4" />
|
||||
Generate Registration Token
|
||||
</Link>
|
||||
) : (
|
||||
<div className="flex items-center gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-blue-600 font-medium">Active Token:</p>
|
||||
<code className="text-xs bg-blue-100 px-2 py-1 rounded">{tokens?.tokens?.[0]?.token || 'N/A'}</code>
|
||||
</div>
|
||||
<Link
|
||||
to="/settings/tokens"
|
||||
className="text-sm text-blue-600 hover:text-blue-800 underline"
|
||||
>
|
||||
View all tokens →
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Platform Selection */}
|
||||
<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">1. Select Target Platform</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{platforms.map((platform) => {
|
||||
const Icon = platform.icon;
|
||||
return (
|
||||
<button
|
||||
key={platform.id}
|
||||
onClick={() => setSelectedPlatform(platform.id)}
|
||||
className={`p-6 border-2 rounded-lg transition-all ${
|
||||
selectedPlatform === platform.id
|
||||
? 'border-blue-500 bg-blue-50'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<Icon className={`w-8 h-8 ${
|
||||
platform.id === 'linux' ? 'text-orange-600' :
|
||||
platform.id === 'windows' ? 'text-blue-600' : 'text-gray-600'
|
||||
}`} />
|
||||
{selectedPlatform === platform.id && (
|
||||
<Check className="w-5 h-5 text-blue-600" />
|
||||
)}
|
||||
</div>
|
||||
<h3 className="font-semibold text-gray-900 mb-2">{platform.name}</h3>
|
||||
<p className="text-sm text-gray-600">{platform.description}</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Installation Methods */}
|
||||
{selectedPlatformData && (
|
||||
<div className="space-y-8">
|
||||
{/* One-Liner Installation */}
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900">2. One-Liner Installation (Recommended)</h2>
|
||||
<p className="text-gray-600 mt-1">
|
||||
Automatically downloads and configures the agent for {selectedPlatformData.name}
|
||||
</p>
|
||||
</div>
|
||||
<Terminal className="w-6 h-6 text-gray-400" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Installation Command {selectedPlatformData.id === 'windows' && <span className="text-blue-600">(Run in PowerShell as Administrator)</span>}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<pre className="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto">
|
||||
<code>{generateInstallCommand(selectedPlatformData)}</code>
|
||||
</pre>
|
||||
<button
|
||||
onClick={() => copyToClipboard(generateInstallCommand(selectedPlatformData), 'one-liner')}
|
||||
className="absolute top-2 right-2 p-2 bg-gray-700 text-white rounded hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
{copiedCommand === 'one-liner' ? (
|
||||
<Check className="w-4 h-4" />
|
||||
) : (
|
||||
<Copy className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="w-5 h-5 text-yellow-600 mt-0.5" />
|
||||
<div>
|
||||
<h4 className="font-medium text-yellow-900">Before Running</h4>
|
||||
<ul className="text-sm text-yellow-700 mt-1 space-y-1">
|
||||
{selectedPlatformData.id === 'windows' ? (
|
||||
<>
|
||||
<li>• Open <strong>PowerShell as Administrator</strong></li>
|
||||
<li>• The script will download and install the agent to <code className="bg-yellow-100 px-1 rounded">%ProgramFiles%\RedFlag</code></li>
|
||||
<li>• A Windows service will be created and started automatically</li>
|
||||
<li>• Script is idempotent - safe to re-run for upgrades</li>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<li>• Run this command as <strong>root</strong> (use sudo)</li>
|
||||
<li>• The script will create a dedicated <code className="bg-yellow-100 px-1 rounded">redflag-agent</code> user</li>
|
||||
<li>• Limited sudo access will be configured via <code className="bg-yellow-100 px-1 rounded">/etc/sudoers.d/redflag-agent</code></li>
|
||||
<li>• Systemd service will be installed and enabled automatically</li>
|
||||
<li>• Script is idempotent - safe to re-run for upgrades</li>
|
||||
</>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Security Information */}
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900">3. Security Information</h2>
|
||||
<p className="text-gray-600 mt-1">
|
||||
Understanding the security model and installation details
|
||||
</p>
|
||||
</div>
|
||||
<Shield className="w-6 h-6 text-gray-400" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 mb-3">🛡️ Security Model</h4>
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
The installation script follows the principle of least privilege by creating a dedicated system user with minimal permissions:
|
||||
</p>
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 bg-blue-600 rounded-full"></div>
|
||||
<span className="text-sm text-blue-800"><strong>System User:</strong> <code className="bg-blue-100 px-1 rounded">redflag-agent</code> with no login shell</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 bg-blue-600 rounded-full"></div>
|
||||
<span className="text-sm text-blue-800"><strong>Sudo Access:</strong> Limited to package management commands only</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 bg-blue-600 rounded-full"></div>
|
||||
<span className="text-sm text-blue-800"><strong>Systemd Service:</strong> Runs with security hardening (ProtectSystem, ProtectHome)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 bg-blue-600 rounded-full"></div>
|
||||
<span className="text-sm text-blue-800"><strong>Configuration:</strong> Secured in <code className="bg-blue-100 px-1 rounded">/etc/aggregator/config.json</code> with restricted permissions</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 mb-3">📁 Installation Files</h4>
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<pre className="text-sm text-gray-700 space-y-1">
|
||||
{`Binary: /usr/local/bin/redflag-agent
|
||||
Config: /etc/aggregator/config.json
|
||||
Service: /etc/systemd/system/redflag-agent.service
|
||||
Sudoers: /etc/sudoers.d/redflag-agent
|
||||
Home Dir: /var/lib/redflag-agent
|
||||
Logs: journalctl -u redflag-agent`}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 mb-3">⚙️ Sudoers Configuration</h4>
|
||||
<p className="text-sm text-gray-600 mb-2">
|
||||
The agent gets sudo access only for these specific commands:
|
||||
</p>
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<pre className="text-xs text-gray-700 overflow-x-auto">
|
||||
{`# APT (Debian/Ubuntu)
|
||||
/usr/bin/apt-get update
|
||||
/usr/bin/apt-get install -y *
|
||||
/usr/bin/apt-get upgrade -y *
|
||||
/usr/bin/apt-get install --dry-run --yes *
|
||||
|
||||
# DNF (RHEL/Fedora/Rocky/Alma)
|
||||
/usr/bin/dnf makecache
|
||||
/usr/bin/dnf install -y *
|
||||
/usr/bin/dnf upgrade -y *
|
||||
/usr/bin/dnf install --assumeno --downloadonly *
|
||||
|
||||
# Docker
|
||||
/usr/bin/docker pull *
|
||||
/usr/bin/docker image inspect *
|
||||
/usr/bin/docker manifest inspect *`}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 mb-3">🔄 Updates and Upgrades</h4>
|
||||
<p className="text-sm text-gray-600">
|
||||
The installation script is <strong>idempotent</strong> - it's safe to run multiple times.
|
||||
RedFlag agents update themselves automatically when new versions are released.
|
||||
If you need to manually reinstall or upgrade, simply run the same one-liner command.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Advanced Configuration */}
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900">4. Advanced Configuration</h2>
|
||||
<p className="text-gray-600 mt-1">
|
||||
Additional agent configuration options
|
||||
</p>
|
||||
</div>
|
||||
<Code className="w-6 h-6 text-gray-400" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Configuration Options */}
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 mb-3">Command Line Options</h4>
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<pre className="text-sm text-gray-700">
|
||||
{`./redflag-agent [options]
|
||||
|
||||
Options:
|
||||
--server <url> Server URL (default: http://localhost:8080)
|
||||
--token <token> Registration token
|
||||
--proxy-http <url> HTTP proxy URL
|
||||
--proxy-https <url> HTTPS proxy URL
|
||||
--log-level <level> Log level (debug, info, warn, error)
|
||||
--organization <name> Organization name
|
||||
--tags <tags> Comma-separated tags
|
||||
--name <display> Display name for the agent
|
||||
--insecure-tls Skip TLS certificate verification`}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Environment Variables */}
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 mb-3">Environment Variables</h4>
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<pre className="text-sm text-gray-700">
|
||||
{`REDFLAG_SERVER_URL="https://your-server.com"
|
||||
REDFLAG_REGISTRATION_TOKEN="your-token-here"
|
||||
REDFLAG_HTTP_PROXY="http://proxy.company.com:8080"
|
||||
REDFLAG_HTTPS_PROXY="https://proxy.company.com:8080"
|
||||
REDFLAG_NO_PROXY="localhost,127.0.0.1"
|
||||
REDFLAG_LOG_LEVEL="info"
|
||||
REDFLAG_ORGANIZATION="IT Department"`}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Configuration File */}
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 mb-3">Configuration File</h4>
|
||||
<p className="text-sm text-gray-600 mb-3">
|
||||
After installation, the agent configuration is stored at <code>/etc/aggregator/config.json</code> (Linux) or
|
||||
<code>%ProgramData%\RedFlag\config.json</code> (Windows):
|
||||
</p>
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<pre className="text-sm text-gray-700 overflow-x-auto">
|
||||
{`{
|
||||
"server_url": "https://your-server.com",
|
||||
"registration_token": "your-token-here",
|
||||
"proxy": {
|
||||
"enabled": true,
|
||||
"http": "http://proxy.company.com:8080",
|
||||
"https": "https://proxy.company.com:8080",
|
||||
"no_proxy": "localhost,127.0.0.1"
|
||||
},
|
||||
"network": {
|
||||
"timeout": "30s",
|
||||
"retry_count": 3,
|
||||
"retry_delay": "5s"
|
||||
},
|
||||
"tls": {
|
||||
"insecure_skip_verify": false
|
||||
},
|
||||
"logging": {
|
||||
"level": "info",
|
||||
"max_size": 100,
|
||||
"max_backups": 3
|
||||
},
|
||||
"tags": ["production", "webserver"],
|
||||
"organization": "IT Department",
|
||||
"display_name": "Web Server 01"
|
||||
}`}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Next Steps */}
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<Check className="w-6 h-6 text-green-600 mt-1" />
|
||||
<div>
|
||||
<h3 className="font-semibold text-green-900 mb-2">Next Steps</h3>
|
||||
<ol className="text-sm text-green-800 space-y-2">
|
||||
<li>1. Deploy agents to your target machines using the methods above</li>
|
||||
<li>2. Monitor agent registration in the <Link to="/agents" className="underline">Agents dashboard</Link></li>
|
||||
<li>3. Configure update policies and scanning schedules</li>
|
||||
<li>4. Review agent status and system information</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AgentManagement;
|
||||
@@ -292,20 +292,24 @@ export interface ApiError {
|
||||
export interface RegistrationToken {
|
||||
id: string;
|
||||
token: string;
|
||||
label: string;
|
||||
expires_at: string | null;
|
||||
max_seats: number | null;
|
||||
current_seats: number;
|
||||
is_active: boolean;
|
||||
label: string | null;
|
||||
expires_at: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
last_used_at: string | null;
|
||||
used_at: string | null;
|
||||
used_by_agent_id: string | null;
|
||||
revoked: boolean;
|
||||
revoked_at: string | null;
|
||||
revoked_reason: string | null;
|
||||
status: 'active' | 'used' | 'expired' | 'revoked';
|
||||
created_by: string;
|
||||
metadata: Record<string, any>;
|
||||
max_seats: number;
|
||||
seats_used: number;
|
||||
}
|
||||
|
||||
export interface CreateRegistrationTokenRequest {
|
||||
label: string;
|
||||
expires_at?: string;
|
||||
expires_in?: string;
|
||||
max_seats?: number;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user