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
|
|
@ -10,9 +10,15 @@ import { SidecarManager } from "./sidecar-manager.ts";
|
|||
import type { PtyRPCSchema } from "../shared/pty-rpc-schema.ts";
|
||||
import { randomUUID } from "crypto";
|
||||
import { SearchDb } from "./search-db.ts";
|
||||
import { checkForUpdates, getLastCheckTimestamp } from "./updater.ts";
|
||||
import { RelayClient } from "./relay-client.ts";
|
||||
import { initTelemetry, telemetry } from "./telemetry.ts";
|
||||
import { homedir } from "os";
|
||||
import { join } from "path";
|
||||
|
||||
/** Current app version — sourced from electrobun.config.ts at build time. */
|
||||
const APP_VERSION = "0.0.1";
|
||||
|
||||
const DEV_SERVER_PORT = 9760; // Project convention: 9700+ range
|
||||
const DEV_SERVER_URL = `http://localhost:${DEV_SERVER_PORT}`;
|
||||
|
||||
|
|
@ -21,8 +27,12 @@ const DEV_SERVER_URL = `http://localhost:${DEV_SERVER_PORT}`;
|
|||
const ptyClient = new PtyClient();
|
||||
const sidecarManager = new SidecarManager();
|
||||
const searchDb = new SearchDb();
|
||||
const relayClient = new RelayClient();
|
||||
const PLUGINS_DIR = join(homedir(), ".config", "agor", "plugins");
|
||||
|
||||
// Initialize telemetry (console-only unless AGOR_OTLP_ENDPOINT is set)
|
||||
initTelemetry();
|
||||
|
||||
async function connectToDaemon(retries = 5, delayMs = 500): Promise<boolean> {
|
||||
for (let attempt = 1; attempt <= retries; attempt++) {
|
||||
try {
|
||||
|
|
@ -874,6 +884,103 @@ const rpc = BrowserView.defineRPC<PtyRPCSchema>({
|
|||
return { ok: false, content: "", error };
|
||||
}
|
||||
},
|
||||
|
||||
// ── Updater handlers ──────────────────────────────────────────────────
|
||||
|
||||
"updater.check": async () => {
|
||||
try {
|
||||
const result = await checkForUpdates(APP_VERSION);
|
||||
return { ...result, error: undefined };
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err.message : String(err);
|
||||
console.error("[updater.check]", err);
|
||||
return {
|
||||
available: false,
|
||||
version: "",
|
||||
downloadUrl: "",
|
||||
releaseNotes: "",
|
||||
checkedAt: Date.now(),
|
||||
error,
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
"updater.getVersion": () => {
|
||||
return {
|
||||
version: APP_VERSION,
|
||||
lastCheck: getLastCheckTimestamp(),
|
||||
};
|
||||
},
|
||||
|
||||
// ── Remote machine (relay) handlers ──────────────────────────────────
|
||||
|
||||
"remote.connect": async ({ url, token, label }) => {
|
||||
try {
|
||||
const machineId = await relayClient.connect(url, token, label);
|
||||
return { ok: true, machineId };
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err.message : String(err);
|
||||
console.error("[remote.connect]", err);
|
||||
return { ok: false, error };
|
||||
}
|
||||
},
|
||||
|
||||
"remote.disconnect": ({ machineId }) => {
|
||||
try {
|
||||
relayClient.disconnect(machineId);
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err.message : String(err);
|
||||
console.error("[remote.disconnect]", err);
|
||||
return { ok: false, error };
|
||||
}
|
||||
},
|
||||
|
||||
"remote.list": () => {
|
||||
try {
|
||||
return { machines: relayClient.listMachines() };
|
||||
} catch (err) {
|
||||
console.error("[remote.list]", err);
|
||||
return { machines: [] };
|
||||
}
|
||||
},
|
||||
|
||||
"remote.send": ({ machineId, command, payload }) => {
|
||||
try {
|
||||
relayClient.sendCommand(machineId, command, payload);
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err.message : String(err);
|
||||
console.error("[remote.send]", err);
|
||||
return { ok: false, error };
|
||||
}
|
||||
},
|
||||
|
||||
"remote.status": ({ machineId }) => {
|
||||
try {
|
||||
const info = relayClient.getStatus(machineId);
|
||||
if (!info) {
|
||||
return { status: "disconnected" as const, latencyMs: null, error: "Machine not found" };
|
||||
}
|
||||
return { status: info.status, latencyMs: info.latencyMs };
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err.message : String(err);
|
||||
console.error("[remote.status]", err);
|
||||
return { status: "error" as const, latencyMs: null, error };
|
||||
}
|
||||
},
|
||||
|
||||
// ── Telemetry handler ────────────────────────────────────────────────
|
||||
|
||||
"telemetry.log": ({ level, message, attributes }) => {
|
||||
try {
|
||||
telemetry.log(level, `[frontend] ${message}`, attributes ?? {});
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
console.error("[telemetry.log]", err);
|
||||
return { ok: false };
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
messages: {},
|
||||
|
|
@ -900,6 +1007,29 @@ ptyClient.on("session_closed", (msg) => {
|
|||
}
|
||||
});
|
||||
|
||||
// ── Forward relay events to WebView ─────────────────────────────────────────
|
||||
|
||||
relayClient.onEvent((machineId, event) => {
|
||||
try {
|
||||
rpc.send["remote.event"]({
|
||||
machineId,
|
||||
eventType: event.type,
|
||||
sessionId: event.sessionId,
|
||||
payload: event.payload,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[remote.event] forward error:", err);
|
||||
}
|
||||
});
|
||||
|
||||
relayClient.onStatus((machineId, status, error) => {
|
||||
try {
|
||||
rpc.send["remote.statusChange"]({ machineId, status, error });
|
||||
} catch (err) {
|
||||
console.error("[remote.statusChange] forward error:", err);
|
||||
}
|
||||
});
|
||||
|
||||
// ── App window ───────────────────────────────────────────────────────────────
|
||||
|
||||
async function getMainViewUrl(): Promise<string> {
|
||||
|
|
|
|||
343
ui-electrobun/src/bun/relay-client.ts
Normal file
343
ui-electrobun/src/bun/relay-client.ts
Normal file
|
|
@ -0,0 +1,343 @@
|
|||
/**
|
||||
* WebSocket client for connecting to agor-relay instances.
|
||||
*
|
||||
* Features:
|
||||
* - Token-based auth handshake (Bearer header)
|
||||
* - Exponential backoff reconnection (1s–30s cap)
|
||||
* - TCP probe before full WS upgrade on reconnect
|
||||
* - Per-connection command routing
|
||||
* - Event forwarding to webview via callback
|
||||
*/
|
||||
|
||||
import { randomUUID } from "crypto";
|
||||
import { Socket } from "net";
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export type ConnectionStatus = "connecting" | "connected" | "disconnected" | "error";
|
||||
|
||||
export interface RelayCommand {
|
||||
id: string;
|
||||
type: string;
|
||||
payload: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface RelayEvent {
|
||||
type: string;
|
||||
sessionId?: string;
|
||||
machineId?: string;
|
||||
payload?: unknown;
|
||||
}
|
||||
|
||||
export type EventCallback = (machineId: string, event: RelayEvent) => void;
|
||||
export type StatusCallback = (machineId: string, status: ConnectionStatus, error?: string) => void;
|
||||
|
||||
interface MachineConnection {
|
||||
machineId: string;
|
||||
label: string;
|
||||
url: string;
|
||||
token: string;
|
||||
status: ConnectionStatus;
|
||||
latencyMs: number | null;
|
||||
ws: WebSocket | null;
|
||||
heartbeatTimer: ReturnType<typeof setInterval> | null;
|
||||
reconnectTimer: ReturnType<typeof setTimeout> | null;
|
||||
cancelled: boolean;
|
||||
lastPingSent: number;
|
||||
}
|
||||
|
||||
// ── Relay Client ───────────────────────────────────────────────────────────
|
||||
|
||||
export class RelayClient {
|
||||
private machines = new Map<string, MachineConnection>();
|
||||
private eventListeners: EventCallback[] = [];
|
||||
private statusListeners: StatusCallback[] = [];
|
||||
|
||||
/** Register an event listener for relay events from any machine. */
|
||||
onEvent(cb: EventCallback): void {
|
||||
this.eventListeners.push(cb);
|
||||
}
|
||||
|
||||
/** Register a listener for connection status changes. */
|
||||
onStatus(cb: StatusCallback): void {
|
||||
this.statusListeners.push(cb);
|
||||
}
|
||||
|
||||
/** Connect to an agor-relay instance. Returns a machine ID. */
|
||||
async connect(url: string, token: string, label?: string): Promise<string> {
|
||||
const machineId = randomUUID();
|
||||
const machine: MachineConnection = {
|
||||
machineId,
|
||||
label: label ?? url,
|
||||
url,
|
||||
token,
|
||||
status: "connecting",
|
||||
latencyMs: null,
|
||||
ws: null,
|
||||
heartbeatTimer: null,
|
||||
reconnectTimer: null,
|
||||
cancelled: false,
|
||||
lastPingSent: 0,
|
||||
};
|
||||
this.machines.set(machineId, machine);
|
||||
this.emitStatus(machineId, "connecting");
|
||||
|
||||
try {
|
||||
await this.openWebSocket(machine);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
machine.status = "error";
|
||||
this.emitStatus(machineId, "error", msg);
|
||||
this.scheduleReconnect(machine);
|
||||
}
|
||||
|
||||
return machineId;
|
||||
}
|
||||
|
||||
/** Disconnect from a relay and stop reconnection attempts. */
|
||||
disconnect(machineId: string): void {
|
||||
const machine = this.machines.get(machineId);
|
||||
if (!machine) return;
|
||||
|
||||
machine.cancelled = true;
|
||||
this.cleanupConnection(machine);
|
||||
machine.status = "disconnected";
|
||||
this.emitStatus(machineId, "disconnected");
|
||||
}
|
||||
|
||||
/** Remove a machine entirely from tracking. */
|
||||
removeMachine(machineId: string): void {
|
||||
this.disconnect(machineId);
|
||||
this.machines.delete(machineId);
|
||||
}
|
||||
|
||||
/** Send a command to a connected relay. */
|
||||
sendCommand(machineId: string, type: string, payload: Record<string, unknown>): void {
|
||||
const machine = this.machines.get(machineId);
|
||||
if (!machine?.ws || machine.status !== "connected") {
|
||||
throw new Error(`Machine ${machineId} not connected`);
|
||||
}
|
||||
|
||||
const cmd: RelayCommand = {
|
||||
id: randomUUID(),
|
||||
type,
|
||||
payload,
|
||||
};
|
||||
machine.ws.send(JSON.stringify(cmd));
|
||||
}
|
||||
|
||||
/** Get the status of a specific machine. */
|
||||
getStatus(machineId: string): { status: ConnectionStatus; latencyMs: number | null } | null {
|
||||
const machine = this.machines.get(machineId);
|
||||
if (!machine) return null;
|
||||
return { status: machine.status, latencyMs: machine.latencyMs };
|
||||
}
|
||||
|
||||
/** List all tracked machines. */
|
||||
listMachines(): Array<{
|
||||
machineId: string;
|
||||
label: string;
|
||||
url: string;
|
||||
status: ConnectionStatus;
|
||||
latencyMs: number | null;
|
||||
}> {
|
||||
return Array.from(this.machines.values()).map((m) => ({
|
||||
machineId: m.machineId,
|
||||
label: m.label,
|
||||
url: m.url,
|
||||
status: m.status,
|
||||
latencyMs: m.latencyMs,
|
||||
}));
|
||||
}
|
||||
|
||||
// ── Internal ─────────────────────────────────────────────────────────────
|
||||
|
||||
private async openWebSocket(machine: MachineConnection): Promise<void> {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const ws = new WebSocket(machine.url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${machine.token}`,
|
||||
},
|
||||
} as unknown as string[]);
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
ws.close();
|
||||
reject(new Error("Connection timeout (10s)"));
|
||||
}, 10_000);
|
||||
|
||||
ws.addEventListener("open", () => {
|
||||
clearTimeout(timeout);
|
||||
machine.ws = ws;
|
||||
machine.status = "connected";
|
||||
machine.cancelled = false;
|
||||
this.emitStatus(machine.machineId, "connected");
|
||||
this.startHeartbeat(machine);
|
||||
resolve();
|
||||
});
|
||||
|
||||
ws.addEventListener("message", (ev) => {
|
||||
this.handleMessage(machine, String(ev.data));
|
||||
});
|
||||
|
||||
ws.addEventListener("close", () => {
|
||||
clearTimeout(timeout);
|
||||
if (machine.status === "connected") {
|
||||
this.cleanupConnection(machine);
|
||||
machine.status = "disconnected";
|
||||
this.emitStatus(machine.machineId, "disconnected");
|
||||
if (!machine.cancelled) {
|
||||
this.scheduleReconnect(machine);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ws.addEventListener("error", (ev) => {
|
||||
clearTimeout(timeout);
|
||||
const errMsg = "WebSocket error";
|
||||
if (machine.status !== "connected") {
|
||||
reject(new Error(errMsg));
|
||||
} else {
|
||||
this.cleanupConnection(machine);
|
||||
machine.status = "error";
|
||||
this.emitStatus(machine.machineId, "error", errMsg);
|
||||
if (!machine.cancelled) {
|
||||
this.scheduleReconnect(machine);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private handleMessage(machine: MachineConnection, data: string): void {
|
||||
let event: RelayEvent;
|
||||
try {
|
||||
event = JSON.parse(data) as RelayEvent;
|
||||
} catch {
|
||||
console.error(`[relay] Invalid JSON from ${machine.machineId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle pong for latency measurement
|
||||
if (event.type === "pong") {
|
||||
if (machine.lastPingSent > 0) {
|
||||
machine.latencyMs = Date.now() - machine.lastPingSent;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Forward all other events
|
||||
event.machineId = machine.machineId;
|
||||
for (const cb of this.eventListeners) {
|
||||
try {
|
||||
cb(machine.machineId, event);
|
||||
} catch (err) {
|
||||
console.error("[relay] Event listener error:", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private startHeartbeat(machine: MachineConnection): void {
|
||||
this.stopHeartbeat(machine);
|
||||
machine.heartbeatTimer = setInterval(() => {
|
||||
if (machine.ws?.readyState === WebSocket.OPEN) {
|
||||
machine.lastPingSent = Date.now();
|
||||
machine.ws.send(JSON.stringify({ id: "", type: "ping", payload: {} }));
|
||||
}
|
||||
}, 15_000);
|
||||
}
|
||||
|
||||
private stopHeartbeat(machine: MachineConnection): void {
|
||||
if (machine.heartbeatTimer) {
|
||||
clearInterval(machine.heartbeatTimer);
|
||||
machine.heartbeatTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private cleanupConnection(machine: MachineConnection): void {
|
||||
this.stopHeartbeat(machine);
|
||||
if (machine.reconnectTimer) {
|
||||
clearTimeout(machine.reconnectTimer);
|
||||
machine.reconnectTimer = null;
|
||||
}
|
||||
if (machine.ws) {
|
||||
try { machine.ws.close(); } catch { /* ignore */ }
|
||||
machine.ws = null;
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleReconnect(machine: MachineConnection): void {
|
||||
let delay = 1_000;
|
||||
const maxDelay = 30_000;
|
||||
|
||||
const attempt = async () => {
|
||||
if (machine.cancelled || !this.machines.has(machine.machineId)) return;
|
||||
|
||||
machine.status = "connecting";
|
||||
this.emitStatus(machine.machineId, "connecting");
|
||||
|
||||
// TCP probe first — avoids full WS overhead if host unreachable
|
||||
const probeOk = await this.tcpProbe(machine.url);
|
||||
if (!probeOk) {
|
||||
delay = Math.min(delay * 2, maxDelay);
|
||||
if (!machine.cancelled) {
|
||||
machine.reconnectTimer = setTimeout(attempt, delay);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.openWebSocket(machine);
|
||||
// Success — reset
|
||||
} catch {
|
||||
delay = Math.min(delay * 2, maxDelay);
|
||||
if (!machine.cancelled) {
|
||||
machine.reconnectTimer = setTimeout(attempt, delay);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
machine.reconnectTimer = setTimeout(attempt, delay);
|
||||
}
|
||||
|
||||
/** TCP-only probe to check if the relay host is reachable. */
|
||||
private tcpProbe(url: string): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const host = this.extractHost(url);
|
||||
if (!host) { resolve(false); return; }
|
||||
|
||||
const [hostname, portStr] = host.includes(":")
|
||||
? [host.split(":")[0], host.split(":")[1]]
|
||||
: [host, "9750"];
|
||||
const port = parseInt(portStr, 10);
|
||||
|
||||
const socket = new Socket();
|
||||
const timer = setTimeout(() => { socket.destroy(); resolve(false); }, 5_000);
|
||||
|
||||
socket.connect(port, hostname, () => {
|
||||
clearTimeout(timer);
|
||||
socket.destroy();
|
||||
resolve(true);
|
||||
});
|
||||
|
||||
socket.on("error", () => {
|
||||
clearTimeout(timer);
|
||||
socket.destroy();
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private extractHost(url: string): string | null {
|
||||
return url.replace("wss://", "").replace("ws://", "").split("/")[0] ?? null;
|
||||
}
|
||||
|
||||
private emitStatus(machineId: string, status: ConnectionStatus, error?: string): void {
|
||||
for (const cb of this.statusListeners) {
|
||||
try {
|
||||
cb(machineId, status, error);
|
||||
} catch (err) {
|
||||
console.error("[relay] Status listener error:", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
193
ui-electrobun/src/bun/telemetry.ts
Normal file
193
ui-electrobun/src/bun/telemetry.ts
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
/**
|
||||
* OpenTelemetry integration for the Bun process.
|
||||
*
|
||||
* Controlled by AGOR_OTLP_ENDPOINT env var:
|
||||
* - Set (e.g. "http://localhost:4318") -> OTLP/HTTP trace export + console
|
||||
* - Absent -> console-only (no network calls)
|
||||
*
|
||||
* Provides structured span creation for agent sessions, PTY operations, and
|
||||
* RPC calls. Frontend events are forwarded via the telemetry.log RPC.
|
||||
*/
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export type LogLevel = "info" | "warn" | "error";
|
||||
|
||||
export interface SpanAttributes {
|
||||
[key: string]: string | number | boolean;
|
||||
}
|
||||
|
||||
interface ActiveSpan {
|
||||
name: string;
|
||||
attributes: SpanAttributes;
|
||||
startTime: number;
|
||||
}
|
||||
|
||||
// ── Telemetry Manager ──────────────────────────────────────────────────────
|
||||
|
||||
class TelemetryManager {
|
||||
private enabled = false;
|
||||
private endpoint = "";
|
||||
private activeSpans = new Map<string, ActiveSpan>();
|
||||
private spanCounter = 0;
|
||||
private serviceName = "agent-orchestrator-electrobun";
|
||||
private serviceVersion = "3.0.0-dev";
|
||||
|
||||
/** Initialize telemetry. Call once at startup. */
|
||||
init(): void {
|
||||
const endpoint = process.env.AGOR_OTLP_ENDPOINT ?? "";
|
||||
const isTest = process.env.AGOR_TEST === "1";
|
||||
|
||||
if (endpoint && !isTest) {
|
||||
this.enabled = true;
|
||||
this.endpoint = endpoint.endsWith("/")
|
||||
? endpoint + "v1/traces"
|
||||
: endpoint + "/v1/traces";
|
||||
console.log(`[telemetry] OTLP export enabled -> ${this.endpoint}`);
|
||||
} else {
|
||||
console.log("[telemetry] Console-only (AGOR_OTLP_ENDPOINT not set)");
|
||||
}
|
||||
}
|
||||
|
||||
/** Start a named span. Returns a spanId to pass to endSpan(). */
|
||||
span(name: string, attributes: SpanAttributes = {}): string {
|
||||
const spanId = `span_${++this.spanCounter}_${Date.now()}`;
|
||||
this.activeSpans.set(spanId, {
|
||||
name,
|
||||
attributes,
|
||||
startTime: Date.now(),
|
||||
});
|
||||
this.consoleLog("info", `[span:start] ${name}`, attributes);
|
||||
return spanId;
|
||||
}
|
||||
|
||||
/** End a span and optionally export it via OTLP. */
|
||||
endSpan(spanId: string, extraAttributes: SpanAttributes = {}): void {
|
||||
const active = this.activeSpans.get(spanId);
|
||||
if (!active) return;
|
||||
this.activeSpans.delete(spanId);
|
||||
|
||||
const durationMs = Date.now() - active.startTime;
|
||||
const allAttributes = { ...active.attributes, ...extraAttributes, durationMs };
|
||||
|
||||
this.consoleLog("info", `[span:end] ${active.name} (${durationMs}ms)`, allAttributes);
|
||||
|
||||
if (this.enabled) {
|
||||
this.exportSpan(active.name, active.startTime, durationMs, allAttributes);
|
||||
}
|
||||
}
|
||||
|
||||
/** Log a structured message. Used for frontend-forwarded events. */
|
||||
log(level: LogLevel, message: string, attributes: SpanAttributes = {}): void {
|
||||
this.consoleLog(level, message, attributes);
|
||||
|
||||
if (this.enabled) {
|
||||
this.exportLog(level, message, attributes);
|
||||
}
|
||||
}
|
||||
|
||||
/** Shutdown — flush any pending exports. */
|
||||
shutdown(): void {
|
||||
this.activeSpans.clear();
|
||||
if (this.enabled) {
|
||||
console.log("[telemetry] Shutdown");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Internal ─────────────────────────────────────────────────────────────
|
||||
|
||||
private consoleLog(level: LogLevel, message: string, attrs: SpanAttributes): void {
|
||||
const attrStr = Object.keys(attrs).length > 0
|
||||
? ` ${JSON.stringify(attrs)}`
|
||||
: "";
|
||||
|
||||
switch (level) {
|
||||
case "error": console.error(`[tel] ${message}${attrStr}`); break;
|
||||
case "warn": console.warn(`[tel] ${message}${attrStr}`); break;
|
||||
default: console.log(`[tel] ${message}${attrStr}`); break;
|
||||
}
|
||||
}
|
||||
|
||||
private async exportSpan(
|
||||
name: string,
|
||||
startTimeMs: number,
|
||||
durationMs: number,
|
||||
attributes: SpanAttributes,
|
||||
): Promise<void> {
|
||||
const traceId = this.randomHex(32);
|
||||
const spanId = this.randomHex(16);
|
||||
const startNs = BigInt(startTimeMs) * 1_000_000n;
|
||||
const endNs = BigInt(startTimeMs + durationMs) * 1_000_000n;
|
||||
|
||||
const otlpPayload = {
|
||||
resourceSpans: [{
|
||||
resource: {
|
||||
attributes: [
|
||||
{ key: "service.name", value: { stringValue: this.serviceName } },
|
||||
{ key: "service.version", value: { stringValue: this.serviceVersion } },
|
||||
],
|
||||
},
|
||||
scopeSpans: [{
|
||||
scope: { name: this.serviceName },
|
||||
spans: [{
|
||||
traceId,
|
||||
spanId,
|
||||
name,
|
||||
kind: 1, // INTERNAL
|
||||
startTimeUnixNano: startNs.toString(),
|
||||
endTimeUnixNano: endNs.toString(),
|
||||
attributes: Object.entries(attributes).map(([key, value]) => ({
|
||||
key,
|
||||
value: typeof value === "number"
|
||||
? { intValue: value }
|
||||
: typeof value === "boolean"
|
||||
? { boolValue: value }
|
||||
: { stringValue: String(value) },
|
||||
})),
|
||||
status: { code: 1 }, // OK
|
||||
}],
|
||||
}],
|
||||
}],
|
||||
};
|
||||
|
||||
try {
|
||||
await fetch(this.endpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(otlpPayload),
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn("[telemetry] OTLP export failed:", err instanceof Error ? err.message : err);
|
||||
}
|
||||
}
|
||||
|
||||
private async exportLog(
|
||||
level: LogLevel,
|
||||
message: string,
|
||||
attributes: SpanAttributes,
|
||||
): Promise<void> {
|
||||
// Wrap log as a zero-duration span for Tempo compatibility
|
||||
await this.exportSpan(
|
||||
`log.${level}`,
|
||||
Date.now(),
|
||||
0,
|
||||
{ ...attributes, "log.message": message, "log.level": level },
|
||||
);
|
||||
}
|
||||
|
||||
private randomHex(length: number): string {
|
||||
const bytes = new Uint8Array(length / 2);
|
||||
crypto.getRandomValues(bytes);
|
||||
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Singleton ──────────────────────────────────────────────────────────────
|
||||
|
||||
export const telemetry = new TelemetryManager();
|
||||
|
||||
/** Initialize telemetry. Call once at app startup. */
|
||||
export function initTelemetry(): void {
|
||||
telemetry.init();
|
||||
}
|
||||
117
ui-electrobun/src/bun/updater.ts
Normal file
117
ui-electrobun/src/bun/updater.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
/**
|
||||
* Auto-updater: checks GitHub Releases API for newer versions.
|
||||
*
|
||||
* Electrobun doesn't have a built-in updater mechanism yet, so this module
|
||||
* only detects available updates and returns metadata — no download/install.
|
||||
*/
|
||||
|
||||
import { settingsDb } from "./settings-db.ts";
|
||||
|
||||
// ── Config ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const GITHUB_OWNER = "DexterFromLab";
|
||||
const GITHUB_REPO = "agents-orchestrator";
|
||||
const RELEASES_URL = `https://api.github.com/repos/${GITHUB_OWNER}/${GITHUB_REPO}/releases/latest`;
|
||||
|
||||
/** Minimum interval between automatic checks (1 hour). */
|
||||
const MIN_CHECK_INTERVAL_MS = 60 * 60 * 1000;
|
||||
|
||||
const SETTINGS_KEY_LAST_CHECK = "updater_last_check";
|
||||
const SETTINGS_KEY_LAST_VERSION = "updater_last_version";
|
||||
|
||||
// ── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface UpdateCheckResult {
|
||||
available: boolean;
|
||||
version: string;
|
||||
downloadUrl: string;
|
||||
releaseNotes: string;
|
||||
checkedAt: number;
|
||||
}
|
||||
|
||||
interface GitHubRelease {
|
||||
tag_name: string;
|
||||
html_url: string;
|
||||
body: string | null;
|
||||
assets: Array<{
|
||||
name: string;
|
||||
browser_download_url: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
// ── Semver comparison ───────────────────────────────────────────────────────
|
||||
|
||||
function parseSemver(v: string): [number, number, number] {
|
||||
const clean = v.replace(/^v/, "");
|
||||
const parts = clean.split("-")[0].split(".");
|
||||
return [
|
||||
parseInt(parts[0] ?? "0", 10),
|
||||
parseInt(parts[1] ?? "0", 10),
|
||||
parseInt(parts[2] ?? "0", 10),
|
||||
];
|
||||
}
|
||||
|
||||
function isNewer(remote: string, local: string): boolean {
|
||||
const [rMaj, rMin, rPatch] = parseSemver(remote);
|
||||
const [lMaj, lMin, lPatch] = parseSemver(local);
|
||||
|
||||
if (rMaj !== lMaj) return rMaj > lMaj;
|
||||
if (rMin !== lMin) return rMin > lMin;
|
||||
return rPatch > lPatch;
|
||||
}
|
||||
|
||||
// ── Core ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Check GitHub Releases API for a newer version.
|
||||
* Returns update metadata (never downloads or installs anything).
|
||||
*/
|
||||
export async function checkForUpdates(
|
||||
currentVersion: string,
|
||||
): Promise<UpdateCheckResult> {
|
||||
const now = Date.now();
|
||||
|
||||
const resp = await fetch(RELEASES_URL, {
|
||||
headers: {
|
||||
Accept: "application/vnd.github.v3+json",
|
||||
"User-Agent": "AgentOrchestrator-Updater",
|
||||
},
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
throw new Error(`GitHub API returned ${resp.status}: ${resp.statusText}`);
|
||||
}
|
||||
|
||||
const release: GitHubRelease = await resp.json();
|
||||
const remoteVersion = release.tag_name.replace(/^v/, "");
|
||||
|
||||
// Find a .deb or .AppImage asset as download URL, fallback to release page
|
||||
const linuxAsset = release.assets.find(
|
||||
(a) => a.name.endsWith(".deb") || a.name.endsWith(".AppImage"),
|
||||
);
|
||||
const downloadUrl = linuxAsset?.browser_download_url ?? release.html_url;
|
||||
|
||||
// Persist check timestamp
|
||||
settingsDb.setSetting(SETTINGS_KEY_LAST_CHECK, String(now));
|
||||
settingsDb.setSetting(SETTINGS_KEY_LAST_VERSION, remoteVersion);
|
||||
|
||||
return {
|
||||
available: isNewer(remoteVersion, currentVersion),
|
||||
version: remoteVersion,
|
||||
downloadUrl,
|
||||
releaseNotes: release.body ?? "",
|
||||
checkedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
/** Return the timestamp of the last update check (0 if never). */
|
||||
export function getLastCheckTimestamp(): number {
|
||||
const val = settingsDb.getSetting(SETTINGS_KEY_LAST_CHECK);
|
||||
return val ? parseInt(val, 10) || 0 : 0;
|
||||
}
|
||||
|
||||
/** Return the last known remote version (empty string if never checked). */
|
||||
export function getLastKnownVersion(): string {
|
||||
return settingsDb.getSetting(SETTINGS_KEY_LAST_VERSION) ?? "";
|
||||
}
|
||||
|
|
@ -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'}
|
||||
|
|
|
|||
139
ui-electrobun/src/mainview/SplashScreen.svelte
Normal file
139
ui-electrobun/src/mainview/SplashScreen.svelte
Normal 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>
|
||||
89
ui-electrobun/src/mainview/machines-store.svelte.ts
Normal file
89
ui-electrobun/src/mainview/machines-store.svelte.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
45
ui-electrobun/src/mainview/telemetry-bridge.ts
Normal file
45
ui-electrobun/src/mainview/telemetry-bridge.ts
Normal 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;
|
||||
|
|
@ -482,6 +482,78 @@ export type PtyRPCRequests = {
|
|||
params: { pluginId: string; filePath: string };
|
||||
response: { ok: boolean; content: string; error?: string };
|
||||
};
|
||||
|
||||
// ── Remote machine (relay) RPC ────────────────────────────────────────────
|
||||
|
||||
/** Connect to an agor-relay instance. */
|
||||
"remote.connect": {
|
||||
params: { url: string; token: string; label?: string };
|
||||
response: { ok: boolean; machineId?: string; error?: string };
|
||||
};
|
||||
/** Disconnect from a relay instance. */
|
||||
"remote.disconnect": {
|
||||
params: { machineId: string };
|
||||
response: { ok: boolean; error?: string };
|
||||
};
|
||||
/** List all known remote machines with connection status. */
|
||||
"remote.list": {
|
||||
params: Record<string, never>;
|
||||
response: {
|
||||
machines: Array<{
|
||||
machineId: string;
|
||||
label: string;
|
||||
url: string;
|
||||
status: "connecting" | "connected" | "disconnected" | "error";
|
||||
latencyMs: number | null;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
/** Send a command to a connected relay. */
|
||||
"remote.send": {
|
||||
params: { machineId: string; command: string; payload: Record<string, unknown> };
|
||||
response: { ok: boolean; error?: string };
|
||||
};
|
||||
/** Get the status of a specific machine. */
|
||||
"remote.status": {
|
||||
params: { machineId: string };
|
||||
response: {
|
||||
status: "connecting" | "connected" | "disconnected" | "error";
|
||||
latencyMs: number | null;
|
||||
error?: string;
|
||||
};
|
||||
};
|
||||
|
||||
// ── Telemetry RPC ─────────────────────────────────────────────────────────
|
||||
|
||||
/** Log a telemetry event from the frontend. */
|
||||
"telemetry.log": {
|
||||
params: {
|
||||
level: "info" | "warn" | "error";
|
||||
message: string;
|
||||
attributes?: Record<string, string | number | boolean>;
|
||||
};
|
||||
response: { ok: boolean };
|
||||
};
|
||||
|
||||
// ── Updater RPC ──────────────────────────────────────────────────────────
|
||||
|
||||
/** Check GitHub Releases for a newer version. */
|
||||
"updater.check": {
|
||||
params: Record<string, never>;
|
||||
response: {
|
||||
available: boolean;
|
||||
version: string;
|
||||
downloadUrl: string;
|
||||
releaseNotes: string;
|
||||
checkedAt: number;
|
||||
error?: string;
|
||||
};
|
||||
};
|
||||
/** Get the current app version and last check timestamp. */
|
||||
"updater.getVersion": {
|
||||
params: Record<string, never>;
|
||||
response: { version: string; lastCheck: number };
|
||||
};
|
||||
};
|
||||
|
||||
// ── Messages (Bun → WebView, fire-and-forget) ────────────────────────────────
|
||||
|
|
@ -518,6 +590,22 @@ export type PtyRPCMessages = {
|
|||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
};
|
||||
|
||||
// ── Remote machine events (Bun → WebView) ────────────────────────────────
|
||||
|
||||
/** Remote relay event forwarded from a connected machine. */
|
||||
"remote.event": {
|
||||
machineId: string;
|
||||
eventType: string;
|
||||
sessionId?: string;
|
||||
payload?: unknown;
|
||||
};
|
||||
/** Remote machine connection status change. */
|
||||
"remote.statusChange": {
|
||||
machineId: string;
|
||||
status: "connecting" | "connected" | "disconnected" | "error";
|
||||
error?: string;
|
||||
};
|
||||
};
|
||||
|
||||
// ── Combined schema ───────────────────────────────────────────────────────────
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue