From 75b40b7ed1c5bf3c66b0cc87e47ff1be483f2d1d Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Thu, 14 May 2026 00:17:17 -0700 Subject: [PATCH] Add env var paste parsing and improve SSE reconnection --- apps/env/index.tsx | 17 +++++++++ src/client/components/LogsSection.tsx | 52 ++++++++++++++++++++++++++- src/client/components/UnifiedLogs.tsx | 16 ++++++++- src/client/components/Vitals.tsx | 18 ++++++++-- src/client/index.tsx | 38 ++++++++++++++++---- src/client/router.ts | 3 ++ src/server/api/apps.ts | 8 ++--- src/server/apps.ts | 40 +++++++++++++++++++-- 8 files changed, 174 insertions(+), 18 deletions(-) diff --git a/apps/env/index.tsx b/apps/env/index.tsx index fa74ff0..c4364a4 100644 --- a/apps/env/index.tsx +++ b/apps/env/index.tsx @@ -288,6 +288,23 @@ document.querySelectorAll('[data-reveal]').forEach(btn => { } }); }); + +document.querySelectorAll('input[name="key"]').forEach(input => { + input.addEventListener('paste', e => { + const text = e.clipboardData?.getData('text') ?? ''; + const eqIndex = text.indexOf('='); + if (eqIndex === -1) return; + e.preventDefault(); + const key = text.slice(0, eqIndex).trim(); + const value = text.slice(eqIndex + 1).trim(); + input.value = key; + const valueInput = input.closest('form').querySelector('input[name="value"]'); + if (valueInput) { + valueInput.value = value; + valueInput.focus(); + } + }); +}); ` app.get('/ok', c => c.text('ok')) diff --git a/src/client/components/LogsSection.tsx b/src/client/components/LogsSection.tsx index 1ee90f3..405fd8c 100644 --- a/src/client/components/LogsSection.tsx +++ b/src/client/components/LogsSection.tsx @@ -10,21 +10,28 @@ import { update } from '../update' type LogsState = { dates: string[] historicalLogs: string[] + liveLogs: LogLineType[] loadingDates: boolean loadingLogs: boolean searchFilter: string selectedDate: string } +const MAX_LOGS = 100 + const logsState = new Map() let currentApp: App | null = null +let _logSource: EventSource | null = null +let _logSourceApp: string | null = null +let _logRenderQueued = false const getState = (appName: string): LogsState => { if (!logsState.has(appName)) { logsState.set(appName, { dates: [], historicalLogs: [], + liveLogs: [], loadingDates: false, loadingLogs: false, searchFilter: '', @@ -34,6 +41,46 @@ const getState = (appName: string): LogsState => { return logsState.get(appName)! } +function connectLogStream(appName: string) { + if (_logSource && _logSourceApp === appName) return + disconnectLogStream() + + _logSourceApp = appName + const state = getState(appName) + state.liveLogs = [] + + _logSource = new EventSource(`/api/apps/${appName}/logs/stream`) + _logSource.onmessage = e => { + try { + const line = JSON.parse(e.data) as LogLineType + state.liveLogs = [...state.liveLogs.slice(-(MAX_LOGS - 1)), line] + // Debounce log renders to avoid overwhelming the browser + if (!_logRenderQueued) { + _logRenderQueued = true + requestAnimationFrame(() => { + _logRenderQueued = false + updateLogsContent() + }) + } + } catch {} + } + _logSource.onerror = () => { + if (_logSource?.readyState === EventSource.CLOSED) { + _logSource = null + // Reconnect after a brief delay + setTimeout(() => { + if (_logSourceApp === appName) connectLogStream(appName) + }, 2000) + } + } +} + +export function disconnectLogStream() { + _logSource?.close() + _logSource = null + _logSourceApp = null +} + const LogsControls = define('LogsControls', { display: 'flex', alignItems: 'center', @@ -92,7 +139,7 @@ function LogsContent() { const state = getState(currentApp.name) const isLive = state.selectedDate === 'live' - const filteredLiveLogs = filterLogs(currentApp.logs ?? [], state.searchFilter, l => stripAnsi(l.text)) + const filteredLiveLogs = filterLogs(state.liveLogs, state.searchFilter, l => stripAnsi(l.text)) const filteredHistoricalLogs = filterLogs(state.historicalLogs, state.searchFilter, l => l) return ( @@ -201,6 +248,9 @@ export function LogsSection({ app }: { app: App }) { currentApp = app const state = getState(app.name) + // Connect to per-app log stream for live logs + connectLogStream(app.name) + // Load dates on first render if (state.dates.length === 0 && !state.loadingDates) { loadDates(app.name) diff --git a/src/client/components/UnifiedLogs.tsx b/src/client/components/UnifiedLogs.tsx index be4b052..dea4033 100644 --- a/src/client/components/UnifiedLogs.tsx +++ b/src/client/components/UnifiedLogs.tsx @@ -114,6 +114,8 @@ export function scrollLogsToBottom() { }) } +let _logsRenderQueued = false + export function initUnifiedLogs() { if (_source) return _source = new EventSource('/api/system/logs/stream') @@ -121,9 +123,21 @@ export function initUnifiedLogs() { try { const line = JSON.parse(e.data) as UnifiedLogLine _logs = [..._logs.slice(-(MAX_LOGS - 1)), line] - renderLogs() + if (!_logsRenderQueued) { + _logsRenderQueued = true + requestAnimationFrame(() => { + _logsRenderQueued = false + renderLogs() + }) + } } catch {} } + _source.onerror = () => { + if (_source?.readyState === EventSource.CLOSED) { + _source = undefined + setTimeout(initUnifiedLogs, 2000) + } + } } function LogsTabsBar() { diff --git a/src/client/components/Vitals.tsx b/src/client/components/Vitals.tsx index 5e01ffc..84eb077 100644 --- a/src/client/components/Vitals.tsx +++ b/src/client/components/Vitals.tsx @@ -146,16 +146,30 @@ function VitalsContent() { ) } +let _vitalsRenderQueued = false + export function initVitals() { if (_source) return _source = new EventSource('/api/system/metrics/stream') _source.onmessage = e => { try { _metrics = JSON.parse(e.data) - update('#vitals', ) - updateTooltips(_metrics.apps) + if (!_vitalsRenderQueued) { + _vitalsRenderQueued = true + requestAnimationFrame(() => { + _vitalsRenderQueued = false + update('#vitals', ) + updateTooltips(_metrics.apps) + }) + } } catch {} } + _source.onerror = () => { + if (_source?.readyState === EventSource.CLOSED) { + _source = undefined + setTimeout(initVitals, 2000) + } + } } export function Vitals() { diff --git a/src/client/index.tsx b/src/client/index.tsx index bd46081..8cf9d1f 100644 --- a/src/client/index.tsx +++ b/src/client/index.tsx @@ -53,14 +53,38 @@ narrowQuery.addEventListener('change', e => { // Initialize router (sets initial state from URL and renders) initRouter(render) -// SSE connection -const events = new EventSource('/api/apps/stream') -events.onmessage = e => { - setApps(JSON.parse(e.data)) +// SSE connection with reconnection handling +let renderQueued = false - if (selectedApp && !apps.some(a => a.name === selectedApp)) { - navigate('/') +const queueRender = () => { + if (renderQueued) return + renderQueued = true + requestAnimationFrame(() => { + renderQueued = false + render() + }) +} + +function connectSSE() { + const events = new EventSource('/api/apps/stream') + + events.onmessage = e => { + setApps(JSON.parse(e.data)) + + if (selectedApp && !apps.some(a => a.name === selectedApp)) { + navigate('/') + } + + queueRender() } - render() + events.onerror = () => { + // EventSource auto-reconnects, but close and retry if it enters CLOSED state + if (events.readyState === EventSource.CLOSED) { + events.close() + setTimeout(connectSSE, 2000) + } + } } + +connectSSE() diff --git a/src/client/router.ts b/src/client/router.ts index 3dd939d..2a662e6 100644 --- a/src/client/router.ts +++ b/src/client/router.ts @@ -1,3 +1,4 @@ +import { disconnectLogStream } from './components/LogsSection' import { setCurrentView, setDashboardTab, setMobileSidebar, setSelectedApp, setSelectedTab } from './state' let _render: () => void @@ -41,9 +42,11 @@ function route() { setCurrentView('dashboard') } else if (path === '/settings') { setSelectedApp(null) + disconnectLogStream() setCurrentView('settings') } else { setSelectedApp(null) + disconnectLogStream() const segment = path.slice(1) setDashboardTab(segment || 'urls') setCurrentView('dashboard') diff --git a/src/server/api/apps.ts b/src/server/api/apps.ts index a94f289..dfabef5 100644 --- a/src/server/api/apps.ts +++ b/src/server/api/apps.ts @@ -23,15 +23,13 @@ function convert(app: BackendApp): SharedApp { return { ...rest, pid: proc?.pid } } -// SSE: full app state snapshots for the dashboard UI (every state change) +// SSE: app state snapshots for the dashboard UI (every state change) +// Logs are excluded to keep payloads small — use /:app/logs/stream for live logs // For discrete lifecycle events consumed by app processes, see /api/events/stream router.sse('/stream', (send) => { let queue = Promise.resolve() const broadcast = () => { - const apps: SharedApp[] = allApps().map(app => ({ - ...convert(app), - logs: app.logs, - })) + const apps: SharedApp[] = allApps().map(convert) queue = queue.then(() => send(apps)) } diff --git a/src/server/apps.ts b/src/server/apps.ts index 5566008..03b87fc 100644 --- a/src/server/apps.ts +++ b/src/server/apps.ts @@ -3,7 +3,7 @@ import type { ToesEvent, ToesEventInput, ToesEventType } from '../shared/events' import type { Subprocess } from 'bun' import { DEFAULT_EMOJI, VALID_NAME } from '@types' import { buildAppUrl, toSubdomain } from '@urls' -import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'fs' +import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, unlinkSync, watch, writeFileSync } from 'fs' import { LOCAL_HOST } from '%config' import { join, resolve } from 'path' import { loadAppEnv } from '../tools/env' @@ -115,6 +115,7 @@ export async function initApps() { setupShutdownHandlers() rotateLogs() discoverApps() + watchAppsDir() runApps() } @@ -445,9 +446,17 @@ const logFile = (appName: string, date: string = formatLogDate()) => const isApp = (dir: string): boolean => !loadApp(dir).error +let _updateTimer: Timer | undefined +const UPDATE_DEBOUNCE_MS = 100 + export const update = () => { setApps(allApps()) - _listeners.forEach(cb => cb()) + // Debounce SSE broadcasts to avoid overwhelming clients during rapid changes + if (_updateTimer) clearTimeout(_updateTimer) + _updateTimer = setTimeout(() => { + _updateTimer = undefined + _listeners.forEach(cb => cb()) + }, UPDATE_DEBOUNCE_MS) } function allAppDirs() { @@ -457,6 +466,25 @@ function allAppDirs() { .sort() } +function diffAppsDir() { + const known = new Set(_apps.keys()) + const current = new Set(allAppDirs()) + + for (const dir of current) { + if (!known.has(dir)) { + hostLog(`Discovered new app: ${dir}`) + registerApp(dir) + } + } + + for (const dir of known) { + if (!current.has(dir)) { + hostLog(`App directory removed: ${dir}`) + removeApp(dir) + } + } +} + function discoverApps() { for (const dir of allAppDirs()) { const { pkg, error } = loadApp(dir) @@ -473,6 +501,14 @@ function discoverApps() { update() } +function watchAppsDir() { + let debounce: Timer | undefined + watch(APPS_DIR, (_event, _filename) => { + clearTimeout(debounce) + debounce = setTimeout(diffAppsDir, 500) + }) +} + function ensureLogDir(appName: string): string { const dir = logDir(appName) if (!existsSync(dir)) {