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

@ -1,12 +1,13 @@
<script lang="ts">
import AppearanceSettings from './settings/AppearanceSettings.svelte';
import AgentSettings from './settings/AgentSettings.svelte';
import SecuritySettings from './settings/SecuritySettings.svelte';
import ProjectSettings from './settings/ProjectSettings.svelte';
import OrchestrationSettings from './settings/OrchestrationSettings.svelte';
import AdvancedSettings from './settings/AdvancedSettings.svelte';
import MarketplaceTab from './settings/MarketplaceTab.svelte';
import KeyboardSettings from './settings/KeyboardSettings.svelte';
import AppearanceSettings from './settings/AppearanceSettings.svelte';
import AgentSettings from './settings/AgentSettings.svelte';
import SecuritySettings from './settings/SecuritySettings.svelte';
import ProjectSettings from './settings/ProjectSettings.svelte';
import OrchestrationSettings from './settings/OrchestrationSettings.svelte';
import AdvancedSettings from './settings/AdvancedSettings.svelte';
import MarketplaceTab from './settings/MarketplaceTab.svelte';
import KeyboardSettings from './settings/KeyboardSettings.svelte';
import RemoteMachinesSettings from './settings/RemoteMachinesSettings.svelte';
interface Props {
open: boolean;
@ -15,7 +16,7 @@
let { open, onClose }: Props = $props();
type CategoryId = 'appearance' | 'agents' | 'security' | 'projects' | 'orchestration' | 'advanced' | 'marketplace' | 'keyboard';
type CategoryId = 'appearance' | 'agents' | 'security' | 'projects' | 'orchestration' | 'machines' | 'advanced' | 'marketplace' | 'keyboard';
interface Category {
id: CategoryId;
@ -29,6 +30,7 @@
{ id: 'security', label: 'Security', icon: '🔒' },
{ id: 'projects', label: 'Projects', icon: '📁' },
{ id: 'orchestration', label: 'Orchestration', icon: '⚙' },
{ id: 'machines', label: 'Machines', icon: '🖥' },
{ id: 'keyboard', label: 'Keyboard', icon: '⌨' },
{ id: 'advanced', label: 'Advanced', icon: '🔧' },
{ id: 'marketplace', label: 'Marketplace', icon: '🛒' },
@ -94,6 +96,8 @@
<ProjectSettings />
{:else if activeCategory === 'orchestration'}
<OrchestrationSettings />
{:else if activeCategory === 'machines'}
<RemoteMachinesSettings />
{:else if activeCategory === 'advanced'}
<AdvancedSettings />
{:else if activeCategory === 'keyboard'}

View file

@ -0,0 +1,139 @@
<script lang="ts">
/**
* Full-screen splash overlay shown on app startup.
* Auto-dismisses when the `ready` prop becomes true.
* Fade-out transition: 300ms opacity.
*/
interface Props {
/** Set to true when app initialization is complete. */
ready: boolean;
}
let { ready }: Props = $props();
let visible = $state(true);
let fading = $state(false);
// When ready flips to true, start fade-out then hide
$effect(() => {
if (ready && visible && !fading) {
fading = true;
setTimeout(() => {
visible = false;
}, 300);
}
});
</script>
<div
class="splash"
style:display={visible ? 'flex' : 'none'}
class:fading
role="status"
aria-label="Loading application"
>
<div class="splash-content">
<div class="logo-text" aria-hidden="true">AGOR</div>
<div class="version">v0.0.1</div>
<div class="loading-indicator">
<span class="dot"></span>
<span class="dot"></span>
<span class="dot"></span>
</div>
<div class="loading-label">Loading...</div>
</div>
</div>
<style>
.splash {
position: fixed;
inset: 0;
z-index: 10000;
background: var(--ctp-base);
display: flex;
align-items: center;
justify-content: center;
opacity: 1;
transition: opacity 300ms ease-out;
}
.splash.fading {
opacity: 0;
pointer-events: none;
}
.splash-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
}
.logo-text {
font-family: 'Inter', system-ui, sans-serif;
font-weight: 900;
font-size: 4rem;
letter-spacing: 0.3em;
background: linear-gradient(
135deg,
var(--ctp-mauve),
var(--ctp-blue),
var(--ctp-sapphire),
var(--ctp-teal)
);
background-size: 300% 300%;
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
animation: gradient-shift 3s ease infinite;
user-select: none;
}
@keyframes gradient-shift {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
.version {
font-size: 0.875rem;
color: var(--ctp-overlay0);
font-family: var(--term-font-family, monospace);
letter-spacing: 0.05em;
}
.loading-indicator {
display: flex;
gap: 0.375rem;
margin-top: 0.5rem;
}
.dot {
width: 0.375rem;
height: 0.375rem;
border-radius: 50%;
background: var(--ctp-overlay1);
animation: pulse-dot 1.2s ease-in-out infinite;
}
.dot:nth-child(2) { animation-delay: 0.2s; }
.dot:nth-child(3) { animation-delay: 0.4s; }
@keyframes pulse-dot {
0%, 80%, 100% { opacity: 0.3; transform: scale(0.8); }
40% { opacity: 1; transform: scale(1); }
}
.loading-label {
font-size: 0.75rem;
color: var(--ctp-subtext0);
font-family: var(--ui-font-family, system-ui, sans-serif);
animation: pulse-text 2s ease-in-out infinite;
}
@keyframes pulse-text {
0%, 100% { opacity: 0.5; }
50% { opacity: 1; }
}
</style>

View file

@ -0,0 +1,89 @@
/**
* Svelte 5 rune store for remote machine state.
*
* Tracks connected machines, their status, and latency.
* Driven by remote.statusChange and remote.event messages from the Bun process.
*/
// ── Types ──────────────────────────────────────────────────────────────────
export type MachineStatus = "connecting" | "connected" | "disconnected" | "error";
export interface RemoteMachine {
machineId: string;
label: string;
url: string;
status: MachineStatus;
latencyMs: number | null;
error?: string;
}
// ── Store ──────────────────────────────────────────────────────────────────
let machines = $state<RemoteMachine[]>([]);
/** Add a machine to the tracked list. */
export function addMachine(
machineId: string,
url: string,
label?: string,
): void {
// Prevent duplicates
if (machines.some((m) => m.machineId === machineId)) return;
machines = [
...machines,
{
machineId,
label: label ?? url,
url,
status: "connecting",
latencyMs: null,
},
];
}
/** Remove a machine from tracking. */
export function removeMachine(machineId: string): void {
machines = machines.filter((m) => m.machineId !== machineId);
}
/** Update the status of a tracked machine. */
export function updateMachineStatus(
machineId: string,
status: MachineStatus,
error?: string,
): void {
machines = machines.map((m) =>
m.machineId === machineId
? { ...m, status, error: error ?? undefined }
: m,
);
}
/** Update the measured latency for a machine. */
export function updateMachineLatency(
machineId: string,
latencyMs: number,
): void {
machines = machines.map((m) =>
m.machineId === machineId ? { ...m, latencyMs } : m,
);
}
/** Get all tracked machines (reactive). */
export function getMachines(): RemoteMachine[] {
return machines;
}
/** Get a single machine by ID (reactive). */
export function getMachineStatus(
machineId: string,
): RemoteMachine | undefined {
return machines.find((m) => m.machineId === machineId);
}
/** Get count of connected machines (reactive). */
export function getConnectedCount(): number {
return machines.filter((m) => m.status === "connected").length;
}

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>

View file

@ -0,0 +1,45 @@
/**
* Frontend telemetry bridge.
*
* Provides tel.info(), tel.warn(), tel.error() convenience methods that
* forward structured log events to the Bun process via RPC for tracing.
* No browser OTEL SDK needed (WebKitGTK incompatible).
*/
import { appRpc } from "./rpc.ts";
type LogLevel = "info" | "warn" | "error";
type Attributes = Record<string, string | number | boolean>;
function sendLog(level: LogLevel, message: string, attributes?: Attributes): void {
try {
appRpc?.request["telemetry.log"]({
level,
message,
attributes: attributes ?? {},
}).catch((err: unknown) => {
// Best-effort — never block the caller
console.warn("[tel-bridge] RPC failed:", err);
});
} catch {
// RPC not yet initialized — swallow silently
}
}
/** Frontend telemetry API. All calls are fire-and-forget. */
export const tel = {
/** Log an informational event. */
info(message: string, attributes?: Attributes): void {
sendLog("info", message, attributes);
},
/** Log a warning event. */
warn(message: string, attributes?: Attributes): void {
sendLog("warn", message, attributes);
},
/** Log an error event. */
error(message: string, attributes?: Attributes): void {
sendLog("error", message, attributes);
},
} as const;