Files
FunConnect/server/src/node-manager.ts

155 lines
4.4 KiB
TypeScript
Raw Normal View History

import axios from 'axios';
import { v4 as uuidv4 } from 'uuid';
import logger from './logger';
import config from './config';
export interface RelayNode {
id: string;
name: string;
host: string;
apiPort: number;
relayPort: number;
status: 'online' | 'offline' | 'busy';
roomCount: number;
playerCount: number;
maxRooms: number;
region: string;
lastHeartbeat: Date;
registeredAt: Date;
}
export class NodeManager {
private nodes: Map<string, RelayNode> = new Map();
private heartbeatTimers: Map<string, NodeJS.Timeout> = new Map();
constructor() {
if (config.isMaster) {
setInterval(() => this.checkNodeHealth(), config.heartbeatInterval);
}
}
registerNode(nodeInfo: {
name: string;
host: string;
apiPort: number;
relayPort: number;
maxRooms: number;
region: string;
}): RelayNode {
const existing = Array.from(this.nodes.values()).find(
n => n.host === nodeInfo.host && n.apiPort === nodeInfo.apiPort
);
if (existing) {
existing.status = 'online';
existing.lastHeartbeat = new Date();
existing.name = nodeInfo.name;
existing.maxRooms = nodeInfo.maxRooms;
existing.region = nodeInfo.region;
logger.info(`Node re-registered: ${existing.name} (${existing.id})`);
return existing;
}
const node: RelayNode = {
id: uuidv4().substring(0, 8),
name: nodeInfo.name,
host: nodeInfo.host,
apiPort: nodeInfo.apiPort,
relayPort: nodeInfo.relayPort,
status: 'online',
roomCount: 0,
playerCount: 0,
maxRooms: nodeInfo.maxRooms,
region: nodeInfo.region,
lastHeartbeat: new Date(),
registeredAt: new Date(),
};
this.nodes.set(node.id, node);
logger.info(`New node registered: ${node.name} (${node.id}) at ${node.host}:${node.relayPort}`);
return node;
}
removeNode(nodeId: string): boolean {
const timer = this.heartbeatTimers.get(nodeId);
if (timer) clearTimeout(timer);
this.heartbeatTimers.delete(nodeId);
const removed = this.nodes.delete(nodeId);
if (removed) {
logger.info(`Node removed: ${nodeId}`);
}
return removed;
}
updateNodeHeartbeat(nodeId: string, stats: { roomCount: number; playerCount: number }): boolean {
const node = this.nodes.get(nodeId);
if (!node) return false;
node.lastHeartbeat = new Date();
node.roomCount = stats.roomCount;
node.playerCount = stats.playerCount;
node.status = node.roomCount >= node.maxRooms ? 'busy' : 'online';
return true;
}
getNode(nodeId: string): RelayNode | undefined {
return this.nodes.get(nodeId);
}
listNodes(): RelayNode[] {
return Array.from(this.nodes.values());
}
getAvailableNodes(): RelayNode[] {
return Array.from(this.nodes.values()).filter(n => n.status === 'online');
}
getBestNode(): RelayNode | undefined {
const available = this.getAvailableNodes();
if (available.length === 0) return undefined;
return available.sort((a, b) => {
const aLoad = a.roomCount / a.maxRooms;
const bLoad = b.roomCount / b.maxRooms;
return aLoad - bLoad;
})[0];
}
private async checkNodeHealth(): Promise<void> {
const now = new Date();
for (const [nodeId, node] of this.nodes) {
const timeSinceHeartbeat = now.getTime() - node.lastHeartbeat.getTime();
if (timeSinceHeartbeat > config.heartbeatInterval * 3) {
node.status = 'offline';
logger.warn(`Node ${node.name} (${nodeId}) marked offline - no heartbeat for ${Math.round(timeSinceHeartbeat / 1000)}s`);
} else {
try {
const response = await axios.get(`http://${node.host}:${node.apiPort}/api/health`, {
timeout: 5000,
});
if (response.data.status === 'ok') {
node.status = node.roomCount >= node.maxRooms ? 'busy' : 'online';
node.lastHeartbeat = new Date();
}
} catch {
if (timeSinceHeartbeat > config.heartbeatInterval * 2) {
node.status = 'offline';
}
}
}
}
}
getStats(): { totalNodes: number; onlineNodes: number; totalRooms: number; totalPlayers: number } {
const nodes = Array.from(this.nodes.values());
return {
totalNodes: nodes.length,
onlineNodes: nodes.filter(n => n.status !== 'offline').length,
totalRooms: nodes.reduce((sum, n) => sum + n.roomCount, 0),
totalPlayers: nodes.reduce((sum, n) => sum + n.playerCount, 0),
};
}
}