Skip to content

Realtime Events

Polling the REST API for live state is wasteful. For anything realtime — console output, power transitions, metric ticks — subscribe to a stream.

Browser-facing realtime is Server-Sent Events (SSE). The panel’s WebSocket gateway at /ws carries the node-agent control plane and plugin clients, not the console or metrics views.

Channel Transport Endpoint
Console output SSE GET /api/servers/:serverId/console/stream
Console input REST POST /api/servers/:serverId/console/command
Server events (state, backups, tasks, alerts) SSE GET /api/servers/:serverId/events
All your servers SSE GET /api/servers/all-servers/events
Server metrics SSE GET /api/servers/:serverId/metrics/stream
Admin entity events SSE GET /api/admin/events
Node agents and plugins WebSocket GET /ws

Every stream sends a : heartbeat comment and a named ping event ({ "t": <epoch ms> }) every 25 s, so proxies keep the connection open and your client can detect a half-open one.

  • Console stream, console command, and metrics stream accept a session cookie or an API key (Authorization: Bearer) — the same auth as REST. The console requires console.read and the metrics stream server.read on that server; the owner, a subuser with the permission, and admins all qualify. An API key must also hold the permission itself, not just its owner.
  • Server events (/events, /all-servers/events) and admin events require a session cookie; an API key is not accepted there.
  • Admin events additionally requires admin.read.

Unauthenticated or unauthorized subscribers get an HTTP status before any data flows (401, 403, 404, or 503 below).

Long-lived streams are capped per backend process. Over the cap the connection is refused with 503:

Code Cap
CONSOLE_VIEWER_LIMIT_REACHED 50 concurrent console viewers per server
SSE_SUBSCRIBER_LIMIT_REACHED 100 concurrent event subscribers per server
SSE_SUBSCRIBER_LIMIT_REACHED 200 concurrent subscribers on /api/servers/all-servers/events, which shares its registry with the metrics streams
SSE_SUBSCRIBER_LIMIT_REACHED 150 concurrent subscribers on /api/admin/events
Event Payload
connected { serverId, timestamp }
console_output { serverId, stream, data, timestamp, logId } — stream is stdout/stderr; logId is the persisted log row id
eula_required { serverId, serverUuid, eulaText, timestamp }
ping { t } — epoch milliseconds
error { serverId?, error }

The metrics stream sends connected, then a resource_stats event on every agent tick (plus one immediately on subscribe).

The SSE event name (what addEventListener receives) is the type discriminator; most payloads also carry a type field. Switch on the event name and ignore unknown names — new event types are added over time and must not break your client.

Server event names:

  • server_state_update — power/state transitions
  • resource_stats — CPU, memory, disk, network tick
  • backup_started, backup_complete, backup_restore_started, backup_restore_complete, backup_restore_failed, backup_delete_started, backup_delete_complete, backup_updated, backup_deleted
  • task_progress, task_complete, task_created, task_updated, task_deleted
  • server_operation_progress — dense install/transfer/clone progress
  • server_files_changed, storage_resize_complete, eula_required, alert
  • server_created, server_updated, server_deleted, server_suspended, server_unsuspended, clone_failed
  • database_created, database_deleted, database_password_rotated
  • mod_install_complete, mod_uninstall_complete, mod_update_complete and the matching plugin_* events
  • permissions_updated — the caller’s permission set changed
  • resync — the panel lost events (bus reconnect); re-fetch authoritative state

The admin stream (/api/admin/events) carries entity-level create/update/delete events (user_*, node_*, template_*, alert_*, role_*, api_key_*, location_*, nest_*, task_*, database_*, network_*, allocation_*, agent_update_*, node_metrics_updated, audit_log_created, auth_lockout_created/auth_lockout_cleared, panel_update_*, …).

  1. Let EventSource reconnect. There is no server-side resume cursor: treat every reconnect as fresh and re-fetch authoritative state over REST.
  2. Handle the HTTP failures (401, 403, 503) instead of reconnect-spinning; a refused stream will not recover without a change on your side.
  3. One connection per view, then fan out locally. The panel’s own SSE hub already shares one EventSource per URL across all widgets.
  4. Fall back to REST polling only when streams are unavailable, and say so in your UI.
const src = new EventSource(
`/api/servers/${serverId}/console/stream`,
{ withCredentials: true },
);
src.addEventListener('console_output', ({ data }) => {
appendOutput(JSON.parse(data).data);
});
src.addEventListener('eula_required', ({ data }) => {
showEula(JSON.parse(data).eulaText);
});
src.addEventListener('error', handleStreamError);
// Sending input is a normal POST, not a socket frame:
await fetch(`/api/servers/${serverId}/console/command`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${key}` },
body: JSON.stringify({ command: 'say Hello' }),
});

The command body is { "command": "..." } (1–4096 characters) and requires console.write.

GET /ws is the bidirectional channel:

  • Nodes connect as /ws?nodeId=<id> and authenticate with the node’s agent API key in the node_handshake message; a socket that has not authenticated within 10 s is closed.
  • Plugin/browser clients connect to the same path and authenticate with the session cookie on the upgrade, or with a client_handshake message carrying a session token within the first 3 s. Plugin traffic is addressed by message type plugin:{pluginName}:{eventType} with a data payload.
  • API reference — the REST endpoints the streams pair with
  • Rate limits — why SSE streams bypass the global bucket
  • Errors — 401, 403, and 503 handling