persistent logs
This commit is contained in:
parent
a81d61f910
commit
f3040abc5d
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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' })
|
||||
|
|
|
|||
|
|
@ -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' && (
|
||||
|
|
|
|||
244
src/client/components/LogsSection.tsx
Normal file
244
src/client/components/LogsSection.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user