diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 5810e49..e11d615 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -5,6 +5,9 @@ import { ServerCard } from '@/components/server/ServerCard'; import { logoutAction } from '@/lib/actions/auth'; import RefreshButton from '@/components/ui/RefreshButton'; import Link from 'next/link'; +import { ServerListWithActions } from '@/components/server/ServerListWithActions'; +import { WebSocketInitializer } from '@/components/websocket/WebSocketInitializer'; +import { SteamCMDNotification } from '@/components/ui/SteamCMDNotification'; export default async function DashboardPage() { const session = await requireAuth(); @@ -12,6 +15,7 @@ export default async function DashboardPage() { return (
+

ACC Server Manager

@@ -61,17 +65,10 @@ export default async function DashboardPage() {
-
-
-

Your Servers

- -
+ -
- {servers.map((server) => ( - - ))} -
+
+
); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 7320964..52a02b6 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,6 +1,10 @@ import type { Metadata } from 'next'; import './globals.css'; import { QueryProvider } from '@/components/providers/QueryProvider'; +import { WebSocketProvider } from '@/lib/websocket/context'; +import { SteamCMDProvider } from '@/lib/context/SteamCMDContext'; +import { ServerCreationPopupProvider } from '@/lib/context/ServerCreationPopupContext'; +import { ServerCreationPopupContainer } from '@/components/server/ServerCreationPopupContainer'; export const metadata: Metadata = { title: 'ACC Server Manager', @@ -15,7 +19,16 @@ export default function RootLayout({ return ( - {children} + + + + + {children} + + + + + ); diff --git a/src/components/server/CreateServerModal.tsx b/src/components/server/CreateServerModal.tsx new file mode 100644 index 0000000..7d3dd5c --- /dev/null +++ b/src/components/server/CreateServerModal.tsx @@ -0,0 +1,106 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { useFormState } from 'react-dom'; +import { Modal } from '@/components/ui/Modal'; +import { LoadingSpinner } from '@/components/ui/LoadingSpinner'; +import { createServerAction, type ServerActionResult } from '@/lib/actions/server-management'; +import { useServerCreationPopup } from '@/lib/context/ServerCreationPopupContext'; + +interface CreateServerModalProps { + isOpen: boolean; + onClose: () => void; +} + +const initialState: ServerActionResult = { success: false, message: '' }; + +export function CreateServerModal({ isOpen, onClose }: CreateServerModalProps) { + const [serverName, setServerName] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + + const [state, formAction] = useFormState(createServerAction, initialState); + const { showPopup } = useServerCreationPopup(); + + useEffect(() => { + if (state.success && state.data?.id) { + // Server creation started, show popup + showPopup(state.data.id, serverName); + onClose(); + setServerName(''); + setIsSubmitting(false); + } + }, [state.success, state.data, showPopup, serverName, onClose]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!serverName.trim()) { + return; + } + + setIsSubmitting(true); + const formData = new FormData(); + formData.append('name', serverName.trim()); + formAction(formData); + }; + + const handleClose = () => { + if (isSubmitting) { + return; // Prevent closing during submission + } + onClose(); + setServerName(''); + setIsSubmitting(false); + }; + + return ( + + {!state.success && state.message && ( +
{state.message}
+ )} + +
+
+ + setServerName(e.target.value)} + required + disabled={isSubmitting} + 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..." + /> +
+ +
+ + +
+
+
+ ); +} diff --git a/src/components/server/DeleteServerModal.tsx b/src/components/server/DeleteServerModal.tsx new file mode 100644 index 0000000..5a351ee --- /dev/null +++ b/src/components/server/DeleteServerModal.tsx @@ -0,0 +1,79 @@ +'use client'; + +import { useState, useTransition } from 'react'; +import { Modal } from '@/components/ui/Modal'; +import { LoadingSpinner } from '@/components/ui/LoadingSpinner'; +import { deleteServerAction } from '@/lib/actions/server-management'; +import { Server } from '@/lib/types/server'; + +interface DeleteServerModalProps { + isOpen: boolean; + onClose: () => void; + server: Server; +} + +export function DeleteServerModal({ isOpen, onClose, server }: DeleteServerModalProps) { + const [error, setError] = useState(null); + const [isPending, startTransition] = useTransition(); + + const handleDelete = () => { + setError(null); + startTransition(async () => { + try { + const result = await deleteServerAction(server.id); + if (result.success) { + onClose(); + } else { + setError(result.message); + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to delete server'); + } + }); + }; + + const handleClose = () => { + if (isPending) { + return; // Prevent closing during deletion + } + onClose(); + setError(null); + }; + + return ( + + {error &&
{error}
} + +
+

+ Are you sure you want to delete the server "{server.name}"? +

+

This action cannot be undone.

+
+ +
+ + +
+
+ ); +} diff --git a/src/components/server/ServerCard.tsx b/src/components/server/ServerCard.tsx index d8d82c9..d05e1ab 100644 --- a/src/components/server/ServerCard.tsx +++ b/src/components/server/ServerCard.tsx @@ -1,88 +1,136 @@ +'use client'; + import Link from 'next/link'; +import { useState } from 'react'; import { Server, ServiceStatus, getStatusColor, serviceStatusToString } from '@/lib/types'; +import { User, hasPermission } from '@/lib/types/user'; import { startServerEventAction, restartServerEventAction, stopServerEventAction } from '@/lib/actions/servers'; +import { DeleteServerModal } from './DeleteServerModal'; +import { useSteamCMD } from '@/lib/context/SteamCMDContext'; interface ServerCardProps { server: Server; + user: User; } -export function ServerCard({ server }: ServerCardProps) { +export function ServerCard({ server, user }: ServerCardProps) { + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const { isSteamCMDRunning } = useSteamCMD(); + + const canDeleteServer = hasPermission(user, 'server.delete'); + return ( -
- -
-
-
-

{server.name}

-
- - - {serviceStatusToString(server.status)} - + <> +
+ +
+
+
+

{server.name}

+
+ + + {serviceStatusToString(server.status)} + +
+
+
+ {canDeleteServer && ( + + )} +
+ + + +
-
- - - -
-
-
-
- Track: - {server.state?.track || 'N/A'} -
-
- Players: - {server.state?.playerCount || 0} +
+
+ Track: + {server.state?.track || 'N/A'} +
+
+ Players: + {server.state?.playerCount || 0} +
+ + +
+
+ +
+ +
+ +
+ +
+ +
- - -
-
- -
- -
- -
- -
- -
-
+ + setIsDeleteModalOpen(false)} + server={server} + /> + ); } diff --git a/src/components/server/ServerCreationPopup.tsx b/src/components/server/ServerCreationPopup.tsx new file mode 100644 index 0000000..fdf6909 --- /dev/null +++ b/src/components/server/ServerCreationPopup.tsx @@ -0,0 +1,444 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; +import { useWebSocket } from '@/lib/websocket/context'; +import { + WebSocketMessage, + StepData, + SteamOutputData, + ErrorData, + CompleteData +} from '@/lib/websocket/client'; + +interface ServerCreationPopupProps { + serverId: string; + serverName: string; + isOpen: boolean; + onClose: () => void; + onComplete?: (success: boolean, message: string) => void; +} + +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 ServerCreationPopup({ + serverId, + serverName, + isOpen, + onClose, + onComplete +}: ServerCreationPopupProps) { + 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 [isConsoleVisible, setIsConsoleVisible] = useState(true); + + const { + associateWithServer, + addMessageHandler, + removeMessageHandler, + connectionStatus, + connectionError, + reconnect + } = useWebSocket(); + + 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 && isConsoleVisible) { + consoleRef.current.scrollTop = consoleRef.current.scrollHeight; + } + }; + + useEffect(() => { + scrollToBottom(); + }, [entries, isMinimized, isConsoleVisible]); + + useEffect(() => { + if (serverId && isOpen) { + associateWithServer(serverId); + } + }, [serverId, isOpen, associateWithServer]); + + useEffect(() => { + const handleMessage = (message: WebSocketMessage) => { + // Only handle messages for this server + 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' + }); + + onComplete?.(data.success, data.message); + break; + } + } + }; + + if (isOpen) { + addMessageHandler(handleMessage); + return () => { + removeMessageHandler(handleMessage); + }; + } + }, [addMessageHandler, removeMessageHandler, serverId, isOpen, onComplete]); + + 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 '🔴'; + } + }; + + const getCurrentProgress = () => { + const completedSteps = Object.values(steps).filter( + (step) => step.status === 'completed' + ).length; + const totalSteps = STEPS.length; + return { completed: completedSteps, total: totalSteps }; + }; + + 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 ( +
+ +
+ ); + } + + // Expanded popup state + return ( +
+ {/* Header */} +
+
+ 🔧 +

{serverName}

+
+ +
+ {/* Connection Status */} +
+ {getConnectionStatusIcon()} + {(connectionStatus === 'disconnected' || connectionStatus === 'error') && ( + + )} +
+ + {/* Console Toggle */} + + + {/* Minimize */} + + + {/* Close */} + {isCompleted && ( + + )} +
+
+ + {/* Connection Error Banner */} + {(connectionStatus === 'disconnected' || connectionStatus === 'error') && ( +
+
+ Connection lost - {connectionError || 'Reconnecting...'} + +
+
+ )} + + {/* Steps Progress */} +
+
+ {STEPS.map(({ key, label }) => { + const stepStatus = steps[key]; + return ( +
+ {getStepStatusIcon(stepStatus?.status || 'pending')} + {label} +
+ ); + })} +
+
+ + {/* Console Output */} + {isConsoleVisible && ( +
+
+ {entries.map((entry) => ( +
+ + {new Date(entry.timestamp * 1000).toLocaleTimeString()} + {' '} + {entry.content} +
+ ))} + {entries.length === 0 && ( +
+ Waiting for server creation to begin... +
+ )} +
+
+ )} + + {/* Completion Status */} + {isCompleted && ( +
+ {completionResult?.message} +
+ )} +
+ ); +} diff --git a/src/components/server/ServerCreationPopupContainer.tsx b/src/components/server/ServerCreationPopupContainer.tsx new file mode 100644 index 0000000..0ea26fb --- /dev/null +++ b/src/components/server/ServerCreationPopupContainer.tsx @@ -0,0 +1,29 @@ +'use client'; + +import { useServerCreationPopup } from '@/lib/context/ServerCreationPopupContext'; +import { ServerCreationPopup } from './ServerCreationPopup'; + +export function ServerCreationPopupContainer() { + const { popup, hidePopup } = useServerCreationPopup(); + + if (!popup) return null; + + 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 ( + + ); +} diff --git a/src/components/server/ServerListWithActions.tsx b/src/components/server/ServerListWithActions.tsx new file mode 100644 index 0000000..d32e907 --- /dev/null +++ b/src/components/server/ServerListWithActions.tsx @@ -0,0 +1,50 @@ +'use client'; + +import { useState } from 'react'; +import { Server } from '@/lib/types/server'; +import { User, hasPermission } from '@/lib/types/user'; +import { ServerCard } from './ServerCard'; +import { CreateServerModal } from './CreateServerModal'; +import RefreshButton from '@/components/ui/RefreshButton'; +import { useSteamCMD } from '@/lib/context/SteamCMDContext'; + +interface ServerListWithActionsProps { + servers: Server[]; + user: User; +} + +export function ServerListWithActions({ servers, user }: ServerListWithActionsProps) { + const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); + const { isSteamCMDRunning } = useSteamCMD(); + + const canCreateServer = hasPermission(user, 'server.create'); + + return ( + <> +
+

Your Servers

+
+ {canCreateServer && ( + + )} + +
+
+ +
+ {servers.map((server) => ( + + ))} +
+ + setIsCreateModalOpen(false)} /> + + ); +} diff --git a/src/components/ui/SteamCMDNotification.tsx b/src/components/ui/SteamCMDNotification.tsx new file mode 100644 index 0000000..1dfddc4 --- /dev/null +++ b/src/components/ui/SteamCMDNotification.tsx @@ -0,0 +1,46 @@ +'use client'; + +import { useSteamCMD } from '@/lib/context/SteamCMDContext'; + +export function SteamCMDNotification() { + const { isSteamCMDRunning, runningSteamServers } = useSteamCMD(); + + if (!isSteamCMDRunning) { + return null; + } + + const serverCount = runningSteamServers.size; + + return ( +
+
+
+ + + +
+
+

+ SteamCMD is currently running for {serverCount} server{serverCount !== 1 ? 's' : ''}. + Server actions are temporarily disabled to prevent conflicts. + This will automatically resolve when the download completes. +

+
+
+
+
+ Downloading... +
+
+
+
+ ); +} diff --git a/src/components/websocket/WebSocketInitializer.tsx b/src/components/websocket/WebSocketInitializer.tsx new file mode 100644 index 0000000..91e94ac --- /dev/null +++ b/src/components/websocket/WebSocketInitializer.tsx @@ -0,0 +1,27 @@ +'use client'; + +import { useEffect } from 'react'; +import { useWebSocket } from '@/lib/websocket/context'; + +interface WebSocketInitializerProps { + openToken?: string; +} + +export function WebSocketInitializer({ openToken }: WebSocketInitializerProps) { + const { connect, disconnect, isConnected } = useWebSocket(); + + useEffect(() => { + console.log({ openToken, connect, disconnect, isConnected }); + if (openToken && !isConnected) { + connect(openToken).catch((error) => { + console.error('Failed to connect WebSocket:', error); + }); + } + + return () => { + disconnect(); + }; + }, [openToken, connect, disconnect, isConnected]); + + return null; // This component doesn't render anything +} diff --git a/src/lib/actions/auth.ts b/src/lib/actions/auth.ts index 5806b49..b6509a7 100644 --- a/src/lib/actions/auth.ts +++ b/src/lib/actions/auth.ts @@ -1,8 +1,8 @@ 'use server'; -import { redirect, RedirectType } from 'next/navigation'; -import { loginUser } from '@/lib/api/server/auth'; -import { login, logout } from '@/lib/auth/server'; +import { redirect } from 'next/navigation'; +import { loginUser, getOpenToken } from '@/lib/api/server/auth'; +import { login } from '@/lib/auth/server'; export type LoginResult = { success: boolean; @@ -24,7 +24,8 @@ export async function loginAction(prevState: LoginResult, formData: FormData) { const result = await loginUser(username, password); if (result.token && result.user) { - await login(result.token, result.user); + const openToken = await getOpenToken(result.token); + await login(result.token, result.user, openToken); } else { return { success: false, diff --git a/src/lib/actions/server-management.ts b/src/lib/actions/server-management.ts new file mode 100644 index 0000000..79bbfc1 --- /dev/null +++ b/src/lib/actions/server-management.ts @@ -0,0 +1,60 @@ +'use server'; + +import { revalidatePath } from 'next/cache'; +import { requireAuth } from '@/lib/auth/server'; +import { createServer, deleteServer } from '@/lib/api/server/servers'; + +export type ServerActionResult = { + success: boolean; + message: string; + data?: any; +}; + +export async function createServerAction( + prevState: ServerActionResult, + formData: FormData +): Promise { + try { + const session = await requireAuth(); + const name = formData.get('name') as string; + + if (!name?.trim()) { + return { + success: false, + message: 'Server name is required' + }; + } + + const server = await createServer(session.token!, name.trim()); + revalidatePath('/dashboard'); + + return { + success: true, + message: 'Server creation started', + data: server + }; + } catch (error) { + return { + success: false, + message: error instanceof Error ? error.message : 'Failed to create server' + }; + } +} + +export async function deleteServerAction(serverId: string): Promise { + try { + const session = await requireAuth(); + await deleteServer(session.token!, serverId); + revalidatePath('/dashboard'); + + return { + success: true, + message: 'Server deleted successfully' + }; + } catch (error) { + return { + success: false, + message: error instanceof Error ? error.message : 'Failed to delete server' + }; + } +} diff --git a/src/lib/api/client/base.ts b/src/lib/api/client/base.ts new file mode 100644 index 0000000..a444267 --- /dev/null +++ b/src/lib/api/client/base.ts @@ -0,0 +1,55 @@ +'use client'; + +const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8080'; + +export type ClientApiResponse = { + data?: T; + error?: string; + message?: string; +}; + +export async function fetchClientAPI( + endpoint: string, + method: string = 'GET', + body?: object, + customToken?: string +): Promise> { + let token = customToken; + + if (!token) { + const response = await fetch('/api/session'); + if (response.ok) { + const session = await response.json(); + token = session.openToken; + } + + if (!token) { + throw new Error('No authentication token available'); + } + } + + const headers: Record = { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + }; + + const response = await fetch(`${BASE_URL}${endpoint}`, { + method, + headers, + body: body ? JSON.stringify(body) : undefined + }); + + if (!response.ok) { + if (response.status === 401) { + window.location.href = '/logout'; + return { error: 'unauthorized' }; + } + throw new Error(`API Error: ${response.statusText} - ${method} - ${BASE_URL}${endpoint}`); + } + + if (response.headers.get('Content-Type')?.includes('application/json')) { + return { data: await response.json() }; + } + + return { message: await response.text() }; +} diff --git a/src/lib/api/server/auth.ts b/src/lib/api/server/auth.ts index facd444..8e3b9bf 100644 --- a/src/lib/api/server/auth.ts +++ b/src/lib/api/server/auth.ts @@ -33,3 +33,12 @@ export async function getCurrentUser(token: string): Promise { const response = await fetchServerAPI(`${authRoute}/me`, token); return response.data!; } + +export async function getOpenToken(token: string): Promise { + const response = await fetchServerAPI<{ token: string }>( + `${authRoute}/open-token`, + token, + 'POST' + ); + return response.data!.token; +} diff --git a/src/lib/api/server/servers.ts b/src/lib/api/server/servers.ts index c79d582..70cf30a 100644 --- a/src/lib/api/server/servers.ts +++ b/src/lib/api/server/servers.ts @@ -29,3 +29,12 @@ export async function getServiceStatus(token: string, serverId: string): Promise const response = await fetchServerAPI(`${serverRoute}/${serverId}/service`, token); return response.data!; } + +export async function createServer(token: string, name: string): Promise { + const response = await fetchServerAPI(serverRoute, token, 'POST', { name }); + return response.data!; +} + +export async function deleteServer(token: string, serverId: string): Promise { + await fetchServerAPI(`${serverRoute}/${serverId}`, token, 'DELETE'); +} diff --git a/src/lib/auth/server.ts b/src/lib/auth/server.ts index df099c6..83a549c 100644 --- a/src/lib/auth/server.ts +++ b/src/lib/auth/server.ts @@ -18,10 +18,11 @@ export async function requireAuth() { return session; } -export async function login(token: string, user: SessionData['user']) { +export async function login(token: string, user: SessionData['user'], openToken?: string) { const session = await getSession(); session.token = token; session.user = user; + session.openToken = openToken; await session.save(); } diff --git a/src/lib/context/ServerCreationPopupContext.tsx b/src/lib/context/ServerCreationPopupContext.tsx new file mode 100644 index 0000000..b251578 --- /dev/null +++ b/src/lib/context/ServerCreationPopupContext.tsx @@ -0,0 +1,61 @@ +'use client'; + +import { createContext, useContext, useState, ReactNode } from 'react'; + +interface ServerCreationPopupState { + serverId: string; + serverName: string; + isOpen: boolean; +} + +interface ServerCreationPopupContextType { + popup: ServerCreationPopupState | null; + showPopup: (serverId: string, serverName: string) => void; + hidePopup: () => void; + isPopupOpen: boolean; +} + +const ServerCreationPopupContext = createContext(null); + +export function useServerCreationPopup() { + const context = useContext(ServerCreationPopupContext); + if (!context) { + throw new Error('useServerCreationPopup must be used within a ServerCreationPopupProvider'); + } + return context; +} + +interface ServerCreationPopupProviderProps { + children: ReactNode; +} + +export function ServerCreationPopupProvider({ children }: ServerCreationPopupProviderProps) { + const [popup, setPopup] = useState(null); + + const showPopup = (serverId: string, serverName: string) => { + setPopup({ + serverId, + serverName, + isOpen: true + }); + }; + + const hidePopup = () => { + setPopup(null); + }; + + const isPopupOpen = popup?.isOpen || false; + + return ( + + {children} + + ); +} diff --git a/src/lib/context/SteamCMDContext.tsx b/src/lib/context/SteamCMDContext.tsx new file mode 100644 index 0000000..94f5531 --- /dev/null +++ b/src/lib/context/SteamCMDContext.tsx @@ -0,0 +1,69 @@ +'use client'; + +import { createContext, useContext, useState, ReactNode, useEffect } from 'react'; +import { useWebSocket } from '@/lib/websocket/context'; +import { WebSocketMessage, StepData } from '@/lib/websocket/client'; + +interface SteamCMDContextType { + isSteamCMDRunning: boolean; + runningSteamServers: Set; +} + +const SteamCMDContext = createContext(null); + +export function useSteamCMD() { + const context = useContext(SteamCMDContext); + if (!context) { + throw new Error('useSteamCMD must be used within a SteamCMDProvider'); + } + return context; +} + +interface SteamCMDProviderProps { + children: ReactNode; +} + +export function SteamCMDProvider({ children }: SteamCMDProviderProps) { + const [runningSteamServers, setRunningSteamServers] = useState>(new Set()); + const { addMessageHandler, removeMessageHandler } = useWebSocket(); + + const isSteamCMDRunning = runningSteamServers.size > 0; + + useEffect(() => { + const handleWebSocketMessage = (message: WebSocketMessage) => { + if (message.type === 'step') { + const data = message.data as StepData; + + if (data.step === 'steam_download') { + setRunningSteamServers(prev => { + const newSet = new Set(prev); + + if (data.status === 'in_progress') { + newSet.add(message.server_id); + } else if (data.status === 'completed' || data.status === 'failed') { + newSet.delete(message.server_id); + } + + return newSet; + }); + } + } + }; + + addMessageHandler(handleWebSocketMessage); + return () => { + removeMessageHandler(handleWebSocketMessage); + }; + }, [addMessageHandler, removeMessageHandler]); + + return ( + + {children} + + ); +} diff --git a/src/lib/session/config.ts b/src/lib/session/config.ts index 0c662e1..8db03f1 100644 --- a/src/lib/session/config.ts +++ b/src/lib/session/config.ts @@ -3,6 +3,7 @@ import { User } from '@/lib/types'; export interface SessionData { token?: string; + openToken?: string; user?: User; } diff --git a/src/lib/websocket/client.ts b/src/lib/websocket/client.ts new file mode 100644 index 0000000..66b14e6 --- /dev/null +++ b/src/lib/websocket/client.ts @@ -0,0 +1,207 @@ +const BASE_URL = process.env.NEXT_PUBLIC_WEBSOCKET_BASE_URL || 'ws://localhost:8080'; + +export interface WebSocketMessage { + type: 'step' | 'steam_output' | 'error' | 'complete'; + server_id: string; + timestamp: number; + data: StepData | SteamOutputData | ErrorData | CompleteData; +} + +export interface StepData { + step: + | 'validation' + | 'directory_creation' + | 'steam_download' + | 'config_generation' + | 'service_creation' + | 'firewall_rules' + | 'database_save' + | 'completed'; + status: 'pending' | 'in_progress' | 'completed' | 'failed'; + message: string; + error: string; +} + +export interface SteamOutputData { + output: string; + is_error: boolean; +} + +export interface ErrorData { + error: string; + details: string; +} + +export interface CompleteData { + server_id: string; + success: boolean; + message: string; +} + +export type MessageHandler = (message: WebSocketMessage) => void; +export type ConnectionStatusHandler = ( + status: 'connecting' | 'connected' | 'disconnected' | 'error', + error?: string +) => void; + +export class WebSocketClient { + private ws: WebSocket | null = null; + private token: string; + private messageHandlers: MessageHandler[] = []; + private connectionStatusHandlers: ConnectionStatusHandler[] = []; + private connectionPromise: Promise | null = null; + private reconnectAttempts = 0; + private maxReconnectAttempts = 10; + private reconnectDelay = 1000; // Start with 1 second + private maxReconnectDelay = 30000; // Max 30 seconds + private reconnectTimer: NodeJS.Timeout | null = null; + private shouldReconnect = true; + private associatedServerId: string | null = null; + + constructor(token: string) { + this.token = token; + } + + connect(): Promise { + if (this.connectionPromise) { + return this.connectionPromise; + } + + this.shouldReconnect = true; + this.notifyStatus('connecting'); + + this.connectionPromise = new Promise((resolve, reject) => { + try { + this.ws = new WebSocket(`${BASE_URL}?token=${this.token}`); + + this.ws.onopen = () => { + console.log('WebSocket connected'); + this.reconnectAttempts = 0; + this.reconnectDelay = 5000; + this.notifyStatus('connected'); + + // Re-associate with server if we had one + if (this.associatedServerId) { + this.associateWithServer(this.associatedServerId); + } + + resolve(); + }; + + this.ws.onmessage = (event) => { + try { + const message: WebSocketMessage = JSON.parse(event.data); + this.messageHandlers.forEach((handler) => handler(message)); + } catch (error) { + console.error('Failed to parse WebSocket message:', error); + } + }; + + this.ws.onclose = (event) => { + console.log('WebSocket disconnected:', event.code, event.reason); + this.connectionPromise = null; + this.notifyStatus('disconnected'); + + if (this.shouldReconnect && this.reconnectAttempts < this.maxReconnectAttempts) { + this.scheduleReconnect(); + } + }; + + this.ws.onerror = (error) => { + console.error('WebSocket error:', error); + this.notifyStatus('error', 'Connection failed'); + reject(error); + }; + } catch (error) { + this.notifyStatus('error', error instanceof Error ? error.message : 'Unknown error'); + reject(error); + } + }); + + return this.connectionPromise; + } + + associateWithServer(serverId: string): void { + this.associatedServerId = serverId; + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + this.ws.send(`server_id:${serverId}`); + } + } + + addMessageHandler(handler: MessageHandler): void { + this.messageHandlers.push(handler); + } + + removeMessageHandler(handler: MessageHandler): void { + const index = this.messageHandlers.indexOf(handler); + if (index > -1) { + this.messageHandlers.splice(index, 1); + } + } + + disconnect(): void { + this.shouldReconnect = false; + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + if (this.ws) { + this.ws.close(); + this.ws = null; + } + this.connectionPromise = null; + this.messageHandlers = []; + this.connectionStatusHandlers = []; + this.associatedServerId = null; + } + + isConnected(): boolean { + return this.ws?.readyState === WebSocket.OPEN; + } + + addConnectionStatusHandler(handler: ConnectionStatusHandler): void { + this.connectionStatusHandlers.push(handler); + } + + removeConnectionStatusHandler(handler: ConnectionStatusHandler): void { + const index = this.connectionStatusHandlers.indexOf(handler); + if (index > -1) { + this.connectionStatusHandlers.splice(index, 1); + } + } + + reconnect(): Promise { + this.disconnect(); + return this.connect(); + } + + private notifyStatus( + status: 'connecting' | 'connected' | 'disconnected' | 'error', + error?: string + ): void { + this.connectionStatusHandlers.forEach((handler) => handler(status, error)); + } + + private scheduleReconnect(): void { + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + } + + this.reconnectAttempts++; + const delay = Math.min( + this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1), + this.maxReconnectDelay + ); + + console.log( + `WebSocket reconnect attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts} in ${delay}ms` + ); + + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null; + this.connect().catch((error) => { + console.error('Reconnection failed:', error); + }); + }, delay); + } +} diff --git a/src/lib/websocket/context.tsx b/src/lib/websocket/context.tsx new file mode 100644 index 0000000..e70442a --- /dev/null +++ b/src/lib/websocket/context.tsx @@ -0,0 +1,148 @@ +'use client'; + +import { createContext, useContext, useEffect, useState, ReactNode, useCallback } from 'react'; +import { WebSocketClient, MessageHandler, ConnectionStatusHandler } from './client'; + +interface WebSocketContextType { + client: WebSocketClient | null; + isConnected: boolean; + connectionStatus: 'connecting' | 'connected' | 'disconnected' | 'error'; + connectionError: string | null; + connect: (token: string) => Promise; + disconnect: () => void; + reconnect: () => Promise; + associateWithServer: (serverId: string) => void; + addMessageHandler: (handler: MessageHandler) => void; + removeMessageHandler: (handler: MessageHandler) => void; + addConnectionStatusHandler: (handler: ConnectionStatusHandler) => void; + removeConnectionStatusHandler: (handler: ConnectionStatusHandler) => void; +} + +const WebSocketContext = createContext(null); + +export function useWebSocket() { + const context = useContext(WebSocketContext); + if (!context) { + throw new Error('useWebSocket must be used within a WebSocketProvider'); + } + return context; +} + +interface WebSocketProviderProps { + children: ReactNode; +} + +export function WebSocketProvider({ children }: WebSocketProviderProps) { + const [client, setClient] = useState(null); + const [isConnected, setIsConnected] = useState(false); + const [connectionStatus, setConnectionStatus] = useState< + 'connecting' | 'connected' | 'disconnected' | 'error' + >('disconnected'); + const [connectionError, setConnectionError] = useState(null); + + const connect = useCallback( + async (token: string) => { + if (client) { + client.disconnect(); + } + + const newClient = new WebSocketClient(token); + + // Add connection status handler + const statusHandler: ConnectionStatusHandler = (status, error) => { + setConnectionStatus(status); + setIsConnected(status === 'connected'); + setConnectionError(error || null); + }; + + newClient.addConnectionStatusHandler(statusHandler); + + try { + await newClient.connect(); + setClient(newClient); + } catch (error) { + console.error('Failed to connect WebSocket:', error); + throw error; + } + }, + [client] + ); + + const disconnect = useCallback(() => { + if (client) { + client.disconnect(); + setClient(null); + setIsConnected(false); + setConnectionStatus('disconnected'); + setConnectionError(null); + } + }, [client]); + + const reconnect = async () => { + if (client) { + try { + await client.reconnect(); + } catch (error) { + console.error('Failed to reconnect WebSocket:', error); + throw error; + } + } + }; + + const associateWithServer = (serverId: string) => { + if (client && isConnected) { + client.associateWithServer(serverId); + } + }; + + const addMessageHandler = (handler: MessageHandler) => { + if (client) { + client.addMessageHandler(handler); + } + }; + + const removeMessageHandler = (handler: MessageHandler) => { + if (client) { + client.removeMessageHandler(handler); + } + }; + + const addConnectionStatusHandler = (handler: ConnectionStatusHandler) => { + if (client) { + client.addConnectionStatusHandler(handler); + } + }; + + const removeConnectionStatusHandler = (handler: ConnectionStatusHandler) => { + if (client) { + client.removeConnectionStatusHandler(handler); + } + }; + + useEffect(() => { + return () => { + disconnect(); + }; + }, [disconnect]); + + return ( + + {children} + + ); +}