From fe4d299eae9ba00cc4180692abc3ce8fc7198c98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=20Jurmanovi=C4=87?= Date: Thu, 18 Sep 2025 22:04:34 +0200 Subject: [PATCH] code cleanup --- src/components/membership/CreateUserModal.tsx | 2 +- src/components/membership/DeleteUserModal.tsx | 2 +- src/components/server/CreateServerModal.tsx | 44 +- src/components/server/DeleteServerModal.tsx | 4 +- src/components/server/ServerCard.tsx | 85 ++-- src/components/server/ServerCreationPopup.tsx | 163 ++++--- .../server/ServerCreationPopupContainer.tsx | 45 +- .../server/ServerCreationProgressClient.tsx | 432 ++++++++++++++++++ .../server/ServerListWithActions.tsx | 5 +- src/components/ui/RefreshButton.tsx | 5 +- .../websocket/WebSocketInitializer.tsx | 18 +- src/lib/actions/servers.ts | 5 +- src/lib/api/server/base.ts | 3 +- .../context/ServerCreationPopupContext.tsx | 76 +-- src/lib/context/SteamCMDContext.tsx | 98 ++-- src/lib/types/config.ts | 1 - src/lib/types/index.ts | 1 - src/lib/types/user.ts | 2 +- src/lib/websocket/client.ts | 7 +- src/lib/websocket/context.tsx | 5 +- 20 files changed, 753 insertions(+), 250 deletions(-) create mode 100644 src/components/server/ServerCreationProgressClient.tsx diff --git a/src/components/membership/CreateUserModal.tsx b/src/components/membership/CreateUserModal.tsx index 0fc5df3..cbb7123 100644 --- a/src/components/membership/CreateUserModal.tsx +++ b/src/components/membership/CreateUserModal.tsx @@ -32,7 +32,7 @@ export function CreateUserModal({ roles, onClose }: CreateUserModalProps) { const result = await createUserAction(formDataObj); if (result.success) { onClose(); - window.location.reload(); // Refresh to show new user + window.location.reload(); } else { setError(result.message); } diff --git a/src/components/membership/DeleteUserModal.tsx b/src/components/membership/DeleteUserModal.tsx index 8ce8038..edc52ef 100644 --- a/src/components/membership/DeleteUserModal.tsx +++ b/src/components/membership/DeleteUserModal.tsx @@ -25,7 +25,7 @@ export function DeleteUserModal({ user, onClose }: DeleteUserModalProps) { const result = await deleteUserAction(formDataObj); if (result.success) { onClose(); - window.location.reload(); // Refresh to remove deleted user + window.location.reload(); } else { setError(result.message); } diff --git a/src/components/server/CreateServerModal.tsx b/src/components/server/CreateServerModal.tsx index 7d3dd5c..bd1dcbd 100644 --- a/src/components/server/CreateServerModal.tsx +++ b/src/components/server/CreateServerModal.tsx @@ -1,7 +1,6 @@ 'use client'; -import { useState, useEffect } from 'react'; -import { useFormState } from 'react-dom'; +import { useState, useEffect, useActionState, useTransition } from 'react'; import { Modal } from '@/components/ui/Modal'; import { LoadingSpinner } from '@/components/ui/LoadingSpinner'; import { createServerAction, type ServerActionResult } from '@/lib/actions/server-management'; @@ -16,37 +15,40 @@ const initialState: ServerActionResult = { success: false, message: '' }; export function CreateServerModal({ isOpen, onClose }: CreateServerModalProps) { const [serverName, setServerName] = useState(''); + const [submittedName, setSubmittedName] = useState(''); const [isSubmitting, setIsSubmitting] = useState(false); + const [isPending, setTransition] = useTransition(); - const [state, formAction] = useFormState(createServerAction, initialState); + const [state, formAction] = useActionState(createServerAction, initialState); const { showPopup } = useServerCreationPopup(); useEffect(() => { if (state.success && state.data?.id) { - // Server creation started, show popup - showPopup(state.data.id, serverName); + showPopup(state.data.id, submittedName); onClose(); - setServerName(''); setIsSubmitting(false); } - }, [state.success, state.data, showPopup, serverName, onClose]); + }, [state.success, state.data, showPopup, onClose, submittedName]); - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); + const handleSubmit = (e: React.FormEvent) => + setTransition(async () => { + e.preventDefault(); - if (!serverName.trim()) { - return; - } + if (!serverName.trim()) { + return; + } - setIsSubmitting(true); - const formData = new FormData(); - formData.append('name', serverName.trim()); - formAction(formData); - }; + setIsSubmitting(true); + const formData = new FormData(); + formData.append('name', serverName.trim()); + formAction(formData); + setSubmittedName(serverName.trim()); + setServerName(''); + }); const handleClose = () => { if (isSubmitting) { - return; // Prevent closing during submission + return; } onClose(); setServerName(''); @@ -70,7 +72,7 @@ export function CreateServerModal({ isOpen, onClose }: CreateServerModalProps) { value={serverName} onChange={(e) => setServerName(e.target.value)} required - disabled={isSubmitting} + disabled={isSubmitting || isPending} className="mt-1 block w-full rounded-md border border-gray-600 bg-gray-700 px-3 py-2 text-white focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none disabled:opacity-50" placeholder="Enter server name..." /> @@ -80,14 +82,14 @@ export function CreateServerModal({ isOpen, onClose }: CreateServerModalProps) { - + -
- -
+ -
- -
+ diff --git a/src/components/server/ServerCreationPopup.tsx b/src/components/server/ServerCreationPopup.tsx index fdf6909..2a430b1 100644 --- a/src/components/server/ServerCreationPopup.tsx +++ b/src/components/server/ServerCreationPopup.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useRef, useState, useCallback } from 'react'; import { useWebSocket } from '@/lib/websocket/context'; import { WebSocketMessage, @@ -65,19 +65,18 @@ export function ServerCreationPopup({ addMessageHandler, removeMessageHandler, connectionStatus, - connectionError, reconnect } = useWebSocket(); const consoleRef = useRef(null); - const addEntry = (entry: Omit) => { + const addEntry = useCallback((entry: Omit) => { const newEntry = { ...entry, id: `${Date.now()}-${Math.random()}` }; setEntries((prev) => [...prev, newEntry]); - }; + }, []); const scrollToBottom = () => { if (consoleRef.current && !isMinimized && isConsoleVisible) { @@ -95,9 +94,8 @@ export function ServerCreationPopup({ } }, [serverId, isOpen, associateWithServer]); - useEffect(() => { - const handleMessage = (message: WebSocketMessage) => { - // Only handle messages for this server + const handleMessage = useCallback( + (message: WebSocketMessage) => { if (message.server_id !== serverId) return; const timestamp = message.timestamp; @@ -105,14 +103,31 @@ export function ServerCreationPopup({ switch (message.type) { case 'step': { const data = message.data as StepData; - setSteps((prev) => ({ - ...prev, - [data.step]: { + setSteps((prev) => { + const updatedSteps = { ...prev }; + + const stepIndex = STEPS.findIndex((step) => step.key === data.step); + if (stepIndex > 0) { + for (let i = 0; i < stepIndex; i++) { + const prevStepKey = STEPS[i].key; + if (!updatedSteps[prevStepKey] || updatedSteps[prevStepKey].status === 'pending') { + updatedSteps[prevStepKey] = { + step: prevStepKey, + status: 'completed', + message: `${prevStepKey.replace('_', ' ')} completed` + }; + } + } + } + + updatedSteps[data.step] = { step: data.step, status: data.status, message: data.message - } - })); + }; + + return updatedSteps; + }); let level: ConsoleEntry['level'] = 'info'; if (data.status === 'completed') level = 'success'; @@ -130,6 +145,43 @@ export function ServerCreationPopup({ case 'steam_output': { const data = message.data as SteamOutputData; + + setSteps((prev) => { + const updatedSteps = { ...prev }; + + if ( + !updatedSteps['steam_download'] || + updatedSteps['steam_download'].status === 'pending' + ) { + updatedSteps['steam_download'] = { + step: 'steam_download', + status: 'in_progress', + message: 'Steam download in progress' + }; + } + + if (!updatedSteps['validation'] || updatedSteps['validation'].status === 'pending') { + updatedSteps['validation'] = { + step: 'validation', + status: 'completed', + message: 'Validation completed' + }; + } + + if ( + !updatedSteps['directory_creation'] || + updatedSteps['directory_creation'].status === 'pending' + ) { + updatedSteps['directory_creation'] = { + step: 'directory_creation', + status: 'completed', + message: 'Directory creation completed' + }; + } + + return updatedSteps; + }); + addEntry({ timestamp, type: 'steam_output', @@ -141,6 +193,22 @@ export function ServerCreationPopup({ case 'error': { const data = message.data as ErrorData; + + setSteps((prev) => { + const updatedSteps = { ...prev }; + const currentStep = Object.values(updatedSteps).find( + (step) => step.status === 'in_progress' + ); + if (currentStep) { + updatedSteps[currentStep.step] = { + ...currentStep, + status: 'failed', + message: `${currentStep.step.replace('_', ' ')} failed: ${data.error}` + }; + } + return updatedSteps; + }); + addEntry({ timestamp, type: 'error', @@ -166,15 +234,18 @@ export function ServerCreationPopup({ break; } } - }; + }, + [serverId, addEntry, onComplete] + ); + useEffect(() => { if (isOpen) { addMessageHandler(handleMessage); return () => { removeMessageHandler(handleMessage); }; } - }, [addMessageHandler, removeMessageHandler, serverId, isOpen, onComplete]); + }, [addMessageHandler, removeMessageHandler, handleMessage, isOpen]); const handleReconnect = async () => { try { @@ -210,19 +281,6 @@ export function ServerCreationPopup({ } }; - const getConnectionStatusColor = () => { - switch (connectionStatus) { - case 'connected': - return 'text-green-400'; - case 'connecting': - return 'text-yellow-400'; - case 'disconnected': - return 'text-gray-400'; - case 'error': - return 'text-red-400'; - } - }; - const getConnectionStatusIcon = () => { switch (connectionStatus) { case 'connected': @@ -246,14 +304,13 @@ export function ServerCreationPopup({ if (!isOpen) return null; - // Minimized state - circular icon in bottom corner if (isMinimized) { const progress = getCurrentProgress(); const isProgressing = !isCompleted && Object.values(steps).some((step) => step.status === 'in_progress'); return ( -
+
- {/* Progress ring */} {!isCompleted && ( - {/* Header */} +
🔧 @@ -318,7 +372,6 @@ export function ServerCreationPopup({
- {/* Connection Status */}
{getConnectionStatusIcon()} {(connectionStatus === 'disconnected' || connectionStatus === 'error') && ( @@ -332,16 +385,14 @@ export function ServerCreationPopup({ )}
- {/* Console Toggle */} - {/* Minimize */} - {/* Close */} - {isCompleted && ( - - )} +
- {/* Connection Error Banner */} - {(connectionStatus === 'disconnected' || connectionStatus === 'error') && ( -
-
- Connection lost - {connectionError || 'Reconnecting...'} - -
-
- )} - - {/* Steps Progress */}
{STEPS.map(({ key, label }) => { @@ -406,7 +441,6 @@ export function ServerCreationPopup({
- {/* Console Output */} {isConsoleVisible && (
@@ -427,7 +461,6 @@ export function ServerCreationPopup({
)} - {/* Completion Status */} {isCompleted && (
{ + hidePopup(); + if (popup) return dissociateServer(popup.serverId); + }, [popup, dissociateServer, hidePopup]); + if (!popup) return null; - if (!popup) return null; + const handleComplete = (success: boolean) => { + if (success) { + setTimeout(() => { + window.location.reload(); + }, 2000); + } + }; - const handleComplete = (success: boolean, message: string) => { - // Refresh the page on successful completion to show the new server - if (success) { - setTimeout(() => { - window.location.reload(); - }, 2000); // Wait 2 seconds to let user see the success message - } - }; - - return ( - - ); + return ( + + ); } diff --git a/src/components/server/ServerCreationProgressClient.tsx b/src/components/server/ServerCreationProgressClient.tsx new file mode 100644 index 0000000..6ec2116 --- /dev/null +++ b/src/components/server/ServerCreationProgressClient.tsx @@ -0,0 +1,432 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import Link from 'next/link'; +import { useWebSocket } from '@/lib/websocket/context'; +import { + WebSocketMessage, + StepData, + SteamOutputData, + ErrorData, + CompleteData +} from '@/lib/websocket/client'; + +interface ServerCreationProgressClientProps { + serverId: string; +} + +interface ConsoleEntry { + id: string; + timestamp: number; + type: 'step' | 'steam_output' | 'error' | 'complete'; + content: string; + level: 'info' | 'success' | 'warning' | 'error'; +} + +interface StepStatus { + step: string; + status: 'pending' | 'in_progress' | 'completed' | 'failed'; + message: string; +} + +const STEPS = [ + { key: 'validation', label: 'Validation' }, + { key: 'directory_creation', label: 'Directory Creation' }, + { key: 'steam_download', label: 'Steam Download' }, + { key: 'config_generation', label: 'Config Generation' }, + { key: 'service_creation', label: 'Service Creation' }, + { key: 'firewall_rules', label: 'Firewall Rules' }, + { key: 'database_save', label: 'Database Save' }, + { key: 'completed', label: 'Completed' } +]; + +export function ServerCreationProgressClient({ serverId }: ServerCreationProgressClientProps) { + const [entries, setEntries] = useState([]); + const [steps, setSteps] = useState>({}); + const [isCompleted, setIsCompleted] = useState(false); + const [completionResult, setCompletionResult] = useState<{ + success: boolean; + message: string; + } | null>(null); + const [isMinimized, setIsMinimized] = useState(false); + + const { + associateWithServer, + addMessageHandler, + removeMessageHandler, + connectionStatus, + connectionError, + reconnect + } = useWebSocket(); + const router = useRouter(); + const consoleRef = useRef(null); + + const addEntry = (entry: Omit) => { + const newEntry = { + ...entry, + id: `${Date.now()}-${Math.random()}` + }; + setEntries((prev) => [...prev, newEntry]); + }; + + const scrollToBottom = () => { + if (consoleRef.current && !isMinimized) { + consoleRef.current.scrollTop = consoleRef.current.scrollHeight; + } + }; + + useEffect(() => { + scrollToBottom(); + }, [entries, isMinimized]); + + useEffect(() => { + if (serverId) { + associateWithServer(serverId); + } + }, [serverId, associateWithServer]); + + useEffect(() => { + const handleMessage = (message: WebSocketMessage) => { + if (message.server_id !== serverId) return; + + const timestamp = message.timestamp; + + switch (message.type) { + case 'step': { + const data = message.data as StepData; + setSteps((prev) => ({ + ...prev, + [data.step]: { + step: data.step, + status: data.status, + message: data.message + } + })); + + let level: ConsoleEntry['level'] = 'info'; + if (data.status === 'completed') level = 'success'; + else if (data.status === 'failed') level = 'error'; + else if (data.status === 'in_progress') level = 'warning'; + + addEntry({ + timestamp, + type: 'step', + content: `[${data.step.toUpperCase()}] ${data.message}${data.error ? ` - ${data.error}` : ''}`, + level + }); + break; + } + + case 'steam_output': { + const data = message.data as SteamOutputData; + addEntry({ + timestamp, + type: 'steam_output', + content: data.output, + level: data.is_error ? 'error' : 'info' + }); + break; + } + + case 'error': { + const data = message.data as ErrorData; + addEntry({ + timestamp, + type: 'error', + content: `ERROR: ${data.error}${data.details ? ` - ${data.details}` : ''}`, + level: 'error' + }); + break; + } + + case 'complete': { + const data = message.data as CompleteData; + setIsCompleted(true); + setCompletionResult({ success: data.success, message: data.message }); + + addEntry({ + timestamp, + type: 'complete', + content: `COMPLETED: ${data.message}`, + level: data.success ? 'success' : 'error' + }); + break; + } + } + }; + + addMessageHandler(handleMessage); + return () => { + removeMessageHandler(handleMessage); + }; + }, [addMessageHandler, removeMessageHandler, serverId]); + + const handleReturnToDashboard = () => { + router.push('/dashboard'); + }; + + const handleReconnect = async () => { + try { + await reconnect(); + } catch (error) { + console.error('Failed to reconnect:', error); + } + }; + + const getStepStatusIcon = (status: StepStatus['status']) => { + switch (status) { + case 'pending': + return '⏳'; + case 'in_progress': + return '🔄'; + case 'completed': + return '✅'; + case 'failed': + return '❌'; + } + }; + + const getEntryClassName = (level: ConsoleEntry['level']) => { + switch (level) { + case 'success': + return 'text-green-400'; + case 'warning': + return 'text-yellow-400'; + case 'error': + return 'text-red-400'; + default: + return 'text-gray-300'; + } + }; + + const getConnectionStatusColor = () => { + switch (connectionStatus) { + case 'connected': + return 'text-green-400'; + case 'connecting': + return 'text-yellow-400'; + case 'disconnected': + return 'text-gray-400'; + case 'error': + return 'text-red-400'; + } + }; + + const getConnectionStatusIcon = () => { + switch (connectionStatus) { + case 'connected': + return '🟢'; + case 'connecting': + return '🟡'; + case 'disconnected': + return '⚫'; + case 'error': + return '🔴'; + } + }; + + return ( +
+ {/* Header */} +
+
+
+ + + + + +

Server Creation Progress

+
+
+ {/* Connection Status */} +
+ {getConnectionStatusIcon()} + + {connectionStatus === 'connected' && 'Connected'} + {connectionStatus === 'connecting' && 'Connecting...'} + {connectionStatus === 'disconnected' && 'Disconnected'} + {connectionStatus === 'error' && 'Connection Error'} + + {(connectionStatus === 'disconnected' || connectionStatus === 'error') && ( + + )} +
+ + + {isCompleted && ( + + )} +
+
+
+ + {/* Connection Error Banner */} + {(connectionStatus === 'disconnected' || connectionStatus === 'error') && ( +
+
+
+
+ + + +
+
+

+ WebSocket Connection Lost - + {connectionError + ? ` ${connectionError}` + : ' Unable to receive real-time updates.'}{' '} + Progress may not be current. +

+
+
+ +
+
+ )} + +
+
+ {/* Steps Progress */} +
+

Progress Steps

+
+ {STEPS.map(({ key, label }) => { + const stepStatus = steps[key]; + return ( +
+ + {getStepStatusIcon(stepStatus?.status || 'pending')} + + {label} +
+ ); + })} +
+
+ + {/* Console Output */} + {!isMinimized && ( +
+
+

Console Output

+
{entries.length} log entries
+
+
+
+ {entries.map((entry) => ( +
+ + {new Date(entry.timestamp * 1000).toLocaleTimeString()} + {' '} + {entry.content} +
+ ))} + {entries.length === 0 && ( +
+ Waiting for server creation to begin... +
+ )} +
+
+
+ )} + + {isMinimized && ( +
+
+ Console minimized - click the expand button in the header to view output +
+
+ )} +
+
+
+ ); +} diff --git a/src/components/server/ServerListWithActions.tsx b/src/components/server/ServerListWithActions.tsx index d32e907..4c7cf67 100644 --- a/src/components/server/ServerListWithActions.tsx +++ b/src/components/server/ServerListWithActions.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState } from 'react'; +import { useCallback, useState } from 'react'; import { Server } from '@/lib/types/server'; import { User, hasPermission } from '@/lib/types/user'; import { ServerCard } from './ServerCard'; @@ -17,6 +17,7 @@ export function ServerListWithActions({ servers, user }: ServerListWithActionsPr const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); const { isSteamCMDRunning } = useSteamCMD(); + const handleOnClose = useCallback(() => setIsCreateModalOpen(false), []); const canCreateServer = hasPermission(user, 'server.create'); return ( @@ -44,7 +45,7 @@ export function ServerListWithActions({ servers, user }: ServerListWithActionsPr ))}
- setIsCreateModalOpen(false)} /> + ); } diff --git a/src/components/ui/RefreshButton.tsx b/src/components/ui/RefreshButton.tsx index 5d0b153..7fb329c 100644 --- a/src/components/ui/RefreshButton.tsx +++ b/src/components/ui/RefreshButton.tsx @@ -1,9 +1,12 @@ 'use client'; +import { useRouter } from 'next/navigation'; + export default function RefreshButton() { + const router = useRouter(); return (