feat: refactor codebase for separation of concerns

- Implement modular backend architecture with services, middleware, routes, and utils
- Restructure frontend with centralized state management and component organization
- Create dedicated services for auth, Docker, status monitoring, and API communication
- Add WebSocket message handlers and connection management
- Update README.md with comprehensive architecture documentation
- Establish clear boundaries between functional areas for improved maintainability

BREAKING CHANGE: Major architectural overhaul with new module structure
This commit is contained in:
MCHost
2025-12-27 01:24:33 -05:00
parent b1b44fd978
commit 4cf41af76e
37 changed files with 4808 additions and 36 deletions
+40
View File
@@ -0,0 +1,40 @@
import fetch from 'node-fetch';
import { API_URL } from '../../config/environment.js';
/**
* API client for making requests to the external API
*/
/**
* Make an API request to the external service
* @param {string} endpoint - API endpoint (without base URL)
* @param {string} apiKey - Authentication API key
* @param {string} method - HTTP method (default: 'GET')
* @param {object} body - Request body for POST/PUT requests
* @returns {Promise<object>} API response or error object
*/
export async function apiRequest(endpoint, apiKey, method = 'GET', body = null) {
const headers = {
'Accept': 'application/json',
'Content-Type': 'application/json',
'x-my-mc-auth': apiKey
};
try {
const response = await fetch(`${API_URL}${endpoint}`, {
method,
headers,
body: body ? JSON.stringify(body) : null
});
const data = await response.json();
if (!response.ok) {
return { error: data.message || `HTTP ${response.status}` };
}
return data;
} catch (error) {
return { error: `Network error: ${error.message}` };
}
}
+127
View File
@@ -0,0 +1,127 @@
import { linkStorage } from './linkStorage.js';
import { generateApiKey, generateLoginLinkId, createLoginLink } from './tokenService.js';
import { validateSecretKey, validateUsername } from '../../middleware/validation.js';
import { getRealIp } from '../../utils/ipUtils.js';
import {
ADMIN_SECRET_KEY,
STRICT_USER_AGENT_CHECK
} from '../../config/environment.js';
/**
* Login link generation service
*/
export class LinkGenerator {
/**
* Generate a login link for a user
* @param {string} secretKey - Admin secret key
* @param {string} username - Username to generate link for
* @param {object} req - Express request object
* @returns {Promise<{success: boolean, link?: string, error?: string}>} Result object
*/
static async generateLoginLink(secretKey, username, req) {
try {
// Validate secret key
if (!validateSecretKey(secretKey, ADMIN_SECRET_KEY)) {
console.log(`Invalid secret key attempt from IP: ${getRealIp(req)}`);
return { success: false, error: 'Unauthorized' };
}
// Validate username
const sanitizedUsername = validateUsername(username);
if (!sanitizedUsername) {
console.log(`Invalid username attempt from IP: ${getRealIp(req)}, username: ${username}`);
return { success: false, error: 'Invalid username' };
}
// Generate API key
const apiKey = await generateApiKey(sanitizedUsername);
if (!apiKey) {
return { success: false, error: 'Authentication service error' };
}
// Generate link ID and create link
const linkId = generateLoginLinkId();
const loginLink = createLoginLink(linkId);
// Store link data
linkStorage.storeLink(linkId, {
apiKey,
username: sanitizedUsername,
ip: getRealIp(req),
userAgent: req.get('User-Agent') || 'Discord-Bot-Request'
});
console.log(`Generated login link for username: ${sanitizedUsername} from IP: ${getRealIp(req)}, userAgent: ${req.get('User-Agent') || 'Discord-Bot-Request'}`);
return { success: true, link: loginLink };
} catch (error) {
console.log(`Error generating login link: ${error.message}`);
return { success: false, error: 'Server error' };
}
}
/**
* Validate and retrieve login link data
* @param {string} linkId - Link ID to validate
* @param {object} req - Express request object
* @returns {{success: boolean, data?: object, error?: string}} Validation result
*/
static validateLoginLink(linkId, req) {
const linkData = linkStorage.getLink(linkId);
if (!linkData) {
console.log(`Expired or invalid login attempt for link: ${linkId} from IP: ${getRealIp(req)}`);
return { success: false, error: 'Link expired or invalid' };
}
// Verify client consistency
const isIpMatch = linkData.ip === getRealIp(req);
const isUserAgentMatch = linkData.userAgent === (req.get('User-Agent') || 'Discord-Bot-Request');
const isLocal = this.isLocalIp(getRealIp(req)) && this.isLocalIp(linkData.ip);
if (STRICT_USER_AGENT_CHECK && !isUserAgentMatch && !isLocal) {
linkStorage.deleteLink(linkId);
console.log(
`Suspicious login attempt for link: ${linkId} from IP: ${getRealIp(req)}, ` +
`expected IP: ${linkData.ip}, isLocal: ${isLocal}, ` +
`userAgentMatch: ${isUserAgentMatch}, ` +
`expectedUserAgent: ${linkData.userAgent}, ` +
`actualUserAgent: ${req.get('User-Agent') || 'Discord-Bot-Request'}`
);
return { success: false, error: 'Invalid session' };
}
if (!isUserAgentMatch) {
console.log(
`Non-critical user-agent mismatch for link: ${linkId} from IP: ${getRealIp(req)}, ` +
`expectedUserAgent: ${linkData.userAgent}, ` +
`actualUserAgent: ${req.get('User-Agent') || 'Discord-Bot-Request'}`
);
}
return { success: true, data: linkData };
}
/**
* Consume (delete) a login link after successful use
* @param {string} linkId - Link ID to consume
*/
static consumeLoginLink(linkId) {
linkStorage.deleteLink(linkId);
}
/**
* Check if an IP address is considered local
* @param {string} ip - IP address to check
* @returns {boolean} True if local
*/
static isLocalIp(ip) {
return (
ip === '127.0.0.1' ||
ip === '::1' ||
ip.startsWith('192.168.') ||
ip.startsWith('10.')
);
}
}
+83
View File
@@ -0,0 +1,83 @@
import {
TEMP_LINKS_CLEANUP_INTERVAL_MS,
LINK_EXPIRY_SECONDS
} from '../../config/environment.js';
/**
* Temporary login link storage service
*/
class LinkStorage {
constructor() {
this.temporaryLinks = new Map();
this.initializeCleanup();
}
/**
* Initialize the cleanup interval for expired links
*/
initializeCleanup() {
const cleanupInterval = Math.max(60000, parseInt(TEMP_LINKS_CLEANUP_INTERVAL_MS, 10));
setInterval(() => {
const now = Date.now();
for (const [linkId, linkData] of this.temporaryLinks.entries()) {
if (linkData.expiresAt < now) {
this.temporaryLinks.delete(linkId);
console.log(`Cleaned up expired link: ${linkId}`);
}
}
}, cleanupInterval);
}
/**
* Store a temporary login link
* @param {string} linkId - Unique link identifier
* @param {object} linkData - Link data containing apiKey, username, expiresAt, ip, userAgent
*/
storeLink(linkId, linkData) {
// Set expiry time with maximum of 1 hour
const expiresAt = Date.now() + Math.min(3600000, parseInt(LINK_EXPIRY_SECONDS, 10) * 1000);
linkData.expiresAt = expiresAt;
this.temporaryLinks.set(linkId, linkData);
}
/**
* Retrieve a temporary login link
* @param {string} linkId - Link identifier
* @returns {object|null} Link data or null if not found/expired
*/
getLink(linkId) {
const linkData = this.temporaryLinks.get(linkId);
if (!linkData || linkData.expiresAt < Date.now()) {
if (linkData) {
this.temporaryLinks.delete(linkId);
}
return null;
}
return linkData;
}
/**
* Delete a temporary login link
* @param {string} linkId - Link identifier
*/
deleteLink(linkId) {
this.temporaryLinks.delete(linkId);
}
/**
* Check if a link exists and is valid
* @param {string} linkId - Link identifier
* @returns {boolean} True if link exists and is valid
*/
hasValidLink(linkId) {
return this.getLink(linkId) !== null;
}
}
// Export singleton instance
export const linkStorage = new LinkStorage();
+58
View File
@@ -0,0 +1,58 @@
import unirest from 'unirest';
import { generateSecureToken } from '../../utils/cryptoUtils.js';
import {
AUTH_ENDPOINT,
AUTH_PASSWORD,
LINK_ID_BYTES,
AUTO_LOGIN_LINK_PREFIX
} from '../../config/environment.js';
/**
* Token service for authentication operations
*/
/**
* Generate an API key from the authentication service
* @param {string} username - Username to authenticate
* @returns {Promise<string|null>} API key or null if failed
*/
export async function generateApiKey(username) {
try {
const tokenResponse = await unirest
.post(AUTH_ENDPOINT)
.headers({
'Accept': 'application/json',
'Content-Type': 'application/json',
'X-Request-ID': generateSecureToken(16)
})
.send({ username, password: AUTH_PASSWORD })
.timeout(5000);
if (!tokenResponse.body.token) {
console.log(`Failed to generate API key for username: ${username}`);
return null;
}
return tokenResponse.body.token;
} catch (error) {
console.log(`Error generating API key for username ${username}:`, error.message);
return null;
}
}
/**
* Generate a secure login link ID
* @returns {string} Secure link ID
*/
export function generateLoginLinkId() {
return generateSecureToken(LINK_ID_BYTES);
}
/**
* Create a complete login link from a link ID
* @param {string} linkId - Link ID
* @returns {string} Complete login link
*/
export function createLoginLink(linkId) {
return `${AUTO_LOGIN_LINK_PREFIX}${linkId}`;
}
+57
View File
@@ -0,0 +1,57 @@
import Docker from 'dockerode';
import { DOCKER_SOCKET_PATH } from '../../config/environment.js';
/**
* Docker container service for general container operations
*/
/**
* Set up Docker client instance
* @returns {Docker} Docker client instance
*/
export function setupDocker() {
return new Docker({ socketPath: DOCKER_SOCKET_PATH });
}
/**
* Check if a container exists
* @param {Docker} docker - Docker instance
* @param {string} containerName - Name of the container
* @returns {Promise<boolean>} True if container exists
*/
export async function containerExists(docker, containerName) {
try {
const containers = await docker.listContainers({ all: true });
return containers.some(c => c.Names.includes(`/${containerName}`));
} catch (error) {
console.error(`Error checking if container ${containerName} exists:`, error.message);
return false;
}
}
/**
* Get container inspection data
* @param {Docker} docker - Docker instance
* @param {string} containerName - Name of the container
* @returns {Promise<object|null>} Container inspection data or null if error
*/
export async function inspectContainer(docker, containerName) {
try {
const container = docker.getContainer(containerName);
return await container.inspect();
} catch (error) {
console.error(`Error inspecting container ${containerName}:`, error.message);
return null;
}
}
/**
* Check if container is running
* @param {Docker} docker - Docker instance
* @param {string} containerName - Name of the container
* @returns {Promise<boolean>} True if container is running
*/
export async function isContainerRunning(docker, containerName) {
const inspect = await inspectContainer(docker, containerName);
return inspect ? inspect.State.Status === 'running' : false;
}
+115
View File
@@ -0,0 +1,115 @@
import { LOG_STREAM_TAIL_LINES, LOG_STREAM_MONITOR_INTERVAL_MS } from '../../config/environment.js';
/**
* Docker container logs streaming service
*/
/**
* Stream container logs to a WebSocket client
* @param {Docker} docker - Docker instance
* @param {WebSocket} ws - WebSocket connection
* @param {string} containerName - Name of the container
* @param {object} client - Client object to store stream reference
*/
export async function streamContainerLogs(docker, ws, containerName, client) {
let isStreaming = true;
let isStartingStream = false;
const startLogStream = async () => {
if (isStartingStream) return false;
isStartingStream = true;
try {
const container = docker.getContainer(containerName);
const [containers, inspect] = await Promise.all([
docker.listContainers({ all: true }),
container.inspect()
]);
if (!containers.some(c => c.Names.includes(`/${containerName}`))) {
if (isStreaming) ws.send(JSON.stringify({ type: 'docker-logs', error: `Container ${containerName} not found` }));
return false;
}
if (inspect.State.Status !== 'running') {
if (isStreaming) ws.send(JSON.stringify({ type: 'docker-logs', error: `Container ${containerName} is not running` }));
return false;
}
if (client.logStream) {
client.logStream.removeAllListeners();
client.logStream.destroy();
client.logStream = null;
}
const logStream = await container.logs({
follow: true,
stdout: true,
stderr: true,
tail: parseInt(LOG_STREAM_TAIL_LINES, 10),
timestamps: true
});
logStream.on('data', (chunk) => {
if (isStreaming && client.logStream === logStream) {
ws.send(JSON.stringify({ type: 'docker-logs', data: { log: chunk.toString('utf8') } }));
}
});
logStream.on('error', (error) => {
if (isStreaming) ws.send(JSON.stringify({ type: 'docker-logs', error: `Log stream error: ${error.message}` }));
});
client.logStream = logStream;
return true;
} catch (error) {
if (isStreaming) ws.send(JSON.stringify({ type: 'docker-logs', error: `Failed to stream logs: ${error.message}` }));
return false;
} finally {
isStartingStream = false;
}
};
const monitorContainer = async () => {
try {
const container = docker.getContainer(containerName);
const inspect = await container.inspect();
if (inspect.State.Status !== 'running') {
if (client.logStream) {
client.logStream.removeAllListeners();
client.logStream.destroy();
client.logStream = null;
}
return false;
}
return true;
} catch (error) {
return false;
}
};
// Try to start the log stream initially
await startLogStream();
// Set up a single monitor interval to restart the stream if it stops
const monitorInterval = setInterval(async () => {
if (!isStreaming) {
clearInterval(monitorInterval);
return;
}
if (await monitorContainer() && !client.logStream && !isStartingStream) {
await startLogStream();
}
}, parseInt(LOG_STREAM_MONITOR_INTERVAL_MS, 10));
// Set up close handler to clean up resources
ws.on('close', () => {
isStreaming = false;
clearInterval(monitorInterval);
if (client.logStream) {
client.logStream.removeAllListeners();
client.logStream.destroy();
client.logStream = null;
}
});
}
+111
View File
@@ -0,0 +1,111 @@
import { promisify } from 'util';
import { exec } from 'child_process';
import { randomBytes } from 'crypto';
import { promises as fs } from 'fs';
import path from 'path';
import {
SERVER_PROPERTIES_PATH,
TEMP_DIR,
TEMP_FILE_RANDOM_ID_BYTES,
CONTAINER_TEMP_FILE_PREFIX
} from '../../config/environment.js';
const execPromise = promisify(exec);
/**
* Docker server properties management service
*/
/**
* Read server.properties file from container
* @param {Docker} docker - Docker instance
* @param {string} containerName - Name of the container
* @returns {Promise<object>} Object with content or error
*/
export async function readServerProperties(docker, containerName) {
try {
const container = docker.getContainer(containerName);
const inspect = await container.inspect();
if (inspect.State.Status !== 'running') {
return { error: `Container ${containerName} is not running` };
}
const { stdout, stderr } = await execPromise(`docker exec ${containerName} bash -c "cat ${SERVER_PROPERTIES_PATH}"`);
if (stderr) return { error: 'Failed to read server.properties' };
return { content: stdout };
} catch (error) {
return { error: `Failed to read server.properties: ${error.message}` };
}
}
/**
* Write server.properties file to container
* @param {Docker} docker - Docker instance
* @param {string} containerName - Name of the container
* @param {string} content - Content to write
* @returns {Promise<object>} Success message or error
*/
export async function writeServerProperties(docker, containerName, content) {
try {
const randomId = randomBytes(parseInt(TEMP_FILE_RANDOM_ID_BYTES, 10)).toString('hex');
const tmpFile = path.join(TEMP_DIR, `server_properties_${randomId}.tmp`);
const containerFilePath = `${CONTAINER_TEMP_FILE_PREFIX}${randomId}.tmp`;
await fs.writeFile(tmpFile, content);
await execPromise(`docker cp ${tmpFile} ${containerName}:${containerFilePath}`);
await execPromise(`docker exec ${containerName} bash -c "mv ${containerFilePath} ${SERVER_PROPERTIES_PATH} && chown mc:mc ${SERVER_PROPERTIES_PATH}"`);
await fs.unlink(tmpFile).catch(err => console.error(`Error deleting temp file: ${err.message}`));
return { message: 'Server properties updated' };
} catch (error) {
return { error: `Failed to write server.properties: ${error.message}` };
}
}
/**
* Update mods in container
* @param {Docker} docker - Docker instance
* @param {string} containerName - Name of the container
* @returns {Promise<object>} Output or error
*/
export async function updateMods(docker, containerName) {
try {
const container = docker.getContainer(containerName);
const inspect = await container.inspect();
if (inspect.State.Status !== 'running') {
return { error: `Container ${containerName} is not running` };
}
const { stdout, stderr } = await execPromise(`docker exec ${containerName} bash -c 'cd /home/mc/minecraft && mod-manager update'`);
if (stderr) return { output: stderr };
return { output: stdout || 'Mod update completed successfully.' };
} catch (error) {
return { error: `Failed to update mods: ${error.message}` };
}
}
/**
* Create a backup of the container
* @param {Docker} docker - Docker instance
* @param {string} containerName - Name of the container
* @returns {Promise<object>} Backup result with download URL or error
*/
export async function createBackup(docker, containerName) {
try {
const container = docker.getContainer(containerName);
const inspect = await container.inspect();
if (inspect.State.Status !== 'running') {
return { error: `Container ${containerName} is not running` };
}
const command = `docker exec -t ${containerName} bash -c "/home/backup.sh | grep export"`;
const { stdout, stderr } = await execPromise(command);
if (stderr) return { error: stderr };
// Extract the URL using a regular expression
const urlRegex = /(https:\/\/[^\s]+)/;
const match = stdout.match(urlRegex);
if (!match) return { error: 'No download URL found in backup output' };
const downloadURL = match[0];
return { output: 'Backup completed successfully', downloadURL };
} catch (error) {
return { error: `Failed to create backup: ${error.message}` };
}
}
+86
View File
@@ -0,0 +1,86 @@
import { promisify } from 'util';
import { exec } from 'child_process';
const execPromise = promisify(exec);
/**
* Docker container statistics service
*/
/**
* Get comprehensive container statistics
* @param {Docker} docker - Docker instance
* @param {string} containerName - Name of the container
* @returns {Promise<object>} Container statistics or error object
*/
export async function getContainerStats(docker, containerName) {
try {
const container = docker.getContainer(containerName);
const [containers, info, stats] = await Promise.all([
docker.listContainers({ all: true }),
container.inspect(),
container.stats({ stream: false })
]);
if (!containers.some(c => c.Names.includes(`/${containerName}`))) {
return { error: `Container ${containerName} not found` };
}
// Calculate memory statistics
const memoryUsage = stats.memory_stats.usage / 1024 / 1024;
const memoryLimit = stats.memory_stats.limit / 1024 / 1024 / 1024;
const memoryPercent = ((memoryUsage / (memoryLimit * 1024)) * 100).toFixed(2);
// Calculate CPU statistics
const cpuDelta = stats.cpu_stats.cpu_usage.total_usage - (stats.precpu_stats.cpu_usage?.total_usage || 0);
const systemDelta = stats.cpu_stats.system_cpu_usage - (stats.precpu_stats.system_cpu_usage || 0);
const cpuPercent = systemDelta > 0 ? ((cpuDelta / systemDelta) * stats.cpu_stats.online_cpus * 100).toFixed(2) : 0;
// Collect disk space data
let diskData = null;
try {
const { stdout: dfOutput } = await execPromise(`docker exec ${containerName} df -h /`);
const lines = dfOutput.trim().split('\n');
if (lines.length >= 2) {
const dataLine = lines[1];
const columns = dataLine.split(/\s+/);
if (columns.length >= 6) {
const totalSize = columns[1];
const usedSpace = columns[2];
const availableSpace = columns[3];
const usagePercent = columns[4].replace('%', '');
// Check if total size is exactly 25GB (handle both "25G" and "25Gi" formats)
const normalizedTotal = totalSize.replace('i', ''); // Remove 'i' from GiB if present
if (normalizedTotal === '25G') {
diskData = {
raw: `${usedSpace} / ${totalSize}`,
percent: usagePercent,
used: usedSpace,
available: availableSpace,
total: totalSize
};
}
}
}
} catch (diskError) {
console.warn(`Failed to get disk stats for ${containerName}:`, diskError.message);
// Don't fail the entire stats collection if disk data is unavailable
}
const result = {
status: info.State.Status,
memory: { raw: `${memoryUsage.toFixed(2)}MiB / ${memoryLimit.toFixed(2)}GiB`, percent: memoryPercent },
cpu: cpuPercent
};
// Only include disk data if it's available and the container has 25GB quota
if (diskData) {
result.disk = diskData;
}
return result;
} catch (error) {
console.error(`Docker stats error for ${containerName}:`, error.message);
return { error: `Failed to fetch stats for ${containerName}: ${error.message}` };
}
}
+78
View File
@@ -0,0 +1,78 @@
import { promisify } from 'util';
import { exec } from 'child_process';
import { Socket } from 'net';
import {
STATUS_CHECK_PATH,
GEYSER_STATUS_CHECK_PATH,
SFTP_CONNECTION_TIMEOUT_MS,
SFTP_HOSTNAME
} from '../../config/environment.js';
const execPromise = promisify(exec);
/**
* Connection status checking services
*/
/**
* Check Minecraft server connection status
* @param {string} hostname - Server hostname
* @param {number} port - Server port
* @returns {Promise<object>} Status object with isOnline and optional data/error
*/
export async function checkConnectionStatus(hostname, port) {
try {
const { stdout, stderr } = await execPromise(`${STATUS_CHECK_PATH} -host ${hostname} -port ${port}`);
if (stderr) return { isOnline: false, error: stderr };
return { isOnline: true, data: JSON.parse(stdout) };
} catch (error) {
return { isOnline: false, error: error.message };
}
}
/**
* Check Geyser (Bedrock) server connection status
* @param {string} hostname - Server hostname
* @param {number} port - Server port
* @returns {Promise<object>} Status object with isOnline and optional data/error
*/
export async function checkGeyserStatus(hostname, port) {
try {
const { stdout, stderr } = await execPromise(`${GEYSER_STATUS_CHECK_PATH} -host ${hostname} -port ${port}`);
if (stderr) return { isOnline: false, error: stderr };
return { isOnline: true, data: JSON.parse(stdout) };
} catch (error) {
return { isOnline: false, error: error.message };
}
}
/**
* Check SFTP server connection status
* @param {string} hostname - Server hostname
* @param {number} port - Server port
* @returns {Promise<object>} Status object with isOnline and optional error
*/
export async function checkSftpStatus(hostname, port) {
return new Promise((resolve) => {
const socket = new Socket();
const timeout = parseInt(SFTP_CONNECTION_TIMEOUT_MS, 10);
socket.setTimeout(timeout);
socket.on('connect', () => {
socket.destroy();
resolve({ isOnline: true });
});
socket.on('timeout', () => {
socket.destroy();
resolve({ isOnline: false, error: 'Connection timed out' });
});
socket.on('error', (error) => {
socket.destroy();
resolve({ isOnline: false, error: error.message });
});
socket.connect(port, SFTP_HOSTNAME);
});
}
+82
View File
@@ -0,0 +1,82 @@
/**
* Status monitoring service
* Handles periodic status checks and monitoring for various services
*/
export class StatusMonitor {
constructor() {
this.monitors = new Map();
this.intervals = new Map();
}
/**
* Start monitoring a service
* @param {string} id - Monitor identifier
* @param {Function} checkFunction - Function to call for status check
* @param {number} intervalMs - Interval in milliseconds
* @param {Function} callback - Callback function to handle results
*/
startMonitoring(id, checkFunction, intervalMs, callback) {
// Clear existing monitor if it exists
this.stopMonitoring(id);
const intervalId = setInterval(async () => {
try {
const result = await checkFunction();
callback(result);
} catch (error) {
console.error(`Status monitor error for ${id}:`, error.message);
callback({ isOnline: false, error: error.message });
}
}, intervalMs);
this.intervals.set(id, intervalId);
this.monitors.set(id, { checkFunction, callback });
}
/**
* Stop monitoring a service
* @param {string} id - Monitor identifier
*/
stopMonitoring(id) {
const intervalId = this.intervals.get(id);
if (intervalId) {
clearInterval(intervalId);
this.intervals.delete(id);
this.monitors.delete(id);
}
}
/**
* Stop all monitoring
*/
stopAllMonitoring() {
for (const id of this.intervals.keys()) {
this.stopMonitoring(id);
}
}
/**
* Get all active monitors
* @returns {Array<string>} Array of monitor IDs
*/
getActiveMonitors() {
return Array.from(this.monitors.keys());
}
/**
* Perform a one-time status check
* @param {Function} checkFunction - Function to call for status check
* @returns {Promise<object>} Status result
*/
static async performCheck(checkFunction) {
try {
return await checkFunction();
} catch (error) {
return { isOnline: false, error: error.message };
}
}
}
// Export singleton instance
export const statusMonitor = new StatusMonitor();