persistent logs

This commit is contained in:
Chris Wanstrath 2026-02-01 22:15:25 -08:00
parent a81d61f910
commit f3040abc5d
8 changed files with 470 additions and 44 deletions

View File

@ -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<string[]>(`/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<string[]>(`/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<string[]>(`/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)
}
}
}

View File

@ -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 <date>', 'show logs from a specific date (YYYY-MM-DD)')
.option('-s, --since <duration>', 'show logs since duration (e.g., 1h, 2d)')
.option('-g, --grep <pattern>', '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 <date>', 'show logs from a specific date (YYYY-MM-DD)')
.option('-s, --since <duration>', 'show logs since duration (e.g., 1h, 2d)')
.option('-g, --grep <pattern>', 'filter logs by pattern')
.action(logApp)
program

View File

@ -1,5 +1,11 @@
export const getLogDates = (name: string): Promise<string[]> =>
fetch(`/api/apps/${name}/logs/dates`).then(r => r.json())
export const getLogsForDate = (name: string, date: string): Promise<string[]> =>
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' })

View File

@ -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 }) {
)}
</Section>
<Section>
<SectionTitle>Logs</SectionTitle>
<LogsContainer>
{app.logs?.length ? (
app.logs.map((line, i) => (
<LogLine key={i}>
<LogTime>{new Date(line.time).toLocaleTimeString()}</LogTime>
<span>{line.text}</span>
</LogLine>
))
) : (
<LogLine>
<LogTime>--:--:--</LogTime>
<span style={{ color: theme('colors-textFaint') }}>No logs yet</span>
</LogLine>
)}
</LogsContainer>
</Section>
<LogsSection app={app} />
<ActionBar>
{app.state === 'stopped' && (

View File

@ -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<string, LogsState>()
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<T extends string | LogLineType>(
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 && (
<LogLine>
<LogTime>--:--:--</LogTime>
<span style={{ color: theme('colors-textFaint') }}>Loading...</span>
</LogLine>
)}
{!state.loadingLogs && isLive && (
filteredLiveLogs.length ? (
filteredLiveLogs.map((line, i) => (
<LogLine key={i}>
<LogTime>{new Date(line.time).toLocaleTimeString()}</LogTime>
<span>{line.text}</span>
</LogLine>
))
) : (
<LogLine>
<LogTime>--:--:--</LogTime>
<span style={{ color: theme('colors-textFaint') }}>
{state.searchFilter ? 'No matching logs' : 'No logs yet'}
</span>
</LogLine>
)
)}
{!state.loadingLogs && !isLive && (
filteredHistoricalLogs.length ? (
filteredHistoricalLogs.map((line, i) => (
<LogLine key={i}>
<span style={{ color: theme('colors-textFaintest'), marginRight: 12 }}>
{line.match(/^\[([^\]]+)\]/)?.[1]?.split('T')[1]?.slice(0, 8) ?? '--:--:--'}
</span>
<span>{line.replace(/^\[[^\]]+\] \[[^\]]+\] /, '')}</span>
</LogLine>
))
) : (
<LogLine>
<LogTime>--:--:--</LogTime>
<span style={{ color: theme('colors-textFaint') }}>
{state.searchFilter ? 'No matching logs' : 'No logs for this date'}
</span>
</LogLine>
)
)}
</>
)
}
const updateLogsContent = () =>
update('#logs-content', <LogsContent />)
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 (
<Section>
<LogsHeader>
<SectionTitle style={{ marginBottom: 0 }}>Logs</SectionTitle>
<LogsControls>
<SmallInput
id="logs-filter"
type="text"
placeholder="Filter..."
value={state.searchFilter}
onInput={(e: Event) => handleSearchChange(app.name, (e.target as HTMLInputElement).value)}
/>
<SmallSelect
id="logs-date"
value={state.selectedDate}
onChange={(e: Event) => handleDateChange(app.name, (e.target as HTMLSelectElement).value)}
>
<option value="live">Live</option>
{state.dates.map(date => (
<option key={date} value={date}>{date}</option>
))}
</SmallSelect>
</LogsControls>
</LogsHeader>
<LogsContainer id="logs-content">
<LogsContent />
</LogsContainer>
</Section>
)
}

View File

@ -11,8 +11,8 @@ export const LogsContainer = define('LogsContainer', {
maxHeight: 200,
overflow: 'auto',
render({ props: { children }, parts: { Root } }) {
return <Root ref={(el: HTMLElement | null) => {
render({ props: { children, id }, parts: { Root } }) {
return <Root id={id} ref={(el: HTMLElement | null) => {
if (el) requestAnimationFrame(() => el.scrollTop = el.scrollHeight)
}}>{children}</Root>
}

View File

@ -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 => {

View File

@ -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<void> {
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<Uint8Array> | null) => {
const streamOutput = async (stream: ReadableStream<Uint8Array> | 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) {