diff --git a/apps/basic/20260130-000000/index.tsx b/apps/basic/20260130-000000/index.tsx index 301eab6..bd40004 100644 --- a/apps/basic/20260130-000000/index.tsx +++ b/apps/basic/20260130-000000/index.tsx @@ -4,6 +4,12 @@ const app = new Hype app.get('/', c => c.html(

Hi there!

)) +// Test crash - remove after testing +setTimeout(() => { + console.log('About to crash...') + throw new Error('Test crash!') +}, 3000) + const apps = () => { } diff --git a/src/cli/commands/crashes.ts b/src/cli/commands/crashes.ts new file mode 100644 index 0000000..c5171ae --- /dev/null +++ b/src/cli/commands/crashes.ts @@ -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(`/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 { + try { + const res = await fetch(makeUrl(`/api/apps/${appName}/crashes/${filename}`)) + if (!res.ok) return null + return await res.text() + } catch { + return null + } +} diff --git a/src/cli/commands/index.ts b/src/cli/commands/index.ts index a79022f..c59e4e4 100644 --- a/src/cli/commands/index.ts +++ b/src/cli/commands/index.ts @@ -1,3 +1,4 @@ +export { crashesApp } from './crashes' export { logApp } from './logs' export { configShow, diff --git a/src/cli/setup.ts b/src/cli/setup.ts index 6eaaf6e..086cdfb 100644 --- a/src/cli/setup.ts +++ b/src/cli/setup.ts @@ -4,6 +4,7 @@ import color from 'kleur' import { cleanApp, configShow, + crashesApp, diffApp, getApp, infoApp, @@ -106,6 +107,12 @@ program .option('-g, --grep ', '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') diff --git a/src/server/api/apps.ts b/src/server/api/apps.ts index 1d6d5bd..af69f22 100644 --- a/src/server/api/apps.ts +++ b/src/server/api/apps.ts @@ -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 { diff --git a/src/server/apps.ts b/src/server/apps.ts index a540561..376e3a5 100644 --- a/src/server/apps.ts +++ b/src/server/apps.ts @@ -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')