[wip] crash log #4

Closed
defunkt wants to merge 1 commits from crash-log into main
6 changed files with 211 additions and 4 deletions

View File

@ -4,6 +4,12 @@ const app = new Hype
app.get('/', c => c.html(<h1>Hi there!</h1>))
// Test crash - remove after testing
setTimeout(() => {
console.log('About to crash...')
throw new Error('Test crash!')
}, 3000)
const apps = () => {
}

View File

@ -0,0 +1,90 @@
import color from 'kleur'
import { get, makeUrl } from '../http'
import { resolveAppName } from '../name'
interface CrashSummary {
date: string
exitCode: string
filename: string
time: string
uptime: string
}
const parseCrashFilename = (filename: string): { date: string, time: string } => {
// Format: YYYY-MM-DD-HHMMSS.txt
const match = filename.match(/^(\d{4}-\d{2}-\d{2})-(\d{2})(\d{2})(\d{2})\.txt$/)
if (!match) return { date: filename, time: '' }
const [, date, h, m, s] = match
return { date: date!, time: `${h}:${m}:${s}` }
}
const parseCrashReport = (content: string): { exitCode: string, uptime: string } => {
const lines = content.split('\n')
let exitCode = 'unknown'
let uptime = 'unknown'
for (const line of lines) {
if (line.startsWith('Exit Code: ')) {
exitCode = line.slice('Exit Code: '.length)
} else if (line.startsWith('Uptime: ')) {
uptime = line.slice('Uptime: '.length)
}
}
return { exitCode, uptime }
}
export async function crashesApp(arg: string | undefined) {
const name = resolveAppName(arg)
if (!name) return
const files = await get<string[]>(`/api/apps/${name}/crashes`)
if (!files) {
console.error(`App not found: ${name}`)
return
}
if (files.length === 0) {
console.log('No crash reports found')
return
}
console.log(color.bold(`Crash history for ${name}:`))
console.log()
// Fetch summary info for each crash
const crashes: CrashSummary[] = []
for (const filename of files.slice(0, 10)) { // Show last 10 crashes
const content = await fetchCrashContent(name, filename)
const { date, time } = parseCrashFilename(filename)
const { exitCode, uptime } = content
? parseCrashReport(content)
: { exitCode: 'unknown', uptime: 'unknown' }
crashes.push({ filename, date, time, exitCode, uptime })
}
for (const crash of crashes) {
const exitColor = crash.exitCode === '0' ? color.green : color.red
console.log(
` ${color.gray(crash.date)} ${color.gray(crash.time)} ` +
`exit ${exitColor(crash.exitCode)} ` +
`uptime ${color.cyan(crash.uptime)}`
)
}
if (files.length > 10) {
console.log()
console.log(color.gray(` ... and ${files.length - 10} more`))
}
}
async function fetchCrashContent(appName: string, filename: string): Promise<string | null> {
try {
const res = await fetch(makeUrl(`/api/apps/${appName}/crashes/${filename}`))
if (!res.ok) return null
return await res.text()
} catch {
return null
}
}

View File

