From f3040abc5d747a1fd3743f625e6226c1f2c33b8d Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Sun, 1 Feb 2026 22:15:25 -0800 Subject: [PATCH] persistent logs --- src/cli/commands/logs.ts | 103 ++++++++++- src/cli/setup.ts | 6 + src/client/api.ts | 10 +- src/client/components/AppDetail.tsx | 25 +-- src/client/components/LogsSection.tsx | 244 ++++++++++++++++++++++++++ src/client/styles/logs.tsx | 4 +- src/server/api/apps.ts | 30 +++- src/server/apps.ts | 92 +++++++++- 8 files changed, 470 insertions(+), 44 deletions(-) create mode 100644 src/client/components/LogsSection.tsx diff --git a/src/cli/commands/logs.ts b/src/cli/commands/logs.ts index 0b5f075..23798d0 100644 --- a/src/cli/commands/logs.ts +++ b/src/cli/commands/logs.ts @@ -2,18 +2,107 @@ import type { LogLine } from '@types' import { get, handleError, makeUrl } from '../http' import { resolveAppName } from '../name' -export const printLog = (line: LogLine) => - console.log(`${new Date(line.time).toLocaleTimeString()} ${line.text}`) +interface LogOptions { + date?: string + follow?: boolean + grep?: string + since?: string +} -export async function logApp(arg: string | undefined, options: { follow?: boolean }) { +const formatDate = (date: Date) => + date.toISOString().slice(0, 10) + +const matchesGrep = (text: string, pattern: string) => + text.toLowerCase().includes(pattern.toLowerCase()) + +const parseDuration = (duration: string): number | null => { + const match = duration.match(/^(\d+)([hdwm])$/) + if (!match) return null + + const value = parseInt(match[1]!, 10) + const unit = match[2]! + + const ms = { + h: 60 * 60 * 1000, + d: 24 * 60 * 60 * 1000, + w: 7 * 24 * 60 * 60 * 1000, + m: 30 * 24 * 60 * 60 * 1000, + } + + return value * ms[unit as keyof typeof ms] +} + +const printDiskLog = (line: string, grep?: string) => { + if (grep && !matchesGrep(line, grep)) return + console.log(line) +} + +export const printLog = (line: LogLine, grep?: string) => { + if (grep && !matchesGrep(line.text, grep)) return + console.log(`${new Date(line.time).toLocaleTimeString()} ${line.text}`) +} + +export async function logApp(arg: string | undefined, options: LogOptions) { const name = resolveAppName(arg) if (!name) return if (options.follow) { - await tailLogs(name) + await tailLogs(name, options.grep) return } + // Handle --since option + if (options.since) { + const ms = parseDuration(options.since) + if (!ms) { + console.error('Invalid duration. Use format like: 1h, 2d, 1w, 1m') + return + } + + const dates = await get(`/api/apps/${name}/logs/dates`) + if (!dates) { + console.error(`App not found: ${name}`) + return + } + + const cutoff = new Date(Date.now() - ms) + const cutoffDate = formatDate(cutoff) + + // Filter dates that are >= cutoff + const relevantDates = dates.filter(d => d >= cutoffDate).reverse() + if (relevantDates.length === 0) { + console.log('No logs in the specified time range') + return + } + + for (const date of relevantDates) { + const lines = await get(`/api/apps/${name}/logs?date=${date}`) + if (!lines) continue + for (const line of lines) { + printDiskLog(line, options.grep) + } + } + return + } + + // Handle --date option + if (options.date) { + const lines = await get(`/api/apps/${name}/logs?date=${options.date}`) + if (!lines) { + console.error(`App not found: ${name}`) + return + } + if (lines.length === 0) { + console.log('No logs for this date') + return + } + for (const line of lines) { + printDiskLog(line, options.grep) + } + return + } + + // Default: show today's in-memory logs const logs: LogLine[] | undefined = await get(`/api/apps/${name}/logs`) if (!logs) { console.error(`App not found: ${name}`) @@ -24,11 +113,11 @@ export async function logApp(arg: string | undefined, options: { follow?: boolea return } for (const line of logs) { - printLog(line) + printLog(line, options.grep) } } -export async function tailLogs(name: string) { +export async function tailLogs(name: string, grep?: string) { try { const url = makeUrl(`/api/apps/${name}/logs/stream`) const res = await fetch(url) @@ -53,7 +142,7 @@ export async function tailLogs(name: string) { for (const line of lines) { if (line.startsWith('data: ')) { const data = JSON.parse(line.slice(6)) as LogLine - printLog(data) + printLog(data, grep) } } } diff --git a/src/cli/setup.ts b/src/cli/setup.ts index 265324b..6eaaf6e 100644 --- a/src/cli/setup.ts +++ b/src/cli/setup.ts @@ -92,12 +92,18 @@ program .description('Show logs for an app') .argument('[name]', 'app name (uses current directory if omitted)') .option('-f, --follow', 'follow log output') + .option('-d, --date ', 'show logs from a specific date (YYYY-MM-DD)') + .option('-s, --since ', 'show logs since duration (e.g., 1h, 2d)') + .option('-g, --grep ', 'filter logs by pattern') .action(logApp) program .command('log', { hidden: true }) .argument('[name]', 'app name (uses current directory if omitted)') .option('-f, --follow', 'follow log output') + .option('-d, --date ', 'show logs from a specific date (YYYY-MM-DD)') + .option('-s, --since ', 'show logs since duration (e.g., 1h, 2d)') + .option('-g, --grep ', 'filter logs by pattern') .action(logApp) program diff --git a/src/client/api.ts b/src/client/api.ts index 0f7b794..accc7c1 100644 --- a/src/client/api.ts +++ b/src/client/api.ts @@ -1,5 +1,11 @@ +export const getLogDates = (name: string): Promise => + fetch(`/api/apps/${name}/logs/dates`).then(r => r.json()) + +export const getLogsForDate = (name: string, date: string): Promise => + fetch(`/api/apps/${name}/logs?date=${date}`).then(r => r.json()) + +export const restartApp = (name: string) => fetch(`/api/apps/${name}/restart`, { method: 'POST' }) + export const startApp = (name: string) => fetch(`/api/apps/${name}/start`, { method: 'POST' }) export const stopApp = (name: string) => fetch(`/api/apps/${name}/stop`, { method: 'POST' }) - -export const restartApp = (name: string) => fetch(`/api/apps/${name}/restart`, { method: 'POST' }) diff --git a/src/client/components/AppDetail.tsx b/src/client/components/AppDetail.tsx index 86d89fa..9017e02 100644 --- a/src/client/components/AppDetail.tsx +++ b/src/client/components/AppDetail.tsx @@ -12,9 +12,6 @@ import { InfoRow, InfoValue, Link, - LogLine, - LogsContainer, - LogTime, Main, MainContent, MainHeader, @@ -26,8 +23,9 @@ import { TabContent, } from '../styles' import { openEmojiPicker } from './emoji-picker' -import { theme } from '../themes' +import { LogsSection } from './LogsSection' import { Nav } from './Nav' +import { theme } from '../themes' const OpenEmojiPicker = define('OpenEmojiPicker', { cursor: 'pointer', @@ -106,24 +104,7 @@ export function AppDetail({ app, render }: { app: App, render: () => void }) { )} -
- Logs - - {app.logs?.length ? ( - app.logs.map((line, i) => ( - - {new Date(line.time).toLocaleTimeString()} - {line.text} - - )) - ) : ( - - --:--:-- - No logs yet - - )} - -
+ {app.state === 'stopped' && ( diff --git a/src/client/components/LogsSection.tsx b/src/client/components/LogsSection.tsx new file mode 100644 index 0000000..6a139f5 --- /dev/null +++ b/src/client/components/LogsSection.tsx @@ -0,0 +1,244 @@ +import { define } from '@because/forge' +import type { App, LogLine as LogLineType } from '../../shared/types' +import { getLogDates, getLogsForDate } from '../api' +import { LogLine, LogsContainer, LogTime, Section, SectionTitle } from '../styles' +import { theme } from '../themes' +import { update } from '../update' + +type LogsState = { + dates: string[] + historicalLogs: string[] + loadingDates: boolean + loadingLogs: boolean + searchFilter: string + selectedDate: string +} + +const logsState = new Map() + +let currentApp: App | null = null + +const getState = (appName: string): LogsState => { + if (!logsState.has(appName)) { + logsState.set(appName, { + dates: [], + historicalLogs: [], + loadingDates: false, + loadingLogs: false, + searchFilter: '', + selectedDate: 'live', + }) + } + return logsState.get(appName)! +} + +const LogsHeader = define('LogsHeader', { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + gap: 12, + marginBottom: 12, +}) + +const LogsControls = define('LogsControls', { + display: 'flex', + alignItems: 'center', + gap: 8, +}) + +const SmallSelect = define('SmallSelect', { + base: 'select', + padding: '4px 8px', + background: theme('colors-bgSubtle'), + border: `1px solid ${theme('colors-border')}`, + borderRadius: '4px', + color: theme('colors-text'), + fontSize: 12, + cursor: 'pointer', + selectors: { + '&:focus': { + outline: 'none', + borderColor: theme('colors-primary'), + }, + }, +}) + +const SmallInput = define('SmallInput', { + base: 'input', + padding: '4px 8px', + background: theme('colors-bgSubtle'), + border: `1px solid ${theme('colors-border')}`, + borderRadius: '4px', + color: theme('colors-text'), + fontSize: 12, + width: 120, + selectors: { + '&:focus': { + outline: 'none', + borderColor: theme('colors-primary'), + }, + '&::placeholder': { + color: theme('colors-textFaint'), + }, + }, +}) + +function filterLogs( + logs: T[], + filter: string, + getText: (log: T) => string +): T[] { + if (!filter) return logs + const lower = filter.toLowerCase() + return logs.filter(log => getText(log).toLowerCase().includes(lower)) +} + +function LogsContent() { + if (!currentApp) return null + + const state = getState(currentApp.name) + const isLive = state.selectedDate === 'live' + const filteredLiveLogs = filterLogs(currentApp.logs ?? [], state.searchFilter, l => l.text) + const filteredHistoricalLogs = filterLogs(state.historicalLogs, state.searchFilter, l => l) + + return ( + <> + {state.loadingLogs && ( + + --:--:-- + Loading... + + )} + {!state.loadingLogs && isLive && ( + filteredLiveLogs.length ? ( + filteredLiveLogs.map((line, i) => ( + + {new Date(line.time).toLocaleTimeString()} + {line.text} + + )) + ) : ( + + --:--:-- + + {state.searchFilter ? 'No matching logs' : 'No logs yet'} + + + ) + )} + {!state.loadingLogs && !isLive && ( + filteredHistoricalLogs.length ? ( + filteredHistoricalLogs.map((line, i) => ( + + + {line.match(/^\[([^\]]+)\]/)?.[1]?.split('T')[1]?.slice(0, 8) ?? '--:--:--'} + + {line.replace(/^\[[^\]]+\] \[[^\]]+\] /, '')} + + )) + ) : ( + + --:--:-- + + {state.searchFilter ? 'No matching logs' : 'No logs for this date'} + + + ) + )} + + ) +} + +const updateLogsContent = () => + update('#logs-content', ) + +async function loadDates(appName: string) { + const state = getState(appName) + if (state.loadingDates || state.dates.length > 0) return + + state.loadingDates = true + + try { + const dates = await getLogDates(appName) + state.dates = dates + } catch (e) { + console.error('Failed to load log dates:', e) + } finally { + state.loadingDates = false + } +} + +async function loadHistoricalLogs(appName: string, date: string) { + const state = getState(appName) + state.loadingLogs = true + updateLogsContent() + + try { + const logs = await getLogsForDate(appName, date) + state.historicalLogs = logs + } catch (e) { + console.error('Failed to load logs:', e) + state.historicalLogs = [] + } finally { + state.loadingLogs = false + updateLogsContent() + } +} + +function handleDateChange(appName: string, date: string) { + const state = getState(appName) + state.selectedDate = date + state.historicalLogs = [] + + if (date !== 'live') { + loadHistoricalLogs(appName, date) + } else { + updateLogsContent() + } +} + +function handleSearchChange(appName: string, value: string) { + const state = getState(appName) + state.searchFilter = value + updateLogsContent() +} + +export function LogsSection({ app }: { app: App }) { + currentApp = app + const state = getState(app.name) + + // Load dates on first render + if (state.dates.length === 0 && !state.loadingDates) { + loadDates(app.name) + } + + return ( +
+ + Logs + + handleSearchChange(app.name, (e.target as HTMLInputElement).value)} + /> + handleDateChange(app.name, (e.target as HTMLSelectElement).value)} + > + + {state.dates.map(date => ( + + ))} + + + + + + +
+ ) +} diff --git a/src/client/styles/logs.tsx b/src/client/styles/logs.tsx index 6e7f611..e6e426f 100644 --- a/src/client/styles/logs.tsx +++ b/src/client/styles/logs.tsx @@ -11,8 +11,8 @@ export const LogsContainer = define('LogsContainer', { maxHeight: 200, overflow: 'auto', - render({ props: { children }, parts: { Root } }) { - return { + render({ props: { children, id }, parts: { Root } }) { + return { if (el) requestAnimationFrame(() => el.scrollTop = el.scrollHeight) }}>{children} } diff --git a/src/server/api/apps.ts b/src/server/api/apps.ts index 148b12a..1d6d5bd 100644 --- a/src/server/api/apps.ts +++ b/src/server/api/apps.ts @@ -1,4 +1,4 @@ -import { APPS_DIR, allApps, onChange, registerApp, renameApp, startApp, stopApp, updateAppIcon } from '$apps' +import { APPS_DIR, allApps, getLogDates, onChange, readLogs, registerApp, renameApp, startApp, stopApp, updateAppIcon } from '$apps' import type { App as BackendApp } from '$apps' import type { App as SharedApp } from '@types' import { generateTemplates, type TemplateType } from '%templates' @@ -57,7 +57,33 @@ router.get('/:app/logs', c => { const app = allApps().find(a => a.name === appName) if (!app) return c.json({ error: 'App not found' }, 404) - return c.json(app.logs ?? []) + // Check for date query param to read from disk + const date = c.req.query('date') + const tailParam = c.req.query('tail') + const tail = tailParam ? parseInt(tailParam, 10) : undefined + + if (date) { + // Read from disk for historical logs + const lines = readLogs(appName, date, tail) + return c.json(lines) + } + + // Return in-memory logs for today (real-time) + const logs = app.logs ?? [] + if (tail && tail > 0) { + return c.json(logs.slice(-tail)) + } + return c.json(logs) +}) + +router.get('/:app/logs/dates', c => { + const appName = c.req.param('app') + if (!appName) return c.json({ error: 'App not found' }, 404) + + const app = allApps().find(a => a.name === appName) + if (!app) return c.json({ error: 'App not found' }, 404) + + return c.json(getLogDates(appName)) }) router.post('/', async c => { diff --git a/src/server/apps.ts b/src/server/apps.ts index de40dd9..a540561 100644 --- a/src/server/apps.ts +++ b/src/server/apps.ts @@ -1,7 +1,7 @@ import type { App as SharedApp, AppState } from '@types' import type { Subprocess } from 'bun' import { DEFAULT_EMOJI } from '@types' -import { existsSync, readdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from 'fs' +import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync } from 'fs' import { join, resolve } from 'path' import { appLog, hostLog, setApps } from './tui' @@ -13,6 +13,7 @@ export const TOES_URL = process.env.TOES_URL ?? `http://localhost:${process.env. const HEALTH_CHECK_FAILURES_BEFORE_RESTART = 3 const HEALTH_CHECK_INTERVAL = 30000 const HEALTH_CHECK_TIMEOUT = 5000 +const LOG_RETENTION_DAYS = 7 const MAX_LOGS = 100 const MAX_PORT = 3100 const MIN_PORT = 3001 @@ -54,9 +55,34 @@ export const runApps = () => export const runningApps = (): App[] => allApps().filter(a => a.state === 'running') +export function getLogDates(appName: string): string[] { + const dir = logDir(appName) + if (!existsSync(dir)) return [] + + return readdirSync(dir) + .filter(f => f.endsWith('.log')) + .map(f => f.replace('.log', '')) + .sort() + .reverse() +} + +export function readLogs(appName: string, date?: string, tail?: number): string[] { + const file = logFile(appName, date ?? formatLogDate()) + if (!existsSync(file)) return [] + + const content = readFileSync(file, 'utf-8') + const lines = content.split('\n').filter(Boolean) + + if (tail && tail > 0) { + return lines.slice(-tail) + } + return lines +} + export function initApps() { initPortPool() setupShutdownHandlers() + rotateLogs() discoverApps() runApps() } @@ -177,12 +203,12 @@ export async function restartApp(dir: string): Promise { const pollInterval = 100 let waited = 0 - while (app.state !== 'stopped' && waited < maxWait) { + while (_apps.get(dir)?.state !== 'stopped' && waited < maxWait) { await new Promise(resolve => setTimeout(resolve, pollInterval)) waited += pollInterval } - if (app.state !== 'stopped') { + if (_apps.get(dir)?.state !== 'stopped') { throw new Error(`App ${dir} failed to stop after ${maxWait}ms`) } } @@ -236,11 +262,20 @@ const clearTimers = (app: App) => { } } +const formatLogDate = (date: Date = new Date()) => + date.toISOString().slice(0, 10) + const info = (app: App, ...msg: string[]) => { appLog(app, ...msg) app.logs?.push({ time: Date.now(), text: msg.join(' ') }) } +const logDir = (appName: string) => + join(APPS_DIR, appName, 'logs') + +const logFile = (appName: string, date: string = formatLogDate()) => + join(logDir(appName), `${date}.log`) + const isApp = (dir: string): boolean => !loadApp(dir).error @@ -267,6 +302,14 @@ function discoverApps() { update() } +function ensureLogDir(appName: string): string { + const dir = logDir(appName) + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }) + } + return dir +} + function getPort(appName?: string): number { // Try to return the same port this app used before if (appName) { @@ -409,6 +452,32 @@ function releasePort(port: number) { } } +function rotateLogs() { + const cutoff = Date.now() - LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000 + + for (const appName of allAppDirs()) { + const dir = logDir(appName) + if (!existsSync(dir)) continue + + for (const file of readdirSync(dir)) { + if (!file.endsWith('.log')) continue + const dateStr = file.replace('.log', '') + const fileDate = new Date(dateStr).getTime() + if (fileDate < cutoff) { + unlinkSync(join(dir, file)) + hostLog(`Rotated old log: ${appName}/logs/${file}`) + } + } + } +} + +function writeLogLine(appName: string, streamType: 'stdout' | 'stderr' | 'system', text: string) { + ensureLogDir(appName) + const timestamp = new Date().toISOString() + const line = `[${timestamp}] [${streamType}] ${text}\n` + appendFileSync(logFile(appName), line) +} + async function runApp(dir: string, port: number) { const { error } = loadApp(dir) if (error) return @@ -463,7 +532,7 @@ async function runApp(dir: string, port: number) { // Start health checks startHealthChecks(app, port) - const streamOutput = async (stream: ReadableStream | null) => { + const streamOutput = async (stream: ReadableStream | null, streamType: 'stdout' | 'stderr') => { if (!stream) return const reader = stream.getReader() const decoder = new TextDecoder() @@ -474,14 +543,15 @@ async function runApp(dir: string, port: number) { const lines = chunk.split('\n').map(l => l.trimEnd()).filter(Boolean) for (const text of lines) { info(app, text) + writeLogLine(dir, streamType, text) app.logs = (app.logs ?? []).slice(-MAX_LOGS) } if (lines.length) update() } } - streamOutput(proc.stdout) - streamOutput(proc.stderr) + streamOutput(proc.stdout, 'stdout') + streamOutput(proc.stderr, 'stderr') // Handle process exit proc.exited.then(code => { @@ -491,10 +561,14 @@ async function runApp(dir: string, port: number) { // Check if app was stable before crashing (for backoff reset) maybeResetBackoff(app) - if (code !== 0) - app.logs?.push({ time: Date.now(), text: `Exited with code ${code}` }) - else + if (code !== 0) { + const msg = `Exited with code ${code}` + app.logs?.push({ time: Date.now(), text: msg }) + writeLogLine(dir, 'system', msg) + } else { app.logs?.push({ time: Date.now(), text: 'Stopped' }) + writeLogLine(dir, 'system', 'Stopped') + } // Release port back to pool if (app.port) {