testing: web-based server setup with automatic restart
- Add React setup form matching our design system - Implement automatic server restart after configuration - Add WelcomeChecker component for proper routing - Update API to handle setup endpoints and restart logic - Improve setup workflow with proper loading states and redirects Testing complete setup workflow from welcome mode to login.
This commit is contained in:
@@ -142,7 +142,6 @@ func main() {
|
||||
registrationTokenHandler := handlers.NewRegistrationTokenHandler(registrationTokenQueries, agentQueries, cfg)
|
||||
rateLimitHandler := handlers.NewRateLimitHandler(rateLimiter)
|
||||
downloadHandler := handlers.NewDownloadHandler(filepath.Join(".", "redflag-agent"))
|
||||
setupHandler := handlers.NewSetupHandler("/app/config")
|
||||
|
||||
// Setup router
|
||||
router := gin.Default()
|
||||
|
||||
@@ -6,10 +6,11 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/Fimeg/RedFlag/aggregator-server/internal/config"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
@@ -300,9 +301,38 @@ LATEST_AGENT_VERSION=0.1.16`,
|
||||
return
|
||||
}
|
||||
|
||||
// Trigger graceful server restart after configuration
|
||||
go func() {
|
||||
time.Sleep(2 * time.Second) // Give response time to reach client
|
||||
|
||||
// Get the current executable path
|
||||
execPath, err := os.Executable()
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to get executable path: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Restart the server with the same executable
|
||||
cmd := exec.Command(execPath)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Stdin = os.Stdin
|
||||
|
||||
// Start the new process
|
||||
if err := cmd.Start(); err != nil {
|
||||
fmt.Printf("Failed to start new server process: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Exit the current process gracefully
|
||||
fmt.Printf("Server restarting... PID: %d\n", cmd.Process.Pid)
|
||||
os.Exit(0)
|
||||
}()
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "Configuration saved successfully! Server will restart automatically.",
|
||||
"configPath": envPath,
|
||||
"restart": true,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@ import Settings from '@/pages/Settings';
|
||||
import TokenManagement from '@/pages/TokenManagement';
|
||||
import RateLimiting from '@/pages/RateLimiting';
|
||||
import Login from '@/pages/Login';
|
||||
import Setup from '@/pages/Setup';
|
||||
import { WelcomeChecker } from '@/components/WelcomeChecker';
|
||||
|
||||
// Protected route component
|
||||
const ProtectedRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
@@ -77,6 +79,9 @@ const App: React.FC = () => {
|
||||
|
||||
{/* App routes */}
|
||||
<Routes>
|
||||
{/* Setup route - shown when server needs configuration */}
|
||||
<Route path="/setup" element={<Setup />} />
|
||||
|
||||
{/* Login route */}
|
||||
<Route
|
||||
path="/login"
|
||||
@@ -87,6 +92,7 @@ const App: React.FC = () => {
|
||||
<Route
|
||||
path="/*"
|
||||
element={
|
||||
<WelcomeChecker>
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<Routes>
|
||||
@@ -106,6 +112,7 @@ const App: React.FC = () => {
|
||||
</Routes>
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
</WelcomeChecker>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
|
||||
55
aggregator-web/src/components/WelcomeChecker.tsx
Normal file
55
aggregator-web/src/components/WelcomeChecker.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { setupApi } from '@/lib/api';
|
||||
|
||||
interface WelcomeCheckerProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const WelcomeChecker: React.FC<WelcomeCheckerProps> = ({ children }) => {
|
||||
const [isWelcomeMode, setIsWelcomeMode] = useState<boolean | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const checkWelcomeMode = async () => {
|
||||
try {
|
||||
const data = await setupApi.checkHealth();
|
||||
|
||||
if (data.status === 'waiting for configuration') {
|
||||
setIsWelcomeMode(true);
|
||||
} else {
|
||||
setIsWelcomeMode(false);
|
||||
}
|
||||
} catch (error) {
|
||||
// If we can't reach the health endpoint, assume normal mode
|
||||
setIsWelcomeMode(false);
|
||||
}
|
||||
};
|
||||
|
||||
checkWelcomeMode();
|
||||
|
||||
// Check periodically for configuration changes
|
||||
const interval = setInterval(checkWelcomeMode, 5000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
if (isWelcomeMode === null) {
|
||||
// Loading state
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600 mx-auto mb-4"></div>
|
||||
<p className="text-gray-600">Checking server status...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isWelcomeMode) {
|
||||
// Redirect to setup page
|
||||
return <Navigate to="/setup" replace />;
|
||||
}
|
||||
|
||||
// Normal mode - render children
|
||||
return <>{children}</>;
|
||||
};
|
||||
@@ -255,6 +255,40 @@ export const authApi = {
|
||||
},
|
||||
};
|
||||
|
||||
// Setup API for server configuration (uses base API without auth)
|
||||
const setupApiInstance = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
export const setupApi = {
|
||||
// Check server health and status
|
||||
checkHealth: async (): Promise<{ status: string }> => {
|
||||
const response = await setupApiInstance.get('/health');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Submit server configuration
|
||||
configure: async (config: {
|
||||
adminUser: string;
|
||||
adminPassword: string;
|
||||
dbHost: string;
|
||||
dbPort: string;
|
||||
dbName: string;
|
||||
dbUser: string;
|
||||
dbPassword: string;
|
||||
serverHost: string;
|
||||
serverPort: string;
|
||||
maxSeats: string;
|
||||
}): Promise<{ message: string; configPath?: string; restart?: boolean }> => {
|
||||
const response = await setupApiInstance.post('/setup', config);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
// Utility functions
|
||||
export const createQueryString = (params: Record<string, any>): string => {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
349
aggregator-web/src/pages/Setup.tsx
Normal file
349
aggregator-web/src/pages/Setup.tsx
Normal file
@@ -0,0 +1,349 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { XCircle } from 'lucide-react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { setupApi } from '@/lib/api';
|
||||
|
||||
interface SetupFormData {
|
||||
adminUser: string;
|
||||
adminPassword: string;
|
||||
dbHost: string;
|
||||
dbPort: string;
|
||||
dbName: string;
|
||||
dbUser: string;
|
||||
dbPassword: string;
|
||||
serverHost: string;
|
||||
serverPort: string;
|
||||
maxSeats: string;
|
||||
}
|
||||
|
||||
|
||||
const Setup: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [formData, setFormData] = useState<SetupFormData>({
|
||||
adminUser: 'admin',
|
||||
adminPassword: '',
|
||||
dbHost: 'postgres',
|
||||
dbPort: '5432',
|
||||
dbName: 'redflag',
|
||||
dbUser: 'redflag',
|
||||
dbPassword: 'redflag',
|
||||
serverHost: '0.0.0.0',
|
||||
serverPort: '8080',
|
||||
maxSeats: '50',
|
||||
});
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
};
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
if (!formData.adminUser.trim()) {
|
||||
setError('Admin username is required');
|
||||
return false;
|
||||
}
|
||||
if (!formData.adminPassword.trim()) {
|
||||
setError('Admin password is required');
|
||||
return false;
|
||||
}
|
||||
if (!formData.dbHost.trim()) {
|
||||
setError('Database host is required');
|
||||
return false;
|
||||
}
|
||||
if (!formData.dbPort.trim()) {
|
||||
setError('Database port is required');
|
||||
return false;
|
||||
}
|
||||
const dbPort = parseInt(formData.dbPort);
|
||||
if (isNaN(dbPort) || dbPort <= 0 || dbPort > 65535) {
|
||||
setError('Database port must be between 1 and 65535');
|
||||
return false;
|
||||
}
|
||||
if (!formData.dbName.trim()) {
|
||||
setError('Database name is required');
|
||||
return false;
|
||||
}
|
||||
if (!formData.dbUser.trim()) {
|
||||
setError('Database user is required');
|
||||
return false;
|
||||
}
|
||||
if (!formData.dbPassword.trim()) {
|
||||
setError('Database password is required');
|
||||
return false;
|
||||
}
|
||||
if (!formData.serverPort.trim()) {
|
||||
setError('Server port is required');
|
||||
return false;
|
||||
}
|
||||
const serverPort = parseInt(formData.serverPort);
|
||||
if (isNaN(serverPort) || serverPort <= 0 || serverPort > 65535) {
|
||||
setError('Server port must be between 1 and 65535');
|
||||
return false;
|
||||
}
|
||||
const maxSeats = parseInt(formData.maxSeats);
|
||||
if (isNaN(maxSeats) || maxSeats <= 0) {
|
||||
setError('Maximum agent seats must be greater than 0');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const result = await setupApi.configure(formData);
|
||||
|
||||
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';
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
|
||||
<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" />
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-red-800">{error}</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Admin Account */}
|
||||
<div className="px-6 py-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">Admin Account</h3>
|
||||
<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">
|
||||
Admin Username
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="adminUser"
|
||||
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"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="adminPassword" className="block text-sm font-medium text-gray-700">
|
||||
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>
|
||||
</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 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">
|
||||
Database Host
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="dbHost"
|
||||
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"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="dbPort" className="block text-sm font-medium text-gray-700">
|
||||
Database Port
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="dbPort"
|
||||
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"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="dbName" className="block text-sm font-medium text-gray-700">
|
||||
Database Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="dbName"
|
||||
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"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="dbUser" className="block text-sm font-medium text-gray-700">
|
||||
Database User
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="dbUser"
|
||||
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"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="dbPassword" className="block text-sm font-medium text-gray-700">
|
||||
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>
|
||||
</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 className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label htmlFor="serverHost" className="block text-sm font-medium text-gray-700">
|
||||
Server Host
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="serverHost"
|
||||
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"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="serverPort" className="block text-sm font-medium text-gray-700">
|
||||
Server Port
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="serverPort"
|
||||
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"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="maxSeats" className="block text-sm font-medium text-gray-700">
|
||||
Maximum Agent Seats
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="maxSeats"
|
||||
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"
|
||||
min="1"
|
||||
max="1000"
|
||||
required
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">Security limit for agent registration</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<div className="px-6 py-4 bg-gray-50">
|
||||
<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"
|
||||
>
|
||||
{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...
|
||||
</div>
|
||||
) : (
|
||||
'Configure Server'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Setup;
|
||||
@@ -16,6 +16,10 @@ export default defineConfig({
|
||||
'/api': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/health': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user