From 6aeb654abfae6711bc8796673d5f342173bb4f0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=20Jurmanovi=C4=87?= Date: Sun, 21 Sep 2025 22:53:23 +0200 Subject: [PATCH] lazy load socket connection --- src/app/api/session/route.ts | 11 + src/app/dashboard/layout.tsx | 23 + src/app/dashboard/page.tsx | 4 - src/app/dashboard/server/[id]/page.tsx | 2 +- src/app/layout.tsx | 20 +- src/app/logout/route.ts | 7 - src/app/page.tsx | 2 +- src/components/server/ServerCard.tsx | 38 +- .../server/ServerCreationPopupContainer.tsx | 4 +- .../server/ServerCreationProgressClient.tsx | 432 ------------------ src/components/server/ServerHeader.tsx | 98 +++- .../server/ServerListWithActions.tsx | 2 +- src/lib/actions/auth.ts | 4 +- src/lib/api/client/base.ts | 24 +- src/lib/api/server/base.ts | 9 +- src/lib/auth/server.ts | 7 +- src/lib/websocket/config.ts | 7 + src/lib/websocket/context.tsx | 94 ++-- 18 files changed, 216 insertions(+), 572 deletions(-) create mode 100644 src/app/api/session/route.ts create mode 100644 src/app/dashboard/layout.tsx delete mode 100644 src/app/logout/route.ts delete mode 100644 src/components/server/ServerCreationProgressClient.tsx create mode 100644 src/lib/websocket/config.ts diff --git a/src/app/api/session/route.ts b/src/app/api/session/route.ts new file mode 100644 index 0000000..3b87a6a --- /dev/null +++ b/src/app/api/session/route.ts @@ -0,0 +1,11 @@ +import { requireAuth } from '@/lib/auth/server'; +import { NextResponse } from 'next/server'; + +export async function GET(): Promise { + const session = await requireAuth(true); + return NextResponse.json({ openToken: session.openToken }); +} +export async function DELETE(): Promise { + const session = await requireAuth(true); + session.destroy(); +} diff --git a/src/app/dashboard/layout.tsx b/src/app/dashboard/layout.tsx new file mode 100644 index 0000000..f75f6a0 --- /dev/null +++ b/src/app/dashboard/layout.tsx @@ -0,0 +1,23 @@ +import { WebSocketProvider } from '@/lib/websocket/context'; +import { SteamCMDProvider } from '@/lib/context/SteamCMDContext'; +import { ServerCreationPopupProvider } from '@/lib/context/ServerCreationPopupContext'; +import { ServerCreationPopupContainer } from '@/components/server/ServerCreationPopupContainer'; +import { requireAuth } from '@/lib/auth/server'; + +export default async function DashboardLayout({ + children +}: Readonly<{ + children: React.ReactNode; +}>) { + const session = await requireAuth(); + return ( + + + + {children} + + + + + ); +} diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index e11d615..673fd55 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -1,12 +1,9 @@ import { requireAuth } from '@/lib/auth/server'; import { getServers } from '@/lib/api/server/servers'; import { hasPermission } from '@/lib/types'; -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() { @@ -15,7 +12,6 @@ export default async function DashboardPage() { return (
-

ACC Server Manager

diff --git a/src/app/dashboard/server/[id]/page.tsx b/src/app/dashboard/server/[id]/page.tsx index 236e0de..d64cb0a 100644 --- a/src/app/dashboard/server/[id]/page.tsx +++ b/src/app/dashboard/server/[id]/page.tsx @@ -28,7 +28,7 @@ export default async function ServerPage({ params }: ServerPageProps) { return (
- +
) { return ( - - - - - - {children} - - - - - - + {children} ); } diff --git a/src/app/logout/route.ts b/src/app/logout/route.ts deleted file mode 100644 index f40f3fc..0000000 --- a/src/app/logout/route.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { logout } from '@/lib/auth/server'; -import { redirect, RedirectType } from 'next/navigation'; - -export async function GET() { - await logout(); - redirect('/login', RedirectType.replace); -} diff --git a/src/app/page.tsx b/src/app/page.tsx index 900ac7d..7c659bf 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -7,6 +7,6 @@ export default async function HomePage() { if (session.token && session.user) { redirect('/dashboard'); } else { - redirect('/logout'); + redirect('/login'); } } diff --git a/src/components/server/ServerCard.tsx b/src/components/server/ServerCard.tsx index 558e31a..3c88a43 100644 --- a/src/components/server/ServerCard.tsx +++ b/src/components/server/ServerCard.tsx @@ -1,26 +1,21 @@ import Link from 'next/link'; -import { useState, useTransition } from 'react'; +import { useTransition } 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 { useRouter } from 'next/navigation'; interface ServerCardProps { server: Server; - user: User; } -export function ServerCard({ server, user }: ServerCardProps) { - const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); +export function ServerCard({ server }: ServerCardProps) { const [isPending, startTransition] = useTransition(); const router = useRouter(); - const canDeleteServer = hasPermission(user, 'server.delete'); const startServer = () => startTransition(async () => { await startServerEventAction(server.id); @@ -62,29 +57,6 @@ export function ServerCard({ server, user }: ServerCardProps) {
- {canDeleteServer && ( - - )}
- - setIsDeleteModalOpen(false)} - server={server} - /> ); } diff --git a/src/components/server/ServerCreationPopupContainer.tsx b/src/components/server/ServerCreationPopupContainer.tsx index 9dd679d..ab847f9 100644 --- a/src/components/server/ServerCreationPopupContainer.tsx +++ b/src/components/server/ServerCreationPopupContainer.tsx @@ -4,10 +4,12 @@ import { useServerCreationPopup } from '@/lib/context/ServerCreationPopupContext import { ServerCreationPopup } from './ServerCreationPopup'; import { useSteamCMD } from '@/lib/context/SteamCMDContext'; import { useCallback } from 'react'; +import { useRouter } from 'next/navigation'; export function ServerCreationPopupContainer() { const { popup, hidePopup } = useServerCreationPopup(); const { dissociateServer } = useSteamCMD(); + const router = useRouter(); const handleClose = useCallback(() => { hidePopup(); if (popup) return dissociateServer(popup.serverId); @@ -17,7 +19,7 @@ export function ServerCreationPopupContainer() { const handleComplete = (success: boolean) => { if (success) { setTimeout(() => { - window.location.reload(); + router.refresh(); }, 2000); } }; diff --git a/src/components/server/ServerCreationProgressClient.tsx b/src/components/server/ServerCreationProgressClient.tsx deleted file mode 100644 index 6ec2116..0000000 --- a/src/components/server/ServerCreationProgressClient.tsx +++ /dev/null @@ -1,432 +0,0 @@ -'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/ServerHeader.tsx b/src/components/server/ServerHeader.tsx index 6e43f83..af34375 100644 --- a/src/components/server/ServerHeader.tsx +++ b/src/components/server/ServerHeader.tsx @@ -1,3 +1,4 @@ +'use client'; import Link from 'next/link'; import { Server, getStatusColor, serviceStatusToString, ServiceStatus } from '@/lib/types/server'; import { @@ -5,12 +6,43 @@ import { restartServerEventAction, stopServerEventAction } from '@/lib/actions/servers'; +import { hasPermission, User } from '@/lib/types'; +import { useState, useTransition } from 'react'; +import { useRouter } from 'next/navigation'; +import { DeleteServerModal } from './DeleteServerModal'; interface ServerHeaderProps { server: Server; + user: User; } -export function ServerHeader({ server }: ServerHeaderProps) { +export function ServerHeader({ server, user }: ServerHeaderProps) { + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [isPending, startTransition] = useTransition(); + const router = useRouter(); + const canDeleteServer = hasPermission(user, 'server.delete'); + const startServer = () => + startTransition(async () => { + await startServerEventAction(server.id); + router.refresh(); + }); + const restartServer = () => + startTransition(async () => { + await restartServerEventAction(server.id); + router.refresh(); + }); + const stopServer = () => + startTransition(async () => { + await stopServerEventAction(server.id); + router.refresh(); + }); + + const disabled = [ + ServiceStatus.Restarting, + ServiceStatus.Starting, + ServiceStatus.Stopping, + ServiceStatus.Unknown + ].includes(server.status); return (
@@ -53,35 +85,45 @@ export function ServerHeader({ server }: ServerHeaderProps) {
-
+ {canDeleteServer && ( -
+ )} + -
- -
+ -
- -
+
@@ -108,6 +150,12 @@ export function ServerHeader({ server }: ServerHeaderProps) {
+ + setIsDeleteModalOpen(false)} + server={server} + /> ); } diff --git a/src/components/server/ServerListWithActions.tsx b/src/components/server/ServerListWithActions.tsx index 4c7cf67..ef8ee2f 100644 --- a/src/components/server/ServerListWithActions.tsx +++ b/src/components/server/ServerListWithActions.tsx @@ -41,7 +41,7 @@ export function ServerListWithActions({ servers, user }: ServerListWithActionsPr
{servers.map((server) => ( - + ))}
diff --git a/src/lib/actions/auth.ts b/src/lib/actions/auth.ts index b6509a7..e8f8ff8 100644 --- a/src/lib/actions/auth.ts +++ b/src/lib/actions/auth.ts @@ -2,7 +2,7 @@ import { redirect } from 'next/navigation'; import { loginUser, getOpenToken } from '@/lib/api/server/auth'; -import { login } from '@/lib/auth/server'; +import { login, logout } from '@/lib/auth/server'; export type LoginResult = { success: boolean; @@ -43,5 +43,5 @@ export async function loginAction(prevState: LoginResult, formData: FormData) { } export async function logoutAction() { - redirect('/logout'); + logout(); } diff --git a/src/lib/api/client/base.ts b/src/lib/api/client/base.ts index a444267..ad9f6e8 100644 --- a/src/lib/api/client/base.ts +++ b/src/lib/api/client/base.ts @@ -1,5 +1,7 @@ 'use client'; +import { SessionData } from '@/lib/session/config'; + const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8080'; export type ClientApiResponse = { @@ -8,6 +10,17 @@ export type ClientApiResponse = { message?: string; }; +const getSession = async (): Promise => { + const response = await fetch('/api/session'); + if (response.ok) { + return await response.json(); + } + return null; +}; +const destroySession = async (): Promise => { + await fetch('/api/session', { method: 'DELETE' }); +}; + export async function fetchClientAPI( endpoint: string, method: string = 'GET', @@ -15,13 +28,11 @@ export async function fetchClientAPI( customToken?: string ): Promise> { let token = customToken; + let session: SessionData | null = null; if (!token) { - const response = await fetch('/api/session'); - if (response.ok) { - const session = await response.json(); - token = session.openToken; - } + session = await getSession(); + token = session?.openToken; if (!token) { throw new Error('No authentication token available'); @@ -41,7 +52,8 @@ export async function fetchClientAPI( if (!response.ok) { if (response.status === 401) { - window.location.href = '/logout'; + await destroySession(); + window.location.href = '/login'; return { error: 'unauthorized' }; } throw new Error(`API Error: ${response.statusText} - ${method} - ${BASE_URL}${endpoint}`); diff --git a/src/lib/api/server/base.ts b/src/lib/api/server/base.ts index cf9b40c..fcf9997 100644 --- a/src/lib/api/server/base.ts +++ b/src/lib/api/server/base.ts @@ -1,5 +1,3 @@ -import { redirect } from 'next/navigation'; - const BASE_URL = process.env.API_BASE_URL || 'http://localhost:8080'; type ApiResponse = { @@ -8,6 +6,10 @@ type ApiResponse = { message?: string; }; +const destroySession = async (): Promise => { + await fetch('/api/session', { method: 'DELETE' }); +}; + export async function fetchServerAPI( endpoint: string, token: string, @@ -28,7 +30,8 @@ export async function fetchServerAPI( if (!response.ok) { if (response.status == 401) { - redirect('/logout'); + await destroySession(); + window.location.href = '/login'; return { error: 'unauthorized' }; } throw new Error( diff --git a/src/lib/auth/server.ts b/src/lib/auth/server.ts index 83a549c..b4aaf39 100644 --- a/src/lib/auth/server.ts +++ b/src/lib/auth/server.ts @@ -8,11 +8,12 @@ export async function getSession() { return session; } -export async function requireAuth() { +export async function requireAuth(skipRedirect?: boolean) { const session = await getSession(); - if (!session.token || !session.user) { - redirect('/logout'); + if (!skipRedirect && (!session.token || !session.user)) { + session.destroy(); + redirect('/login'); } return session; diff --git a/src/lib/websocket/config.ts b/src/lib/websocket/config.ts new file mode 100644 index 0000000..a511a55 --- /dev/null +++ b/src/lib/websocket/config.ts @@ -0,0 +1,7 @@ +export interface WebsocketOptions { + url: string; +} + +export const websocketOptions: WebsocketOptions = { + url: process.env.NEXT_PUBLIC_WEBSOCKET_URL || 'ws://localhost:3000/ws' +}; diff --git a/src/lib/websocket/context.tsx b/src/lib/websocket/context.tsx index 4518c78..f2d7199 100644 --- a/src/lib/websocket/context.tsx +++ b/src/lib/websocket/context.tsx @@ -1,7 +1,16 @@ 'use client'; -import { createContext, useContext, useEffect, useState, ReactNode, useCallback } from 'react'; +import { + createContext, + useContext, + useEffect, + useState, + ReactNode, + useCallback, + useRef +} from 'react'; import { WebSocketClient, MessageHandler, ConnectionStatusHandler } from './client'; +import { websocketOptions } from './config'; interface WebSocketContextType { client: WebSocketClient | null; @@ -30,10 +39,10 @@ export function useWebSocket() { interface WebSocketProviderProps { children: ReactNode; - websocketURL: string; + openToken: string; } -export function WebSocketProvider({ children, websocketURL }: WebSocketProviderProps) { +export function WebSocketProvider({ children, openToken }: WebSocketProviderProps) { const [client, setClient] = useState(null); const [isConnected, setIsConnected] = useState(false); const [connectionStatus, setConnectionStatus] = useState< @@ -51,7 +60,7 @@ export function WebSocketProvider({ children, websocketURL }: WebSocketProviderP client.disconnect(); } - const newClient = new WebSocketClient(token, websocketURL); + const newClient = new WebSocketClient(token, websocketOptions.url); const statusHandler: ConnectionStatusHandler = (status, error) => { setConnectionStatus(status); @@ -82,7 +91,7 @@ export function WebSocketProvider({ children, websocketURL }: WebSocketProviderP } }, [client]); - const reconnect = async () => { + const reconnect = useCallback(async () => { if (client) { try { await client.reconnect(); @@ -91,37 +100,60 @@ export function WebSocketProvider({ children, websocketURL }: WebSocketProviderP throw error; } } - }; + }, [client]); + const hasInitialized = useRef(false); - const associateWithServer = (serverId: string) => { - if (client && isConnected) { - client.associateWithServer(serverId); - } - }; + const associateWithServer = useCallback( + (serverId: string) => { + if (openToken && !isConnected && !hasInitialized.current) { + hasInitialized.current = true; + connect(openToken).catch((error) => { + console.error('Failed to connect WebSocket:', error); + hasInitialized.current = false; + }); + } + if (client && isConnected) { + client.associateWithServer(serverId); + } + }, + [client, isConnected, connect] + ); - const addMessageHandler = (handler: MessageHandler) => { - if (client) { - client.addMessageHandler(handler); - } - }; + const addMessageHandler = useCallback( + (handler: MessageHandler) => { + if (client) { + client.addMessageHandler(handler); + } + }, + [client] + ); - const removeMessageHandler = (handler: MessageHandler) => { - if (client) { - client.removeMessageHandler(handler); - } - }; + const removeMessageHandler = useCallback( + (handler: MessageHandler) => { + if (client) { + client.removeMessageHandler(handler); + } + }, + [client] + ); - const addConnectionStatusHandler = (handler: ConnectionStatusHandler) => { - if (client) { - client.addConnectionStatusHandler(handler); - } - }; + const addConnectionStatusHandler = useCallback( + (handler: ConnectionStatusHandler) => { + if (client) { + client.addConnectionStatusHandler(handler); + } + }, + [client] + ); - const removeConnectionStatusHandler = (handler: ConnectionStatusHandler) => { - if (client) { - client.removeConnectionStatusHandler(handler); - } - }; + const removeConnectionStatusHandler = useCallback( + (handler: ConnectionStatusHandler) => { + if (client) { + client.removeConnectionStatusHandler(handler); + } + }, + [client] + ); useEffect(() => { return () => {