This commit is contained in:
SowinskiBraeden committed 2026-09-05 13:10:36 -07:00
1 parent 3ecced51c8
commit 3adb45886a
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,
+12
View File
@@ -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);
}
}