@ -1,3 +1,4 @@
export { crashesApp } from './crashes'
export { logApp } from './logs'
export {
configShow,

View File

@ -4,6 +4,7 @@ import color from 'kleur'
import {
cleanApp,
configShow,
crashesApp,
diffApp,
getApp,
infoApp,
@ -106,6 +107,12 @@ program
.option('-g, --grep <pattern>', 'filter logs by pattern')
.action(logApp)
program
.command('crashes')
.description('Show crash history for an app')
.argument('[name]', 'app name (uses current directory if omitted)')
.action(crashesApp)
program
.command('open')
.description('Open an app in browser')

View File

@ -1,4 +1,4 @@
import { APPS_DIR, allApps, getLogDates, onChange, readLogs, registerApp, renameApp, startApp, stopApp, updateAppIcon } from '$apps'
import { APPS_DIR, allApps, getCrashFiles, getLogDates, onChange, readCrashReport, 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'
@ -86,6 +86,31 @@ router.get('/:app/logs/dates', c => {
return c.json(getLogDates(appName))
})
router.get('/:app/crashes', 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)
const files = getCrashFiles(appName)
return c.json(files)
})
router.get('/:app/crashes/:filename', c => {
const appName = c.req.param('app')
const filename = c.req.param('filename')
if (!appName || !filename) 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)
const content = readCrashReport(appName, filename)
if (content === null) return c.json({ error: 'Crash report not found' }, 404)
return c.text(content)
})
router.post('/', async c => {
let body: { name?: string, template?: TemplateType, tool?: boolean }
try {

View File

@ -55,6 +55,16 @@ export const runApps = () =>
export const runningApps = (): App[] =>
allApps().filter(a => a.state === 'running')
export function getCrashFiles(appName: string): string[] {
const dir = crashDir(appName)
if (!existsSync(dir)) return []
return readdirSync(dir)
.filter(f => f.endsWith('.txt'))
.sort()
.reverse()
}
export function getLogDates(appName: string): string[] {
const dir = logDir(appName)
if (!existsSync(dir)) return []
@ -66,6 +76,12 @@ export function getLogDates(appName: string): string[] {
.reverse()
}
export function readCrashReport(appName: string, filename: string): string | null {
const file = join(crashDir(appName), filename)
if (!existsSync(file)) return null
return readFileSync(file, 'utf-8')
}
export function readLogs(appName: string, date?: string, tail?: number): string[] {
const file = logFile(appName, date ?? formatLogDate())
if (!existsSync(file)) return []
@ -270,15 +286,34 @@ const info = (app: App, ...msg: string[]) => {
app.logs?.push({ time: Date.now(), text: msg.join(' ') })
}
const crashDir = (appName: string) =>
join(APPS_DIR, appName, 'crashes')
const formatCrashTimestamp = (date: Date = new Date()) => {
const pad = (n: number) => String(n).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`
}
const formatUptime = (ms: number): string => {
const seconds = Math.floor(ms / 1000) % 60
const minutes = Math.floor(ms / 60000) % 60
const hours = Math.floor(ms / 3600000)
const parts: string[] = []
if (hours > 0) parts.push(`${hours}h`)
if (minutes > 0) parts.push(`${minutes}m`)
parts.push(`${seconds}s`)
return parts.join(' ')
}
const isApp = (dir: string): boolean =>
!loadApp(dir).error
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
const update = () => {
setApps(allApps())
_listeners.forEach(cb => cb())
@ -302,6 +337,14 @@ function discoverApps() {
update()
}
function ensureCrashDir(appName: string): string {
const dir = crashDir(appName)
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true })
}
return dir
}
function ensureLogDir(appName: string): string {
const dir = logDir(appName)
if (!existsSync(dir)) {
@ -471,6 +514,36 @@ function rotateLogs() {
}
}
function writeCrashReport(app: App, exitCode: number | null, signal: string | null) {
const now = new Date()
const uptime = app.started ? Date.now() - app.started : 0
// Get last 20 log lines
const logs = app.logs ?? []
const lastLogs = logs.slice(-20)
const lines: string[] = [
`App: ${app.name}`,
`Time: ${now.toISOString()}`,
`Exit Code: ${exitCode}`,
`Signal: ${signal}`,
`Uptime: ${formatUptime(uptime)}`,
`Restart Attempt: ${app.restartAttempts ?? 0}`,
'',
'--- Last 20 log lines ---',
]
for (const log of lastLogs) {
const timestamp = new Date(log.time).toISOString()
lines.push(`[${timestamp}] ${log.text}`)
}
ensureCrashDir(app.name)
const filename = `${formatCrashTimestamp(now)}.txt`
const filepath = join(crashDir(app.name), filename)
writeFileSync(filepath, lines.join('\n') + '\n')
}
function writeLogLine(appName: string, streamType: 'stdout' | 'stderr' | 'system', text: string) {
ensureLogDir(appName)
const timestamp = new Date().toISOString()
@ -565,6 +638,11 @@ async function runApp(dir: string, port: number) {
const msg = `Exited with code ${code}`
app.logs?.push({ time: Date.now(), text: msg })
writeLogLine(dir, 'system', msg)
// Write crash report only for actual crashes (non-zero exit, not manually stopped)
if (!app.manuallyStopped) {
writeCrashReport(app, code, null)
}
} else {
app.logs?.push({ time: Date.now(), text: 'Stopped' })
writeLogLine(dir, 'system', 'Stopped')