forked from defunkt/toes
Add env var paste parsing and improve SSE reconnection
This commit is contained in:
parent
be6d4f58ff
commit
75b40b7ed1
17
apps/env/index.tsx
vendored
17
apps/env/index.tsx
vendored
|
|
@ -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'))
|
app.get('/ok', c => c.text('ok'))
|
||||||
|
|
|
||||||
|
|
@ -10,21 +10,28 @@ import { update } from '../update'
|
||||||
type LogsState = {
|
type LogsState = {
|
||||||
dates: string[]
|
dates: string[]
|
||||||
historicalLogs: string[]
|
historicalLogs: string[]
|
||||||
|
liveLogs: LogLineType[]
|
||||||
loadingDates: boolean
|
loadingDates: boolean
|
||||||
loadingLogs: boolean
|
loadingLogs: boolean
|
||||||
searchFilter: string
|
searchFilter: string
|
||||||
selectedDate: string
|
selectedDate: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MAX_LOGS = 100
|
||||||
|
|
||||||
const logsState = new Map<string, LogsState>()
|
const logsState = new Map<string, LogsState>()
|
||||||
|
|
||||||
let currentApp: App | null = null
|
let currentApp: App | null = null
|
||||||
|
let _logSource: EventSource | null = null
|
||||||
|
let _logSourceApp: string | null = null
|
||||||
|
let _logRenderQueued = false
|
||||||
|
|
||||||
const getState = (appName: string): LogsState => {
|
const getState = (appName: string): LogsState => {
|
||||||
if (!logsState.has(appName)) {
|
if (!logsState.has(appName)) {
|
||||||
logsState.set(appName, {
|
logsState.set(appName, {
|
||||||
dates: [],
|
dates: [],
|
||||||
historicalLogs: [],
|
historicalLogs: [],
|
||||||
|
liveLogs: [],
|
||||||
loadingDates: false,
|
loadingDates: false,
|
||||||
loadingLogs: false,
|
loadingLogs: false,
|
||||||
searchFilter: '',
|
searchFilter: '',
|
||||||
|
|
@ -34,6 +41,46 @@ const getState = (appName: string): LogsState => {
|
||||||
return logsState.get(appName)!
|
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', {
|
const LogsControls = define('LogsControls', {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
|
@ -92,7 +139,7 @@ function LogsContent() {
|
||||||
|
|
||||||
const state = getState(currentApp.name)
|
const state = getState(currentApp.name)
|
||||||
const isLive = state.selectedDate === 'live'
|
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)
|
const filteredHistoricalLogs = filterLogs(state.historicalLogs, state.searchFilter, l => l)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -201,6 +248,9 @@ export function LogsSection({ app }: { app: App }) {
|
||||||
currentApp = app
|
currentApp = app
|
||||||
const state = getState(app.name)
|
const state = getState(app.name)
|
||||||
|
|
||||||
|
// Connect to per-app log stream for live logs
|
||||||
|
connectLogStream(app.name)
|
||||||
|
|
||||||
// Load dates on first render
|
// Load dates on first render
|
||||||
if (state.dates.length === 0 && !state.loadingDates) {
|
if (state.dates.length === 0 && !state.loadingDates) {
|
||||||
loadDates(app.name)
|
loadDates(app.name)
|
||||||
|
|
|
||||||
|
|
@ -114,6 +114,8 @@ export function scrollLogsToBottom() {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let _logsRenderQueued = false
|
||||||
|
|
||||||
export function initUnifiedLogs() {
|
export function initUnifiedLogs() {
|
||||||
if (_source) return
|
if (_source) return
|
||||||
_source = new EventSource('/api/system/logs/stream')
|
_source = new EventSource('/api/system/logs/stream')
|
||||||
|
|
@ -121,9 +123,21 @@ export function initUnifiedLogs() {
|
||||||
try {
|
try {
|
||||||
const line = JSON.parse(e.data) as UnifiedLogLine
|
const line = JSON.parse(e.data) as UnifiedLogLine
|
||||||
_logs = [..._logs.slice(-(MAX_LOGS - 1)), line]
|
_logs = [..._logs.slice(-(MAX_LOGS - 1)), line]
|
||||||
|
if (!_logsRenderQueued) {
|
||||||
|
_logsRenderQueued = true
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
_logsRenderQueued = false
|
||||||
renderLogs()
|
renderLogs()
|
||||||
|
})
|
||||||
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
_source.onerror = () => {
|
||||||
|
if (_source?.readyState === EventSource.CLOSED) {
|
||||||
|
_source = undefined
|
||||||
|
setTimeout(initUnifiedLogs, 2000)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function LogsTabsBar() {
|
function LogsTabsBar() {
|
||||||
|
|
|
||||||
|
|
@ -146,16 +146,30 @@ function VitalsContent() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let _vitalsRenderQueued = false
|
||||||
|
|
||||||
export function initVitals() {
|
export function initVitals() {
|
||||||
if (_source) return
|
if (_source) return
|
||||||
_source = new EventSource('/api/system/metrics/stream')
|
_source = new EventSource('/api/system/metrics/stream')
|
||||||
_source.onmessage = e => {
|
_source.onmessage = e => {
|
||||||
try {
|
try {
|
||||||
_metrics = JSON.parse(e.data)
|
_metrics = JSON.parse(e.data)
|
||||||
|
if (!_vitalsRenderQueued) {
|
||||||
|
_vitalsRenderQueued = true
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
_vitalsRenderQueued = false
|
||||||
update('#vitals', <VitalsContent />)
|
update('#vitals', <VitalsContent />)
|
||||||
updateTooltips(_metrics.apps)
|
updateTooltips(_metrics.apps)
|
||||||
|
})
|
||||||
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
_source.onerror = () => {
|
||||||
|
if (_source?.readyState === EventSource.CLOSED) {
|
||||||
|
_source = undefined
|
||||||
|
setTimeout(initVitals, 2000)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Vitals() {
|
export function Vitals() {
|
||||||
|
|
|
||||||
|
|
@ -53,14 +53,38 @@ narrowQuery.addEventListener('change', e => {
|
||||||
// Initialize router (sets initial state from URL and renders)
|
// Initialize router (sets initial state from URL and renders)
|
||||||
initRouter(render)
|
initRouter(render)
|
||||||
|
|
||||||
// SSE connection
|
// SSE connection with reconnection handling
|
||||||
const events = new EventSource('/api/apps/stream')
|
let renderQueued = false
|
||||||
events.onmessage = e => {
|
|
||||||
|
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))
|
setApps(JSON.parse(e.data))
|
||||||
|
|
||||||
if (selectedApp && !apps.some(a => a.name === selectedApp)) {
|
if (selectedApp && !apps.some(a => a.name === selectedApp)) {
|
||||||
navigate('/')
|
navigate('/')
|
||||||
}
|
}
|
||||||
|
|
||||||
render()
|
queueRender()
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { disconnectLogStream } from './components/LogsSection'
|
||||||
import { setCurrentView, setDashboardTab, setMobileSidebar, setSelectedApp, setSelectedTab } from './state'
|
import { setCurrentView, setDashboardTab, setMobileSidebar, setSelectedApp, setSelectedTab } from './state'
|
||||||
|
|
||||||
let _render: () => void
|
let _render: () => void
|
||||||
|
|
@ -41,9 +42,11 @@ function route() {
|
||||||
setCurrentView('dashboard')
|
setCurrentView('dashboard')
|
||||||
} else if (path === '/settings') {
|
} else if (path === '/settings') {
|
||||||
setSelectedApp(null)
|
setSelectedApp(null)
|
||||||
|
disconnectLogStream()
|
||||||
setCurrentView('settings')
|
setCurrentView('settings')
|
||||||
} else {
|
} else {
|
||||||
setSelectedApp(null)
|
setSelectedApp(null)
|
||||||
|
disconnectLogStream()
|
||||||
const segment = path.slice(1)
|
const segment = path.slice(1)
|
||||||
setDashboardTab(segment || 'urls')
|
setDashboardTab(segment || 'urls')
|
||||||
setCurrentView('dashboard')
|
setCurrentView('dashboard')
|
||||||
|
|
|
||||||
|
|
@ -23,15 +23,13 @@ function convert(app: BackendApp): SharedApp {
|
||||||
return { ...rest, pid: proc?.pid }
|
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
|
// For discrete lifecycle events consumed by app processes, see /api/events/stream
|
||||||
router.sse('/stream', (send) => {
|
router.sse('/stream', (send) => {
|
||||||
let queue = Promise.resolve()
|
let queue = Promise.resolve()
|
||||||
const broadcast = () => {
|
const broadcast = () => {
|
||||||
const apps: SharedApp[] = allApps().map(app => ({
|
const apps: SharedApp[] = allApps().map(convert)
|
||||||
...convert(app),
|
|
||||||
logs: app.logs,
|
|
||||||
}))
|
|
||||||
queue = queue.then(() => send(apps))
|
queue = queue.then(() => send(apps))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import type { ToesEvent, ToesEventInput, ToesEventType } from '../shared/events'
|
||||||
import type { Subprocess } from 'bun'
|
import type { Subprocess } from 'bun'
|
||||||
import { DEFAULT_EMOJI, VALID_NAME } from '@types'
|
import { DEFAULT_EMOJI, VALID_NAME } from '@types'
|
||||||
import { buildAppUrl, toSubdomain } from '@urls'
|
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 { LOCAL_HOST } from '%config'
|
||||||
import { join, resolve } from 'path'
|
import { join, resolve } from 'path'
|
||||||
import { loadAppEnv } from '../tools/env'
|
import { loadAppEnv } from '../tools/env'
|
||||||
|
|
@ -115,6 +115,7 @@ export async function initApps() {
|
||||||
setupShutdownHandlers()
|
setupShutdownHandlers()
|
||||||
rotateLogs()
|
rotateLogs()
|
||||||
discoverApps()
|
discoverApps()
|
||||||
|
watchAppsDir()
|
||||||
runApps()
|
runApps()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -445,9 +446,17 @@ const logFile = (appName: string, date: string = formatLogDate()) =>
|
||||||
const isApp = (dir: string): boolean =>
|
const isApp = (dir: string): boolean =>
|
||||||
!loadApp(dir).error
|
!loadApp(dir).error
|
||||||
|
|
||||||
|
let _updateTimer: Timer | undefined
|
||||||
|
const UPDATE_DEBOUNCE_MS = 100
|
||||||
|
|
||||||
export const update = () => {
|
export const update = () => {
|
||||||
setApps(allApps())
|
setApps(allApps())
|
||||||
|
// Debounce SSE broadcasts to avoid overwhelming clients during rapid changes
|
||||||
|
if (_updateTimer) clearTimeout(_updateTimer)
|
||||||
|
_updateTimer = setTimeout(() => {
|
||||||
|
_updateTimer = undefined
|
||||||
_listeners.forEach(cb => cb())
|
_listeners.forEach(cb => cb())
|
||||||
|
}, UPDATE_DEBOUNCE_MS)
|
||||||
}
|
}
|
||||||
|
|
||||||
function allAppDirs() {
|
function allAppDirs() {
|
||||||
|
|
@ -457,6 +466,25 @@ function allAppDirs() {
|
||||||
.sort()
|
.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() {
|
function discoverApps() {
|
||||||
for (const dir of allAppDirs()) {
|
for (const dir of allAppDirs()) {
|
||||||
const { pkg, error } = loadApp(dir)
|
const { pkg, error } = loadApp(dir)
|
||||||
|
|
@ -473,6 +501,14 @@ function discoverApps() {
|
||||||
update()
|
update()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function watchAppsDir() {
|
||||||
|
let debounce: Timer | undefined
|
||||||
|
watch(APPS_DIR, (_event, _filename) => {
|
||||||
|
clearTimeout(debounce)
|
||||||
|
debounce = setTimeout(diffAppsDir, 500)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function ensureLogDir(appName: string): string {
|
function ensureLogDir(appName: string): string {
|
||||||
const dir = logDir(appName)
|
const dir = logDir(appName)
|
||||||
if (!existsSync(dir)) {
|
if (!existsSync(dir)) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user