@grafana-coda/sandbox-client
v1.9.0
Published
Typed client for the grafana-coda-app v1 sandbox API: session lifecycle, Live terminal protocol, and exec.
Readme
@grafana/coda-client
Typed client for the grafana-coda-app v1 API: ephemeral sandbox VMs, exposed to
any Grafana plugin as an interactive terminal.
The REST calls are the easy half. This package exists for the other half — the handful of details that are invisible, undocumentable, and cost a debugging session each:
- Publishing must go over the socket. Without
{ useSocket: true }a publish becomesPOST /api/live/publish, which can land on a Grafana node that is not running your stream. It fails silently on multi-node Grafana and works perfectly on a laptop. - A session is terminal on error. Handling
errorby showing a message and leaving the subscription open lets Grafana Live reconnect it, reopening a stream against a dead VM in a loop. You cannot document a negative, soCodaSessionis one-shot instead and never exposes the raw observable. - The channel string is opaque. Split it, never construct it: an Alloy scenario id can contain
/, which is exactly why intent travels in the request body. - Frames carry one JSON-encoded string field, because Grafana Live cannot carry arbitrary JSON.
A frame that claims to be one and fails validation is dropped and reported once through
onProtocolError— never rendered, and never fatal. - The availability probe is not always there.
isAppPluginEnabledreached@grafana/runtimein Grafana 13.1, and the host serves that module, so what you built against proves nothing. Where it is absent the import isundefinedand calling it throws synchronously — before a promise exists, so no.catch()sees it and the throw lands in your render.isAvailable()feature-detects instead, which means it answersfalseon a host older than 13.1 even where Coda is installed: the feature hides rather than crashing.
Use
Coda is optional. Detect it at runtime — never declare it as a plugin dependency.
import { CodaClient, isCodaUsable, isNotReady } from '@grafana/coda-client';
const coda = new CodaClient();
if (!(await coda.isAvailable())) {
return; // Absent, or a Grafana too old to say: hide the feature, don't error.
}
const caps = await coda.getCapabilities();
if (!isCodaUsable(caps)) {
// Not set up, or set up and no longer working: `caps.registered` only says a
// credential was stored, and stays true of one Coda has stopped accepting.
return;
}
const session = await coda.createSession({ template: 'vm-aws-sample-app', app: 'nginx' });
// Provisioning starts here, not at createSession — it can take minutes, which
// is why status arrives as events rather than blocking an HTTP call.
session.subscribe({
onStatus: ({ message }) => showProgress(message), // render `message`, don't map `state`
onConnected: () => terminal.focus(),
onOutput: (data) => terminal.write(data),
onError: (err) => showError(err.message),
onClosed: () => teardown(),
});
terminal.onData((d) => session.write(d));
const result = await session.exec('test -f /etc/nginx/nginx.conf', { readyFile: '/tmp/my-setup-done' });
if (result.exitCode === 0) {
pass();
}
await session.close(); // Ends the terminal. The VM survives for reuse.Sending input
write() sends raw input with no newline appended — it is write, not writeln. The backend
writes the payload to the PTY's stdin untransformed, so control sequences work as-is:
session.write('ls -la\n'); // The '\n' is yours to send — nothing appends it.
session.write('\x0f'); // Ctrl+O
session.write('\x1b[A'); // Up arrowThree limits bind, and all three are silent:
- Only after
onConnected. An earlierwrite()is dropped client-side, matching the backend, which answers not-found when no terminal is attached yet. - 8 KB per publish (
limits.maxPublishBytes); a larger payload is dropped backend-side. Chunk a large paste. - Rate-limited per user — burst 60, 30/s sustained. A human typing does not reach it; a synthetic "type this whole block" action must pace itself.
The payload is a JSON string, so it carries UTF-8 text rather than arbitrary bytes: C0 control
characters and ANSI escapes are fine, a lone byte in 0x80–0xFF is not expressible.
Surviving transport loss
CodaSession is one-shot. When the transport breaks — a browser network drop, a laptop
resuming, a Grafana or plugin restart, a relay rollout, a node going away — the session ends and
cannot be revived. Use CodaTerminalLink when you want a terminal that comes back:
import { CodaTerminalLink } from '@grafana/coda-client';
const link = new CodaTerminalLink({ client: coda, intent: { template: 'vm-aws-sample-app', app: 'nginx' } });
link.start({
onOutput: (data) => terminal.write(data),
onConnected: ({ vmId, reattached }) => {
if (reattached) {
// A NEW shell on the SAME VM. Say so, or draw a rule — do not pretend
// the old one continued.
terminal.write('\r\n— reconnected, new shell —\r\n');
}
},
onState: (state) => setBanner(state), // 'reconnecting' is your cue to render one
onReconnect: ({ attempt, delayMs, outageMs, budgetMs }) => showRetry(attempt, delayMs, outageMs / budgetMs),
onEnded: ({ reason, error }) => showEnd(reason, error?.message),
});
terminal.onData((d) => link.write(d));It reattaches by asking for fresh authorized intent — a new POST /v1/sessions — and landing on
the same VM, because the backend resolves VMs by owner. It never retries a consumed session id;
those are single-use. Every reattach re-runs the whole authorization chain server-side, so nothing
is carried forward from the previous session.
It renders nothing. No spinner, no banner, no copy — those are yours. What it gives you is a state, a closed-set reason and numbers.
Four things to design around:
- A reattach is a new shell, not your old one. cwd, exported variables, a running
topand an unsubmitted line are gone. The filesystem survives, because it is the same VM. Preserving live PTY state would need a multiplexer inside the VM image, which is a decision for the Coda service and has not been made — so do not build a UI that implies continuity. (The backend'sptyTermis already the stringtmux-256color. That is aTERMvalue, not tmux running.) - Nothing is replayed, ever. Input sent while disconnected is dropped and there is no queue;
execrejects withterminal_not_connectedrather than waiting, and is never retried for you. A repeatedexecis a repeated side effect on the user's VM. - The session id changes on every reattach. Key your state on
link.vmID. - It gives up. After a five-minute outage budget it ends with
recovery_exhausted, andvm_expired,vm_failed,role_forbiddenand your ownclose()end it at once.isReattachableis exported if you want to predict that rather than re-deriving it.
Tune it with policy — baseDelayMs, maxDelayMs, factor, outageBudgetMs. The defaults are in
CODA_RECOVERY_DEFAULTS, with the reasoning next to them.
Measuring the lifecycle
Pass a sink and the client reports what happened, with no dependency on a metrics library and no opinion about where it goes:
const coda = new CodaClient({
telemetry: (event) => {
switch (event.type) {
case 'phase': // one latency observation, `seconds`
case 'session_end': // exactly one per session
case 'terminal_end': // exactly one per CodaTerminalLink
}
},
});phase covers reserve → first prompt (not merely reserve → VM active), provisioning, and each
reconnect measured over the whole outage. path is warm or cold, because a reused VM and a
fresh one differ by two orders of magnitude and averaging them together makes both numbers useless.
session_end and terminal_end carry a closed-set reason derived from the wire code.
Two rules the types cannot enforce for you:
phase,outcome,pathandreasonare label-safe.seconds,attempt,reconnectsandoutageSecondsare values — put them in a histogram or a sum, never in a label.- Nothing here carries an identifier or a payload, deliberately: no session id, no VM id, no login, no command, no output, no token. Do not add them on the way out. Correlate in traces.
CODA_PHASE_BUCKETS_SECONDS is the recommended histogram boundary set, exported so a consumer and a
dashboard cannot quietly disagree about what a p95 means.
Inspecting what the user already has
listVMs() returns the caller's own VMs — filtered by owner server-side, so it can never return
anyone else's. It is the only place a remaining lifetime is available: sessions carry no expiry, so
join on session.vmID to age the VM behind a live terminal.
import { isUsableVMState } from '@grafana/coda-client';
const vms = await coda.listVMs();
const used = vms.filter((vm) => isUsableVMState(vm.state)).length;
showQuota(used, caps.limits.maxVMsPerUser);
for (const vm of vms) {
// createdAt/expiresAt are RFC 3339. A backend whose upstream omitted them
// sends the zero time, so guard rather than rendering a 2000-year countdown.
const expiry = new Date(vm.expiresAt);
if (expiry.getUTCFullYear() > 1) {
showCountdown(vm.id, expiry.getTime() - Date.now());
}
}
await coda.deleteVM(vms[0].id, true); // Frees a quota slot; still `destroying` for a moment.Use isUsableVMState rather than counting states yourself — it mirrors the backend's own quota
arithmetic exactly, including how it treats a state it does not recognise.
listSessions() returns the caller's sessions, but note they live only in the backend's memory: a
Grafana restart or a settings save empties that list while the VMs survive upstream.
Errors
Every failure is a CodaError with a code from a closed set. Branch on that, never on the message
or the status — several distinct failures share a status:
try {
await session.exec('...');
} catch (err) {
if (isNotReady(err)) {
// terminal_not_connected — wait for onConnected and retry.
// terminal_disconnected — the VM is gone; make a new session.
}
}isRetryable, isUnavailable and isNotReady cover the common branches. The first two are disjoint
on purpose — isRetryable is "waiting may help", isUnavailable is "an administrator must act" — and
isUnavailable includes coda_auth_failed, the plugin's own stored Coda credential being rejected.
That one arrives as a 401 and is not about the caller's session. Treat an unrecognised code as
non-fatal and fall back to the status: new codes can appear within v1.
Two request behaviours follow from that, and both are invisible until they bite:
- There is no toast. Every request sets
showErrorAlert: false, so a failure you do not render is a failure the user never sees. - There is no re-auth prompt, and no retry. Every request presets Grafana's attempt counter
(
retry: 1), which opts out of core's global 401 handling. Without it a rejected plugin credential costs a spurious/api/login/pingand a second failing round trip, as though the user's session had expired. Nothing is retried on your behalf: back off yourself whenisRetryablesays to.
Versioning
The major tracks the API major, not the plugin version: 1.x speaks v1.
This client is compiled into your bundle at your build time, while the backend is whatever the
operator installed — so being newer than the backend is normal. It never requires a field
/v1/capabilities does not advertise, and falls back to V1_DEFAULTS.
Feature-detect rather than compare versions. A response field you do not get means "use the v1
default"; a request field the backend does not know is ignored, so its absence changes the answer
while still returning 200. Anything of that kind is named in capabilities.features:
import { codaSupports } from '@grafana/coda-client';
if (!codaSupports(caps, 'exec.readyFile')) {
// This backend may run the command ungated. Degrade deliberately.
}There is deliberately no pluginVersion floor to check: it would refuse a backend that has the
behaviour and admit one that has regressed it.
codaSessionEligibility(caps) answers the other half — whether this caller may spend VM quota —
without spending a request to be told 403 role_forbidden. It returns 'unknown' on a backend too old
to say, which means "attempt the call", not "assume yes".
Nothing here validates a response, on purpose. Rejecting a field this client does not recognise
would break the backend's additive guarantee, and requiring one an older backend does not send would
break the other direction — so the types are held against bytes the real handlers produced, in CI:
pkg/plugin/contract_fixtures_test.go writes the goldens and src/contract/wire-contract.test.ts
checks these interfaces against them. See
docs/API.md.
Developing
Built with plain tsc, deliberately outside the plugin's webpack build (.config/ is tool-managed
and scoped to src/). Publishing is documented in RELEASING.md. Releases go out
under the interim name @grafana-coda/sandbox-client until @grafana/coda-client can be claimed —
alias it back to the real specifier in one dependency line and no source file mentions the interim
name.
npm run typecheck:packages # from the repo root
npm run build:packages
npx jest packages/