server creation

This commit is contained in:
Fran Jurmanović
2025-09-18 01:34:16 +02:00
parent b7999b02e8
commit b0ee67c2be
21 changed files with 1542 additions and 82 deletions

View File

@@ -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,

View File

@@ -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<ServerActionResult> {
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<ServerActionResult> {
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'
};
}
}

View File

@@ -0,0 +1,55 @@
'use client';
const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8080';
export type ClientApiResponse<T> = {
data?: T;
error?: string;
message?: string;
};
export async function fetchClientAPI<T>(
endpoint: string,
method: string = 'GET',
body?: object,
customToken?: string
): Promise<ClientApiResponse<T>> {
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<string, string> = {
'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() };
}

View File

@@ -33,3 +33,12 @@ export async function getCurrentUser(token: string): Promise<User> {
const response = await fetchServerAPI<User>(`${authRoute}/me`, token);
return response.data!;
}
export async function getOpenToken(token: string): Promise<string> {
const response = await fetchServerAPI<{ token: string }>(
`${authRoute}/open-token`,
token,
'POST'
);
return response.data!.token;
}

View File

@@ -29,3 +29,12 @@ export async function getServiceStatus(token: string, serverId: string): Promise
const response = await fetchServerAPI<ServiceStatus>(`${serverRoute}/${serverId}/service`, token);
return response.data!;
}
export async function createServer(token: string, name: string): Promise<Server> {
const response = await fetchServerAPI<Server>(serverRoute, token, 'POST', { name });
return response.data!;
}
export async function deleteServer(token: string, serverId: string): Promise<void> {
await fetchServerAPI(`${serverRoute}/${serverId}`, token, 'DELETE');
}

View File

@@ -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();
}

View File

@@ -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<ServerCreationPopupContextType | null>(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<ServerCreationPopupState | null>(null);
const showPopup = (serverId: string, serverName: string) => {
setPopup({
serverId,
serverName,
isOpen: true
});
};
const hidePopup = () => {
setPopup(null);
};
const isPopupOpen = popup?.isOpen || false;
return (
<ServerCreationPopupContext.Provider
value={{
popup,
showPopup,
hidePopup,
isPopupOpen
}}
>
{children}
</ServerCreationPopupContext.Provider>
);
}

View File

@@ -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<string>;
}
const SteamCMDContext = createContext<SteamCMDContextType | null>(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<Set<string>>(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 (
<SteamCMDContext.Provider
value={{
isSteamCMDRunning,
runningSteamServers
}}
>
{children}
</SteamCMDContext.Provider>
);
}

View File

@@ -3,6 +3,7 @@ import { User } from '@/lib/types';
export interface SessionData {
token?: string;
openToken?: string;
user?: User;
}

207
src/lib/websocket/client.ts Normal file
View File

@@ -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<void> | 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<void> {
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<void> {
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);
}
}

View File

@@ -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<void>;
disconnect: () => void;
reconnect: () => Promise<void>;
associateWithServer: (serverId: string) => void;
addMessageHandler: (handler: MessageHandler) => void;
removeMessageHandler: (handler: MessageHandler) => void;
addConnectionStatusHandler: (handler: ConnectionStatusHandler) => void;
removeConnectionStatusHandler: (handler: ConnectionStatusHandler) => void;
}
const WebSocketContext = createContext<WebSocketContextType | null>(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<WebSocketClient | null>(null);
const [isConnected, setIsConnected] = useState(false);
const [connectionStatus, setConnectionStatus] = useState<
'connecting' | 'connected' | 'disconnected' | 'error'
>('disconnected');
const [connectionError, setConnectionError] = useState<string | null>(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 (
<WebSocketContext.Provider
value={{
client,
isConnected,
connectionStatus,
connectionError,
connect,
disconnect,
reconnect,
associateWithServer,
addMessageHandler,
removeMessageHandler,
addConnectionStatusHandler,
removeConnectionStatusHandler
}}
>
{children}
</WebSocketContext.Provider>
);
}