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
|
|
@ -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;
|
||||
Loading…
Add table
Add a link
Reference in a new issue