feat(electrobun): multi-machine relay + OTEL telemetry

Multi-machine relay:
- relay-client.ts: WebSocket client for agor-relay with token auth,
  exponential backoff (1s-30s), TCP probe, heartbeat (15s ping)
- machines-store.svelte.ts: remote machine state tracking
- RemoteMachinesSettings.svelte: machine list, add/connect/disconnect UI
- 7 RPC types (remote.connect/disconnect/list/send/status + events)

Telemetry:
- telemetry.ts: OTEL spans + OTLP/HTTP export to Tempo,
  controlled by AGOR_OTLP_ENDPOINT env var
- telemetry-bridge.ts: tel.info/warn/error frontend convenience API
- telemetry.log RPC for frontend→Bun tracing
This commit is contained in:
Hibryda 2026-03-22 01:46:03 +01:00
parent ec30c69c3e
commit 88206205fe
11 changed files with 1458 additions and 15 deletions

View file

@ -18,9 +18,10 @@
let relayUrls = $state('');
let connTimeout = $state(30);
let appVersion = $state('3.0.0-dev');
let appVersion = $state('...');
let updateChecking = $state(false);
let updateResult = $state<string | null>(null);
let updateUrl = $state<string | null>(null);
let importError = $state<string | null>(null);
function persist(key: string, value: string) {
@ -39,13 +40,26 @@
persist('plugin_states', JSON.stringify(states));
}
function checkForUpdates() {
async function checkForUpdates() {
if (!appRpc) return;
updateChecking = true;
updateResult = null;
setTimeout(() => {
updateUrl = null;
try {
const result = await appRpc.request['updater.check']({});
if (result.error) {
updateResult = `Check failed: ${result.error}`;
} else if (result.available) {
updateResult = `Update available: v${result.version}`;
updateUrl = result.downloadUrl;
} else {
updateResult = `Already up to date (v${appVersion})`;
}
} catch (err) {
updateResult = `Check failed: ${err instanceof Error ? err.message : 'unknown error'}`;
} finally {
updateChecking = false;
updateResult = 'Already up to date (v3.0.0-dev)';
}, 1200);
}
}
async function handleExport() {
@ -99,6 +113,12 @@
plugins = plugins.map(p => ({ ...p, enabled: states[p.id] ?? p.enabled }));
} catch { /* ignore */ }
}
// Fetch real version from backend
try {
const { version } = await appRpc.request['updater.getVersion']({});
appVersion = version;
} catch { /* keep default */ }
}
onMount(loadSettings);
@ -159,7 +179,10 @@
</button>
</div>
{#if updateResult}
<p class="update-result">{updateResult}</p>
<p class="update-result" class:has-update={updateUrl}>{updateResult}</p>
{/if}
{#if updateUrl}
<a class="download-link" href={updateUrl} target="_blank" rel="noopener noreferrer">Download update</a>
{/if}
<h3 class="sh" style="margin-top: 0.75rem;">Settings Data</h3>
@ -211,6 +234,9 @@
.update-row { display: flex; align-items: center; gap: 0.625rem; }
.version-label { font-size: 0.75rem; color: var(--ctp-overlay1); font-family: var(--term-font-family, monospace); }
.update-result { font-size: 0.75rem; color: var(--ctp-green); margin: 0.125rem 0 0; }
.update-result.has-update { color: var(--ctp-peach); }
.download-link { font-size: 0.75rem; color: var(--ctp-blue); text-decoration: none; margin-top: 0.125rem; }
.download-link:hover { text-decoration: underline; color: var(--ctp-sapphire); }
.import-error { font-size: 0.75rem; color: var(--ctp-red); margin: 0.125rem 0 0; }
.data-row { display: flex; gap: 0.5rem; }

View file

@ -0,0 +1,269 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { appRpc } from '../rpc.ts';
import {
getMachines, addMachine, removeMachine, updateMachineStatus,
type RemoteMachine, type MachineStatus,
} from '../machines-store.svelte.ts';
// ── Form state ───────────────────────────────────────────────────────────
let newUrl = $state('');
let newToken = $state('');
let newLabel = $state('');
let error = $state<string | null>(null);
let loading = $state(false);
// ── Derived machine list ────────────────────────────────────────────────
let machines = $derived(getMachines());
// ── Poll for machine list from Bun ──────────────────────────────────────
let pollTimer: ReturnType<typeof setInterval> | null = null;
async function refreshMachines() {
if (!appRpc) return;
try {
const { machines: list } = await appRpc.request['remote.list']({});
for (const m of list) {
const existing = getMachines().find(x => x.machineId === m.machineId);
if (!existing) {
addMachine(m.machineId, m.url, m.label);
}
updateMachineStatus(m.machineId, m.status as MachineStatus);
}
} catch { /* best-effort */ }
}
onMount(() => {
refreshMachines();
pollTimer = setInterval(refreshMachines, 5_000);
});
onDestroy(() => {
if (pollTimer) clearInterval(pollTimer);
});
// ── Actions ──────────────────────────────────────────────────────────────
async function handleConnect() {
if (!newUrl.trim() || !newToken.trim()) {
error = 'URL and token are required.';
return;
}
error = null;
loading = true;
try {
const res = await appRpc.request['remote.connect']({
url: newUrl.trim(),
token: newToken.trim(),
label: newLabel.trim() || undefined,
});
if (res.ok && res.machineId) {
addMachine(res.machineId, newUrl.trim(), newLabel.trim() || undefined);
newUrl = '';
newToken = '';
newLabel = '';
} else {
error = res.error ?? 'Connection failed';
}
} catch (err) {
error = err instanceof Error ? err.message : 'Connection failed';
} finally {
loading = false;
}
}
async function handleDisconnect(machineId: string) {
try {
await appRpc.request['remote.disconnect']({ machineId });
updateMachineStatus(machineId, 'disconnected');
} catch { /* ignore */ }
}
async function handleRemove(machineId: string) {
try {
await appRpc.request['remote.disconnect']({ machineId });
} catch { /* may already be disconnected */ }
removeMachine(machineId);
}
function statusColor(status: MachineStatus): string {
switch (status) {
case 'connected': return 'var(--ctp-green)';
case 'connecting': return 'var(--ctp-yellow)';
case 'error': return 'var(--ctp-red)';
case 'disconnected': return 'var(--ctp-overlay0)';
}
}
function statusLabel(status: MachineStatus): string {
return status.charAt(0).toUpperCase() + status.slice(1);
}
function formatLatency(ms: number | null): string {
if (ms === null) return '--';
return `${ms}ms`;
}
</script>
<div class="section">
<h3 class="sh">Connected Machines</h3>
{#if machines.length === 0}
<p class="empty">No remote machines configured.</p>
{:else}
<div class="machine-list">
{#each machines as m (m.machineId)}
<div class="machine-row">
<div class="machine-info">
<span class="status-dot" style:background={statusColor(m.status)}></span>
<div class="machine-detail">
<span class="machine-label">{m.label}</span>
<span class="machine-url">{m.url}</span>
</div>
</div>
<div class="machine-meta">
<span class="latency">{formatLatency(m.latencyMs)}</span>
<span class="status-text" style:color={statusColor(m.status)}>
{statusLabel(m.status)}
</span>
</div>
<div class="machine-actions">
{#if m.status === 'connected'}
<button class="action-btn danger" onclick={() => handleDisconnect(m.machineId)}>
Disconnect
</button>
{:else if m.status === 'disconnected' || m.status === 'error'}
<button class="action-btn" onclick={() => {
appRpc.request['remote.connect']({
url: m.url, token: '', label: m.label,
}).catch(() => {});
}}>Reconnect</button>
{/if}
<button class="action-btn secondary" onclick={() => handleRemove(m.machineId)}
title="Remove machine">
x
</button>
</div>
</div>
{/each}
</div>
{/if}
<h3 class="sh" style="margin-top: 1rem;">Add Machine</h3>
<div class="field">
<label class="lbl" for="rm-label">Label (optional)</label>
<input id="rm-label" class="text-in" bind:value={newLabel}
placeholder="e.g. build-server" />
</div>
<div class="field">
<label class="lbl" for="rm-url">Relay URL</label>
<input id="rm-url" class="text-in" bind:value={newUrl}
placeholder="wss://relay.example.com:9750" />
</div>
<div class="field">
<label class="lbl" for="rm-token">Auth token</label>
<input id="rm-token" class="text-in" type="password" bind:value={newToken}
placeholder="Bearer token" />
</div>
{#if error}
<p class="error-msg">{error}</p>
{/if}
<button class="connect-btn" onclick={handleConnect} disabled={loading}>
{loading ? 'Connecting...' : 'Connect'}
</button>
</div>
<style>
.section { display: flex; flex-direction: column; gap: 0.5rem; }
.sh { margin: 0.125rem 0; font-size: 0.6875rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: var(--ctp-overlay0); }
.lbl { font-size: 0.75rem; color: var(--ctp-subtext0); }
.field { display: flex; flex-direction: column; gap: 0.2rem; }
.empty { font-size: 0.8rem; color: var(--ctp-overlay0); margin: 0; font-style: italic; }
.text-in {
padding: 0.3rem 0.5rem; background: var(--ctp-surface0);
border: 1px solid var(--ctp-surface1); border-radius: 0.25rem;
color: var(--ctp-text); font-size: 0.8125rem;
font-family: var(--ui-font-family);
}
.text-in:focus { outline: none; border-color: var(--ctp-blue); }
.machine-list { display: flex; flex-direction: column; gap: 0.25rem; }
.machine-row {
display: flex; align-items: center; gap: 0.5rem;
padding: 0.375rem 0.5rem; background: var(--ctp-surface0);
border: 1px solid var(--ctp-surface1); border-radius: 0.25rem;
}
.machine-info { display: flex; align-items: center; gap: 0.5rem; flex: 1; min-width: 0; }
.status-dot {
width: 8px; height: 8px; border-radius: 50%;
flex-shrink: 0;
}
.machine-detail {
display: flex; flex-direction: column; min-width: 0;
}
.machine-label {
font-size: 0.8rem; color: var(--ctp-text); font-weight: 500;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.machine-url {
font-size: 0.6875rem; color: var(--ctp-overlay0);
font-family: var(--term-font-family, monospace);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.machine-meta {
display: flex; flex-direction: column; align-items: flex-end;
gap: 0.125rem; flex-shrink: 0;
}
.latency {
font-size: 0.6875rem; color: var(--ctp-overlay1);
font-family: var(--term-font-family, monospace);
}
.status-text { font-size: 0.6875rem; font-weight: 500; }
.machine-actions { display: flex; gap: 0.25rem; flex-shrink: 0; }
.action-btn {
padding: 0.2rem 0.5rem; background: var(--ctp-surface0);
border: 1px solid var(--ctp-surface1); border-radius: 0.25rem;
color: var(--ctp-subtext1); font-size: 0.7rem; cursor: pointer;
font-family: var(--ui-font-family);
transition: background 0.12s, color 0.12s;
}
.action-btn:hover { background: var(--ctp-surface1); color: var(--ctp-text); }
.action-btn.secondary { color: var(--ctp-overlay0); }
.action-btn.danger { color: var(--ctp-red); border-color: color-mix(in srgb, var(--ctp-red) 30%, var(--ctp-surface1)); }
.action-btn.danger:hover { background: color-mix(in srgb, var(--ctp-red) 15%, var(--ctp-surface0)); }
.error-msg { font-size: 0.75rem; color: var(--ctp-red); margin: 0; }
.connect-btn {
align-self: flex-start;
padding: 0.3rem 1rem; background: var(--ctp-blue);
border: none; border-radius: 0.25rem;
color: var(--ctp-base); font-size: 0.8rem; font-weight: 600;
cursor: pointer; font-family: var(--ui-font-family);
transition: opacity 0.12s;
}
.connect-btn:hover:not(:disabled) { opacity: 0.85; }
.connect-btn:disabled { opacity: 0.4; cursor: not-allowed; }
</style>