overhaul
This commit is contained in:
66 files changed
+9227
-3679
No files matched your search
@@ -0,0 +1,129 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { ConsoleEvent, LiveStats } from './console-hub.js';
|
||||
import { BaseConsoleHub, mapPowerState, stripAnsi } from './console-hub.js';
|
||||
|
||||
const ESC = String.fromCharCode(27);
|
||||
|
||||
class TestHub extends BaseConsoleHub {
|
||||
start(): void {
|
||||
this.setConnected(true);
|
||||
}
|
||||
async stop(): Promise<void> {
|
||||
this.setConnected(false);
|
||||
}
|
||||
emitOutput(stream: 'console' | 'install' | 'daemon', chunk: string): void {
|
||||
this.pushOutput(stream, chunk);
|
||||
}
|
||||
emitStats(stats: LiveStats): void {
|
||||
this.setStats(stats);
|
||||
}
|
||||
emitStatus(status: Parameters<BaseConsoleHub['setStatus']>[0]): void {
|
||||
this.setStatus(status);
|
||||
}
|
||||
}
|
||||
|
||||
function stats(overrides: Partial<LiveStats> = {}): LiveStats {
|
||||
return {
|
||||
status: 'online',
|
||||
cpuPercent: 38,
|
||||
memoryBytes: 1024,
|
||||
diskBytes: 2048,
|
||||
networkRxBytes: 10,
|
||||
networkTxBytes: 20,
|
||||
uptimeMs: 1000,
|
||||
at: Date.now(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('mapPowerState', () => {
|
||||
it('maps every Wings state, defaulting to unknown', () => {
|
||||
expect(mapPowerState('running')).toBe('online');
|
||||
expect(mapPowerState('starting')).toBe('starting');
|
||||
expect(mapPowerState('stopping')).toBe('stopping');
|
||||
expect(mapPowerState('offline')).toBe('offline');
|
||||
expect(mapPowerState(undefined)).toBe('unknown');
|
||||
expect(mapPowerState('something-new')).toBe('unknown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripAnsi', () => {
|
||||
it('removes the colour escapes Wings wraps console output in', () => {
|
||||
expect(stripAnsi(`${ESC}[0;32mSuccess!${ESC}[0m`)).toBe('Success!');
|
||||
});
|
||||
|
||||
it('leaves plain text alone', () => {
|
||||
expect(stripAnsi('NETWORK : Server is ready')).toBe('NETWORK : Server is ready');
|
||||
});
|
||||
});
|
||||
|
||||
describe('BaseConsoleHub', () => {
|
||||
it('splits chunks into lines and numbers them for de-duplication', () => {
|
||||
const hub = new TestHub();
|
||||
hub.emitOutput('console', 'first\r\nsecond\n\nthird\n');
|
||||
const lines = hub.backlog().lines;
|
||||
expect(lines.map((line) => line.text)).toEqual(['first', 'second', 'third']);
|
||||
expect(lines.map((line) => line.seq)).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it('keeps install output distinguishable from game output', () => {
|
||||
const hub = new TestHub();
|
||||
hub.emitOutput('install', 'Downloading mod 595F2BF2F44836FB');
|
||||
expect(hub.backlog().lines[0]!.stream).toBe('install');
|
||||
});
|
||||
|
||||
it('replays the backlog and current state to a late subscriber', () => {
|
||||
const hub = new TestHub();
|
||||
hub.start();
|
||||
hub.emitOutput('console', 'boot line');
|
||||
hub.emitStatus('starting');
|
||||
const backlog = hub.backlog();
|
||||
expect(backlog.lines).toHaveLength(1);
|
||||
expect(backlog.status).toBe('starting');
|
||||
expect(backlog.connected).toBe(true);
|
||||
});
|
||||
|
||||
it('fans events out to subscribers until they unsubscribe', () => {
|
||||
const hub = new TestHub();
|
||||
const events: ConsoleEvent[] = [];
|
||||
const unsubscribe = hub.subscribe((event) => events.push(event));
|
||||
|
||||
hub.emitOutput('console', 'one');
|
||||
hub.emitStatus('online');
|
||||
unsubscribe();
|
||||
hub.emitOutput('console', 'two');
|
||||
|
||||
expect(events).toHaveLength(2);
|
||||
expect(events[0]).toMatchObject({ type: 'line' });
|
||||
expect(events[1]).toMatchObject({ type: 'status', status: 'online' });
|
||||
});
|
||||
|
||||
it('does not re-emit an unchanged status', () => {
|
||||
const hub = new TestHub();
|
||||
const events: ConsoleEvent[] = [];
|
||||
hub.subscribe((event) => events.push(event));
|
||||
hub.emitStatus('online');
|
||||
hub.emitStatus('online');
|
||||
expect(events.filter((event) => event.type === 'status')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('takes the status carried by a stats frame', () => {
|
||||
const hub = new TestHub();
|
||||
hub.emitStats(stats({ status: 'starting' }));
|
||||
expect(hub.latestStatus()).toBe('starting');
|
||||
expect(hub.latestStats()?.cpuPercent).toBe(38);
|
||||
});
|
||||
|
||||
it('a throwing subscriber cannot break the feed for others', () => {
|
||||
const hub = new TestHub();
|
||||
const seen: string[] = [];
|
||||
hub.subscribe(() => {
|
||||
throw new Error('bad subscriber');
|
||||
});
|
||||
hub.subscribe((event) => {
|
||||
if (event.type === 'line') seen.push(event.line.text);
|
||||
});
|
||||
hub.emitOutput('console', 'still delivered');
|
||||
expect(seen).toEqual(['still delivered']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
import type {
|
||||
ConsoleBacklog,
|
||||
ConsoleLine,
|
||||
ConsoleLineStream,
|
||||
ServerStatus,
|
||||
} from '@reforger-panel/shared';
|
||||
|
||||
/** Maps a hosting backend's power state onto the panel's status vocabulary. */
|
||||
export function mapPowerState(state: string | undefined): ServerStatus {
|
||||
switch (state) {
|
||||
case 'running':
|
||||
return 'online';
|
||||
case 'offline':
|
||||
return 'offline';
|
||||
case 'starting':
|
||||
return 'starting';
|
||||
case 'stopping':
|
||||
return 'stopping';
|
||||
default:
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
/** A resource frame pushed by Wings, or synthesised by the mock provider. */
|
||||
export type LiveStats = {
|
||||
status: ServerStatus;
|
||||
cpuPercent: number;
|
||||
memoryBytes: number;
|
||||
diskBytes: number;
|
||||
networkRxBytes: number;
|
||||
networkTxBytes: number;
|
||||
uptimeMs: number;
|
||||
/** Unix ms the frame arrived. */
|
||||
at: number;
|
||||
};
|
||||
|
||||
export type ConsoleEvent =
|
||||
| { type: 'line'; line: ConsoleLine }
|
||||
| { type: 'status'; status: ServerStatus }
|
||||
| { type: 'stats'; stats: LiveStats };
|
||||
|
||||
/**
|
||||
* A live feed of a game server's console, status and resource usage.
|
||||
*
|
||||
* The panel previously reconstructed "live" output by repeatedly downloading
|
||||
* the *game's* log file, which meant nothing was visible until the game itself
|
||||
* had started and created that file — the install, update and mod download
|
||||
* phases were invisible. A hub instead carries whatever the hosting backend is
|
||||
* emitting, in real time.
|
||||
*/
|
||||
export interface ConsoleHub {
|
||||
start(): void;
|
||||
stop(): Promise<void>;
|
||||
/** Returns an unsubscribe function. */
|
||||
subscribe(listener: (event: ConsoleEvent) => void): () => void;
|
||||
/** Recent lines plus current state, so a new viewer sees context instantly. */
|
||||
backlog(): ConsoleBacklog;
|
||||
latestStats(): LiveStats | null;
|
||||
latestStatus(): ServerStatus;
|
||||
}
|
||||
|
||||
const MAX_BACKLOG_LINES = 2_000;
|
||||
|
||||
/**
|
||||
* Wings colourises console output with ANSI escapes; the panel styles lines
|
||||
* itself, so they are stripped before they reach the browser. Built from a
|
||||
* char code rather than a literal escape to keep this file plain ASCII.
|
||||
*/
|
||||
const ANSI_PATTERN = new RegExp(`${String.fromCharCode(27)}\\[[0-9;?]*[A-Za-z]`, 'g');
|
||||
|
||||
export function stripAnsi(value: string): string {
|
||||
return value.replace(ANSI_PATTERN, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared line buffer, subscriber fan-out and state tracking. Both the real
|
||||
* Wings hub and the mock hub build on this.
|
||||
*/
|
||||
export abstract class BaseConsoleHub implements ConsoleHub {
|
||||
private readonly listeners = new Set<(event: ConsoleEvent) => void>();
|
||||
private readonly lines: ConsoleLine[] = [];
|
||||
private seq = 0;
|
||||
private status: ServerStatus = 'unknown';
|
||||
private stats: LiveStats | null = null;
|
||||
protected connected = false;
|
||||
|
||||
abstract start(): void;
|
||||
abstract stop(): Promise<void>;
|
||||
|
||||
subscribe(listener: (event: ConsoleEvent) => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
backlog(): ConsoleBacklog {
|
||||
return { lines: [...this.lines], status: this.status, connected: this.connected };
|
||||
}
|
||||
|
||||
latestStats(): LiveStats | null {
|
||||
return this.stats;
|
||||
}
|
||||
|
||||
latestStatus(): ServerStatus {
|
||||
return this.status;
|
||||
}
|
||||
|
||||
private emit(event: ConsoleEvent): void {
|
||||
for (const listener of this.listeners) {
|
||||
try {
|
||||
listener(event);
|
||||
} catch {
|
||||
// A broken subscriber must never take the upstream feed down.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Splits on newlines and pushes each non-empty line into the ring buffer. */
|
||||
protected pushOutput(stream: ConsoleLineStream, chunk: string): void {
|
||||
const at = Date.now();
|
||||
for (const raw of stripAnsi(chunk).split(/\r?\n/)) {
|
||||
const text = raw.replace(/\r/g, '').trimEnd();
|
||||
if (!text) continue;
|
||||
const line: ConsoleLine = { seq: ++this.seq, stream, text, at };
|
||||
this.lines.push(line);
|
||||
this.emit({ type: 'line', line });
|
||||
}
|
||||
if (this.lines.length > MAX_BACKLOG_LINES) {
|
||||
this.lines.splice(0, this.lines.length - MAX_BACKLOG_LINES);
|
||||
}
|
||||
}
|
||||
|
||||
protected setStatus(status: ServerStatus): void {
|
||||
if (this.status === status) return;
|
||||
this.status = status;
|
||||
this.emit({ type: 'status', status });
|
||||
}
|
||||
|
||||
protected setStats(stats: LiveStats): void {
|
||||
this.stats = stats;
|
||||
// Wings reports the state alongside every stats frame; trust it.
|
||||
this.setStatus(stats.status);
|
||||
this.emit({ type: 'stats', stats });
|
||||
}
|
||||
|
||||
protected setConnected(connected: boolean): void {
|
||||
this.connected = connected;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { ServerStatus } from '@reforger-panel/shared';
|
||||
import { BaseConsoleHub } from './console-hub.js';
|
||||
import type { MockGameServerProvider } from './mock-provider.js';
|
||||
|
||||
const STATS_INTERVAL_MS = 2_000;
|
||||
|
||||
/**
|
||||
* Simulates the Wings feed so the whole panel — live console, status strip and
|
||||
* resource graphs — works under USE_MOCK_PTERODACTYL. The startup script
|
||||
* deliberately includes the update and mod-download phase, since that is the
|
||||
* part the real websocket exists to surface.
|
||||
*/
|
||||
const BOOT_SCRIPT: { delayMs: number; stream: 'console' | 'install' | 'daemon'; text: string }[] = [
|
||||
{
|
||||
delayMs: 100,
|
||||
stream: 'daemon',
|
||||
text: 'Pulling Docker container image, ensuring it is up to date.',
|
||||
},
|
||||
{ delayMs: 600, stream: 'daemon', text: 'Finished pulling Docker container image.' },
|
||||
{ delayMs: 900, stream: 'console', text: 'Redirecting stderr to stdout.' },
|
||||
{
|
||||
delayMs: 1200,
|
||||
stream: 'console',
|
||||
text: 'Update state (0x5) verifying install, progress: 46.12 (1039 / 2253)',
|
||||
},
|
||||
{ delayMs: 1800, stream: 'console', text: 'Success! App "1874900" fully installed.' },
|
||||
{
|
||||
delayMs: 2200,
|
||||
stream: 'console',
|
||||
text: 'Downloading workshop mod 591AF5BDA9F7CE8B (Mock Sample Mod)',
|
||||
},
|
||||
{
|
||||
delayMs: 2900,
|
||||
stream: 'console',
|
||||
text: 'Mod 591AF5BDA9F7CE8B downloaded (4.2 MiB), verifying.',
|
||||
},
|
||||
{
|
||||
delayMs: 3300,
|
||||
stream: 'console',
|
||||
text: 'ENGINE : Enfusion engine build: 1.3.0.42 (mock)',
|
||||
},
|
||||
{ delayMs: 3700, stream: 'console', text: 'DEFAULT : Loading world.' },
|
||||
{ delayMs: 4100, stream: 'console', text: 'DEFAULT : Game successfully created.' },
|
||||
{ delayMs: 4300, stream: 'console', text: 'NETWORK : Server is ready to accept connections' },
|
||||
];
|
||||
|
||||
export class MockConsoleHub extends BaseConsoleHub {
|
||||
private statsTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private bootTimers: ReturnType<typeof setTimeout>[] = [];
|
||||
private unsubscribe: (() => void) | null = null;
|
||||
|
||||
constructor(private readonly provider: MockGameServerProvider) {
|
||||
super();
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (this.statsTimer) return;
|
||||
this.setConnected(true);
|
||||
this.setStatus(this.provider.currentStatus);
|
||||
this.unsubscribe = this.provider.onStatusChange((status) => this.onStatusChange(status));
|
||||
this.statsTimer = setInterval(() => void this.pushStats(), STATS_INTERVAL_MS);
|
||||
this.statsTimer.unref?.();
|
||||
void this.pushStats();
|
||||
this.pushOutput('daemon', 'Attached to mock Pterodactyl console.');
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
if (this.statsTimer) clearInterval(this.statsTimer);
|
||||
this.statsTimer = null;
|
||||
for (const timer of this.bootTimers) clearTimeout(timer);
|
||||
this.bootTimers = [];
|
||||
this.unsubscribe?.();
|
||||
this.unsubscribe = null;
|
||||
this.setConnected(false);
|
||||
}
|
||||
|
||||
private onStatusChange(status: ServerStatus): void {
|
||||
this.setStatus(status);
|
||||
if (status === 'starting') this.playBootScript();
|
||||
if (status === 'stopping') this.pushOutput('daemon', 'Stopping server container.');
|
||||
if (status === 'offline') this.pushOutput('daemon', 'Server marked as offline.');
|
||||
}
|
||||
|
||||
private playBootScript(): void {
|
||||
for (const timer of this.bootTimers) clearTimeout(timer);
|
||||
this.bootTimers = BOOT_SCRIPT.map((step) => {
|
||||
const timer = setTimeout(() => this.pushOutput(step.stream, step.text), step.delayMs);
|
||||
timer.unref?.();
|
||||
return timer;
|
||||
});
|
||||
}
|
||||
|
||||
private async pushStats(): Promise<void> {
|
||||
const resources = await this.provider.getServerResources();
|
||||
this.setStats({
|
||||
status: resources.status,
|
||||
cpuPercent: resources.cpuPercent,
|
||||
memoryBytes: resources.memoryBytes,
|
||||
diskBytes: resources.diskBytes,
|
||||
networkRxBytes: resources.networkRxBytes,
|
||||
networkTxBytes: resources.networkTxBytes,
|
||||
uptimeMs: resources.uptimeMs,
|
||||
at: Date.now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { ApiError } from '../../lib/errors.js';
|
||||
import type {
|
||||
DownloadableFile,
|
||||
GameServerProvider,
|
||||
ProviderServerLimits,
|
||||
ProviderServerResources,
|
||||
ServerFileEntry,
|
||||
} from './types.js';
|
||||
@@ -138,11 +139,11 @@ export class MockGameServerProvider implements GameServerProvider {
|
||||
}
|
||||
|
||||
private transition(to: ServerStatus, after: number, thenTo: ServerStatus) {
|
||||
this.status = to;
|
||||
this.setStatus(to);
|
||||
if (this.transitionTimer) clearTimeout(this.transitionTimer);
|
||||
this.transitionTimer = setTimeout(() => {
|
||||
this.status = thenTo;
|
||||
if (thenTo === 'online') this.startedAt = Date.now();
|
||||
this.setStatus(thenTo);
|
||||
this.transitionTimer = null;
|
||||
}, after);
|
||||
this.transitionTimer.unref?.();
|
||||
@@ -152,6 +153,32 @@ export class MockGameServerProvider implements GameServerProvider {
|
||||
return this.status;
|
||||
}
|
||||
|
||||
/** Exposed so the panel can subscribe to simulated power transitions. */
|
||||
get currentStatus(): ServerStatus {
|
||||
return this.status;
|
||||
}
|
||||
|
||||
onStatusChange(listener: (status: ServerStatus) => void): () => void {
|
||||
this.statusListeners.add(listener);
|
||||
return () => this.statusListeners.delete(listener);
|
||||
}
|
||||
|
||||
private readonly statusListeners = new Set<(status: ServerStatus) => void>();
|
||||
|
||||
private setStatus(status: ServerStatus): void {
|
||||
if (this.status === status) return;
|
||||
this.status = status;
|
||||
for (const listener of this.statusListeners) listener(status);
|
||||
}
|
||||
|
||||
async getServerLimits(): Promise<ProviderServerLimits> {
|
||||
return {
|
||||
cpuLimitPercent: 400,
|
||||
memoryLimitBytes: 8 * 1024 ** 3,
|
||||
diskLimitBytes: 40 * 1024 ** 3,
|
||||
};
|
||||
}
|
||||
|
||||
async getServerResources(): Promise<ProviderServerResources> {
|
||||
const online = this.status === 'online';
|
||||
const wobble = (base: number, spread: number) => base + (Math.random() - 0.5) * spread;
|
||||
@@ -183,8 +210,8 @@ export class MockGameServerProvider implements GameServerProvider {
|
||||
this.transition('stopping', STOP_DELAY_MS, 'starting');
|
||||
setTimeout(() => {
|
||||
if (this.status === 'starting') {
|
||||
this.status = 'online';
|
||||
this.startedAt = Date.now();
|
||||
this.setStatus('online');
|
||||
}
|
||||
}, STOP_DELAY_MS + START_DELAY_MS).unref?.();
|
||||
}
|
||||
@@ -215,6 +242,30 @@ export class MockGameServerProvider implements GameServerProvider {
|
||||
}
|
||||
|
||||
private startupVariables = [
|
||||
{
|
||||
name: 'Server Name',
|
||||
description: 'Templated into config.json at boot by the egg.',
|
||||
envVariable: 'SERVER_NAME',
|
||||
serverValue: 'Mock Reforger Server',
|
||||
defaultValue: 'Arma Reforger Server',
|
||||
isEditable: true,
|
||||
},
|
||||
{
|
||||
name: 'Max Players',
|
||||
description: 'Templated into config.json at boot by the egg.',
|
||||
envVariable: 'MAX_PLAYERS',
|
||||
serverValue: '16',
|
||||
defaultValue: '64',
|
||||
isEditable: true,
|
||||
},
|
||||
{
|
||||
name: 'Scenario ID',
|
||||
description: 'Templated into config.json at boot by the egg.',
|
||||
envVariable: 'SCENARIO_ID',
|
||||
serverValue: '{FDE33AFE2ED7875B}Missions/23_Campaign_Montignac.conf',
|
||||
defaultValue: '{ECC61978EDCC2B5A}Missions/23_Campaign.conf',
|
||||
isEditable: true,
|
||||
},
|
||||
{
|
||||
name: 'Server Password',
|
||||
description: 'Password required to join the server.',
|
||||
|
||||
@@ -5,9 +5,11 @@ import type {
|
||||
ServerStatus,
|
||||
} from '@reforger-panel/shared';
|
||||
import { ApiError } from '../../lib/errors.js';
|
||||
import { mapPowerState } from './console-hub.js';
|
||||
import type {
|
||||
DownloadableFile,
|
||||
GameServerProvider,
|
||||
ProviderServerLimits,
|
||||
ProviderServerResources,
|
||||
ServerFileEntry,
|
||||
} from './types.js';
|
||||
@@ -15,6 +17,8 @@ import type {
|
||||
const DEFAULT_TIMEOUT_MS = 10_000;
|
||||
const DOWNLOAD_TIMEOUT_MS = 30_000;
|
||||
const DEFAULT_MAX_DOWNLOAD_BYTES = 2 * 1024 * 1024;
|
||||
/** Plan limits change rarely, but not never; a minute keeps them honest. */
|
||||
const LIMITS_CACHE_TTL_MS = 60_000;
|
||||
|
||||
type PterodactylOptions = {
|
||||
baseUrl: string;
|
||||
@@ -60,21 +64,6 @@ type PterodactylScheduleAttributes = {
|
||||
};
|
||||
};
|
||||
|
||||
function mapState(state: string): ServerStatus {
|
||||
switch (state) {
|
||||
case 'running':
|
||||
return 'online';
|
||||
case 'offline':
|
||||
return 'offline';
|
||||
case 'starting':
|
||||
return 'starting';
|
||||
case 'stopping':
|
||||
return 'stopping';
|
||||
default:
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pterodactyl Client API provider. Uses only client-scoped endpoints (status,
|
||||
* resources, power, read-only file access). Errors are sanitized: they carry
|
||||
@@ -143,9 +132,9 @@ export class PterodactylProvider implements GameServerProvider {
|
||||
}
|
||||
}
|
||||
|
||||
private async getLimits(serverId: string) {
|
||||
async getServerLimits(serverId: string): Promise<ProviderServerLimits> {
|
||||
const cached = this.limitsCache.get(serverId);
|
||||
if (cached && Date.now() - cached.fetchedAt < 5 * 60_000) return cached;
|
||||
if (cached && Date.now() - cached.fetchedAt < LIMITS_CACHE_TTL_MS) return cached;
|
||||
const data = await this.request<{
|
||||
attributes?: { limits?: { cpu?: number; memory?: number; disk?: number } };
|
||||
}>('server details', `/servers/${encodeURIComponent(serverId)}`);
|
||||
@@ -182,14 +171,14 @@ export class PterodactylProvider implements GameServerProvider {
|
||||
|
||||
const attrs = data.attributes ?? {};
|
||||
const res = attrs.resources ?? {};
|
||||
const limits = await this.getLimits(serverId).catch(() => ({
|
||||
const limits = await this.getServerLimits(serverId).catch(() => ({
|
||||
cpuLimitPercent: null,
|
||||
memoryLimitBytes: null,
|
||||
diskLimitBytes: null,
|
||||
}));
|
||||
|
||||
return {
|
||||
status: mapState(attrs.current_state ?? 'unknown'),
|
||||
status: mapPowerState(attrs.current_state),
|
||||
cpuPercent: res.cpu_absolute ?? 0,
|
||||
cpuLimitPercent: limits.cpuLimitPercent,
|
||||
memoryBytes: res.memory_bytes ?? 0,
|
||||
|
||||
@@ -4,6 +4,12 @@ import type {
|
||||
ServerStatus,
|
||||
} from '@reforger-panel/shared';
|
||||
|
||||
export type ProviderServerLimits = {
|
||||
cpuLimitPercent: number | null;
|
||||
memoryLimitBytes: number | null;
|
||||
diskLimitBytes: number | null;
|
||||
};
|
||||
|
||||
export type ProviderServerResources = {
|
||||
status: ServerStatus;
|
||||
cpuPercent: number;
|
||||
@@ -42,6 +48,12 @@ export type DownloadableFile = {
|
||||
export interface GameServerProvider {
|
||||
getServerStatus(serverId: string): Promise<ServerStatus>;
|
||||
getServerResources(serverId: string): Promise<ProviderServerResources>;
|
||||
/**
|
||||
* Plan limits, cached by the implementation. Split out from resources so a
|
||||
* live websocket stats frame (which carries usage but not limits) can still
|
||||
* be rendered with a denominator.
|
||||
*/
|
||||
getServerLimits(serverId: string): Promise<ProviderServerLimits>;
|
||||
|
||||
startServer(serverId: string): Promise<void>;
|
||||
stopServer(serverId: string): Promise<void>;
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
import WebSocket from 'ws';
|
||||
import type { Logger } from '../../lib/logger.js';
|
||||
import { BaseConsoleHub, mapPowerState, type LiveStats } from './console-hub.js';
|
||||
|
||||
type WebsocketCredentials = { token: string; socket: string };
|
||||
|
||||
type WingsMessage = { event?: string; args?: unknown[] };
|
||||
|
||||
const CREDENTIAL_TIMEOUT_MS = 10_000;
|
||||
const RECONNECT_BASE_MS = 1_000;
|
||||
const RECONNECT_MAX_MS = 30_000;
|
||||
/** Wings tokens last 10 minutes; refresh well before the warning arrives. */
|
||||
const TOKEN_REFRESH_MS = 8 * 60_000;
|
||||
|
||||
/**
|
||||
* Keeps one authenticated Wings websocket open for the server and republishes
|
||||
* everything it emits: console output (including the SteamCMD/mod-download
|
||||
* phase that never reaches the game's own log file), install output, power
|
||||
* state transitions, and resource frames.
|
||||
*
|
||||
* One upstream connection is shared by every panel viewer, and the base class
|
||||
* keeps a line backlog so a browser attaching mid-session immediately sees
|
||||
* context instead of an empty pane.
|
||||
*/
|
||||
export class WingsConsoleHub extends BaseConsoleHub {
|
||||
private socket: WebSocket | null = null;
|
||||
private stopped = true;
|
||||
private reconnectAttempt = 0;
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private refreshTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private readonly baseUrl: string;
|
||||
private readonly fetchImpl: typeof fetch;
|
||||
|
||||
constructor(
|
||||
private readonly options: {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
serverId: string;
|
||||
logger: Logger;
|
||||
fetchImpl?: typeof fetch;
|
||||
},
|
||||
) {
|
||||
super();
|
||||
this.baseUrl = options.baseUrl.replace(/\/$/, '');
|
||||
this.fetchImpl = options.fetchImpl ?? fetch;
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (!this.stopped) return;
|
||||
this.stopped = false;
|
||||
this.refreshTimer = setInterval(() => void this.reauthenticate(), TOKEN_REFRESH_MS);
|
||||
this.refreshTimer.unref?.();
|
||||
void this.connect();
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
this.stopped = true;
|
||||
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
||||
if (this.refreshTimer) clearInterval(this.refreshTimer);
|
||||
this.reconnectTimer = null;
|
||||
this.refreshTimer = null;
|
||||
const socket = this.socket;
|
||||
this.socket = null;
|
||||
this.setConnected(false);
|
||||
socket?.close();
|
||||
}
|
||||
|
||||
/** Fetches a fresh websocket token from the Pterodactyl client API. */
|
||||
private async fetchCredentials(): Promise<WebsocketCredentials> {
|
||||
const url = `${this.baseUrl}/api/client/servers/${encodeURIComponent(
|
||||
this.options.serverId,
|
||||
)}/websocket`;
|
||||
const response = await this.fetchImpl(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.options.apiKey}`,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
signal: AbortSignal.timeout(CREDENTIAL_TIMEOUT_MS),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`websocket credentials returned HTTP ${response.status}`);
|
||||
}
|
||||
const body = (await response.json()) as { data?: { token?: string; socket?: string } };
|
||||
const token = body.data?.token;
|
||||
const socket = body.data?.socket;
|
||||
if (!token || !socket) throw new Error('websocket credentials response was incomplete');
|
||||
return { token, socket };
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
if (this.stopped || this.reconnectTimer) return;
|
||||
const delay = Math.min(RECONNECT_MAX_MS, RECONNECT_BASE_MS * 2 ** this.reconnectAttempt);
|
||||
this.reconnectAttempt = Math.min(this.reconnectAttempt + 1, 5);
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null;
|
||||
void this.connect();
|
||||
}, delay);
|
||||
this.reconnectTimer.unref?.();
|
||||
}
|
||||
|
||||
private async connect(): Promise<void> {
|
||||
if (this.stopped || this.socket) return;
|
||||
let credentials: WebsocketCredentials;
|
||||
try {
|
||||
credentials = await this.fetchCredentials();
|
||||
} catch (error) {
|
||||
this.options.logger.warn(
|
||||
{ err: error instanceof Error ? error.message : String(error) },
|
||||
'wings websocket credentials failed',
|
||||
);
|
||||
this.scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
if (this.stopped) return;
|
||||
|
||||
// Wings only upgrades connections whose Origin matches the panel URL
|
||||
// (see wings router/websocket GetHandler.CheckOrigin), which is why this
|
||||
// uses `ws` rather than Node's built-in WebSocket.
|
||||
const socket = new WebSocket(credentials.socket, { origin: this.baseUrl });
|
||||
this.socket = socket;
|
||||
|
||||
socket.on('open', () => {
|
||||
this.reconnectAttempt = 0;
|
||||
this.setConnected(true);
|
||||
this.send(socket, 'auth', credentials.token);
|
||||
});
|
||||
|
||||
socket.on('message', (raw: WebSocket.RawData) => this.handleMessage(String(raw)));
|
||||
|
||||
socket.on('error', (error: Error) => {
|
||||
this.options.logger.debug({ err: error.message }, 'wings websocket error');
|
||||
});
|
||||
|
||||
socket.on('close', () => {
|
||||
if (this.socket === socket) this.socket = null;
|
||||
this.setConnected(false);
|
||||
this.scheduleReconnect();
|
||||
});
|
||||
}
|
||||
|
||||
private send(socket: WebSocket, event: string, ...args: string[]): void {
|
||||
if (socket.readyState !== WebSocket.OPEN) return;
|
||||
socket.send(JSON.stringify({ event, args }));
|
||||
}
|
||||
|
||||
/** Re-auths the live socket with a fresh token before the old one expires. */
|
||||
private async reauthenticate(): Promise<void> {
|
||||
const socket = this.socket;
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN) return;
|
||||
try {
|
||||
const { token } = await this.fetchCredentials();
|
||||
this.send(socket, 'auth', token);
|
||||
} catch (error) {
|
||||
this.options.logger.debug(
|
||||
{ err: error instanceof Error ? error.message : String(error) },
|
||||
'wings token refresh failed',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private handleMessage(raw: string): void {
|
||||
let message: WingsMessage;
|
||||
try {
|
||||
message = JSON.parse(raw) as WingsMessage;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const arg = typeof message.args?.[0] === 'string' ? (message.args[0] as string) : '';
|
||||
|
||||
switch (message.event) {
|
||||
case 'auth success':
|
||||
// Ask for the recent backlog and an immediate resource frame so the
|
||||
// panel is populated the moment it attaches.
|
||||
if (this.socket) {
|
||||
this.send(this.socket, 'send logs');
|
||||
this.send(this.socket, 'send stats');
|
||||
}
|
||||
break;
|
||||
case 'console output':
|
||||
this.pushOutput('console', arg);
|
||||
break;
|
||||
case 'install output':
|
||||
this.pushOutput('install', arg);
|
||||
break;
|
||||
case 'install started':
|
||||
this.pushOutput('daemon', 'Installation started.');
|
||||
break;
|
||||
case 'install completed':
|
||||
this.pushOutput('daemon', 'Installation completed.');
|
||||
break;
|
||||
case 'daemon message':
|
||||
this.pushOutput('daemon', arg);
|
||||
break;
|
||||
case 'daemon error':
|
||||
case 'jwt error':
|
||||
this.pushOutput('daemon', arg);
|
||||
void this.reauthenticate();
|
||||
break;
|
||||
case 'token expiring':
|
||||
case 'token expired':
|
||||
void this.reauthenticate();
|
||||
break;
|
||||
case 'status':
|
||||
this.setStatus(mapPowerState(arg));
|
||||
break;
|
||||
case 'stats':
|
||||
this.handleStats(arg);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private handleStats(payload: string): void {
|
||||
let parsed: {
|
||||
state?: string;
|
||||
cpu_absolute?: number;
|
||||
memory_bytes?: number;
|
||||
disk_bytes?: number;
|
||||
uptime?: number;
|
||||
network?: { rx_bytes?: number; tx_bytes?: number };
|
||||
};
|
||||
try {
|
||||
parsed = JSON.parse(payload) as typeof parsed;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const stats: LiveStats = {
|
||||
status: mapPowerState(parsed.state),
|
||||
cpuPercent: parsed.cpu_absolute ?? 0,
|
||||
memoryBytes: parsed.memory_bytes ?? 0,
|
||||
diskBytes: parsed.disk_bytes ?? 0,
|
||||
networkRxBytes: parsed.network?.rx_bytes ?? 0,
|
||||
networkTxBytes: parsed.network?.tx_bytes ?? 0,
|
||||
uptimeMs: parsed.uptime ?? 0,
|
||||
at: Date.now(),
|
||||
};
|
||||
this.setStats(stats);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user