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:
parent
ec30c69c3e
commit
88206205fe
11 changed files with 1458 additions and 15 deletions
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue