Compare commits
No commits in common. "f3040abc5d747a1fd3743f625e6226c1f2c33b8d" and "1fbc7a98587c042254641aaf8ebb6acff0a522b0" have entirely different histories.
f3040abc5d
...
1fbc7a9858
|
|
@ -2,107 +2,18 @@ import type { LogLine } from '@types'
|
||||||
import { get, handleError, makeUrl } from '../http'
|
import { get, handleError, makeUrl } from '../http'
|
||||||
import { resolveAppName } from '../name'
|
import { resolveAppName } from '../name'
|
||||||
|
|
||||||
interface LogOptions {
|
export const printLog = (line: LogLine) =>
|
||||||
date?: string
|
|
||||||
follow?: boolean
|
|
||||||
grep?: string
|
|
||||||
since?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
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}`)
|
console.log(`${new Date(line.time).toLocaleTimeString()} ${line.text}`)
|
||||||
}
|
|
||||||
|
|
||||||
export async function logApp(arg: string | undefined, options: LogOptions) {
|
export async function logApp(arg: string | undefined, options: { follow?: boolean }) {
|
||||||
const name = resolveAppName(arg)
|
const name = resolveAppName(arg)
|
||||||
if (!name) return
|
if (!name) return
|
||||||
|
|
||||||
if (options.follow) {
|
if (options.follow) {
|
||||||
await tailLogs(name, options.grep)
|
await tailLogs(name)
|
||||||
return
|
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`)
|
const logs: LogLine[] | undefined = await get(`/api/apps/${name}/logs`)
|
||||||
if (!logs) {
|
if (!logs) {
|
||||||
console.error(`App not found: ${name}`)
|
console.error(`App not found: ${name}`)
|
||||||
|
|
@ -113,11 +24,11 @@ export async function logApp(arg: string | undefined, options: LogOptions) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for (const line of logs) {
|
for (const line of logs) {
|
||||||
printLog(line, options.grep)
|
printLog(line)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function tailLogs(name: string, grep?: string) {
|
export async function tailLogs(name: string) {
|
||||||
try {
|
try {
|
||||||
const url = makeUrl(`/api/apps/${name}/logs/stream`)
|
const url = makeUrl(`/api/apps/${name}/logs/stream`)
|
||||||
const res = await fetch(url)
|
const res = await fetch(url)
|
||||||
|
|
@ -142,7 +53,7 @@ export async function tailLogs(name: string, grep?: string) {
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
if (line.startsWith('data: ')) {
|
if (line.startsWith('data: ')) {
|
||||||
const data = JSON.parse(line.slice(6)) as LogLine
|
const data = JSON.parse(line.slice(6)) as LogLine
|
||||||
printLog(data, grep)
|
printLog(data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -92,18 +92,12 @@ program
|
||||||
.description('Show logs for an app')
|
.description('Show logs for an app')
|
||||||
.argument('[name]', 'app name (uses current directory if omitted)')
|
.argument('[name]', 'app name (uses current directory if omitted)')
|
||||||
.option('-f, --follow', 'follow log output')
|
.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)
|
.action(logApp)
|
||||||
|
|
||||||
program
|
program
|
||||||
.command('log', { hidden: true })
|
.command('log', { hidden: true })
|
||||||
.argument('[name]', 'app name (uses current directory if omitted)')
|
.argument('[name]', 'app name (uses current directory if omitted)')
|
||||||
.option('-f, --follow', 'follow log output')
|
.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)
|
.action(logApp)
|
||||||
|
|
||||||
program
|
program
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,5 @@
|
||||||
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 startApp = (name: string) => fetch(`/api/apps/${name}/start`, { method: 'POST' })
|
||||||
|
|
||||||
export const stopApp = (name: string) => fetch(`/api/apps/${name}/stop`, { 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,6 +12,9 @@ import {
|
||||||
InfoRow,
|
InfoRow,
|
||||||
InfoValue,
|
InfoValue,
|
||||||
Link,
|
Link,
|
||||||
|
LogLine,
|
||||||
|
LogsContainer,
|
||||||
|
LogTime,
|
||||||
Main,
|
Main,
|
||||||
MainContent,
|
MainContent,
|
||||||
MainHeader,
|
MainHeader,
|
||||||
|
|
@ -23,9 +26,8 @@ import {
|
||||||
TabContent,
|
TabContent,
|
||||||
} from '../styles'
|
} from '../styles'
|
||||||
import { openEmojiPicker } from './emoji-picker'
|
import { openEmojiPicker } from './emoji-picker'
|
||||||
import { LogsSection } from './LogsSection'
|
|
||||||
import { Nav } from './Nav'
|
|
||||||
import { theme } from '../themes'
|
import { theme } from '../themes'
|
||||||
|
import { Nav } from './Nav'
|
||||||
|
|
||||||
const OpenEmojiPicker = define('OpenEmojiPicker', {
|
const OpenEmojiPicker = define('OpenEmojiPicker', {
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
|
|
@ -104,7 +106,24 @@ export function AppDetail({ app, render }: { app: App, render: () => void }) {
|
||||||
)}
|
)}
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<LogsSection app={app} />
|
<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>
|
||||||
|
|
||||||
<ActionBar>
|
<ActionBar>
|
||||||
{app.state === 'stopped' && (
|
{app.state === 'stopped' && (
|
||||||
|
|
|
||||||
|
|
@ -1,244 +0,0 @@
|
||||||
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,
|
maxHeight: 200,
|
||||||
overflow: 'auto',
|
overflow: 'auto',
|
||||||
|
|
||||||
render({ props: { children, id }, parts: { Root } }) {
|
render({ props: { children }, parts: { Root } }) {
|
||||||
return <Root id={id} ref={(el: HTMLElement | null) => {
|
return <Root ref={(el: HTMLElement | null) => {
|
||||||
if (el) requestAnimationFrame(() => el.scrollTop = el.scrollHeight)
|
if (el) requestAnimationFrame(() => el.scrollTop = el.scrollHeight)
|
||||||
}}>{children}</Root>
|
}}>{children}</Root>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { APPS_DIR, allApps, getLogDates, onChange, readLogs, registerApp, renameApp, startApp, stopApp, updateAppIcon } from '$apps'
|
import { APPS_DIR, allApps, onChange, renameApp, startApp, stopApp, updateAppIcon } from '$apps'
|
||||||
import type { App as BackendApp } from '$apps'
|
import type { App as BackendApp } from '$apps'
|
||||||
import type { App as SharedApp } from '@types'
|
import type { App as SharedApp } from '@types'
|
||||||
import { generateTemplates, type TemplateType } from '%templates'
|
import { generateTemplates, type TemplateType } from '%templates'
|
||||||
|
|
@ -57,33 +57,7 @@ router.get('/:app/logs', c => {
|
||||||
const app = allApps().find(a => a.name === appName)
|
const app = allApps().find(a => a.name === appName)
|
||||||
if (!app) return c.json({ error: 'App not found' }, 404)
|
if (!app) return c.json({ error: 'App not found' }, 404)
|
||||||
|
|
||||||
// Check for date query param to read from disk
|
return c.json(app.logs ?? [])
|
||||||
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 => {
|
router.post('/', async c => {
|
||||||
|
|
@ -127,9 +101,6 @@ router.post('/', async c => {
|
||||||
// Create current symlink
|
// Create current symlink
|
||||||
symlinkSync(ts, currentPath)
|
symlinkSync(ts, currentPath)
|
||||||
|
|
||||||
// Register and start the app
|
|
||||||
registerApp(name)
|
|
||||||
|
|
||||||
return c.json({ ok: true, name })
|
return c.json({ ok: true, name })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { APPS_DIR, allApps, registerApp, removeApp, restartApp } from '$apps'
|
import { APPS_DIR, allApps, removeApp, restartApp, startApp, stopApp } from '$apps'
|
||||||
import { computeHash, generateManifest } from '../sync'
|
import { computeHash, generateManifest } from '../sync'
|
||||||
import { loadGitignore } from '@gitignore'
|
import { loadGitignore } from '@gitignore'
|
||||||
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, symlinkSync, unlinkSync, watch, writeFileSync } from 'fs'
|
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, symlinkSync, unlinkSync, watch, writeFileSync } from 'fs'
|
||||||
|
|
@ -223,13 +223,9 @@ router.post('/apps/:app/activate', async c => {
|
||||||
console.error(`Failed to clean up old versions: ${e}`)
|
console.error(`Failed to clean up old versions: ${e}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register new app or restart existing
|
// Restart app to use new version
|
||||||
const app = allApps().find(a => a.name === appName)
|
const app = allApps().find(a => a.name === appName)
|
||||||
if (!app) {
|
if (app?.state === 'running') {
|
||||||
// New app - register it
|
|
||||||
registerApp(appName)
|
|
||||||
} else if (app.state === 'running') {
|
|
||||||
// Existing app - restart it
|
|
||||||
try {
|
try {
|
||||||
await restartApp(appName)
|
await restartApp(appName)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import type { App as SharedApp, AppState } from '@types'
|
import type { App as SharedApp, AppState } from '@types'
|
||||||
import type { Subprocess } from 'bun'
|
import type { Subprocess } from 'bun'
|
||||||
import { DEFAULT_EMOJI } from '@types'
|
import { DEFAULT_EMOJI } from '@types'
|
||||||
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync } from 'fs'
|
import { existsSync, readdirSync, readFileSync, realpathSync, renameSync, statSync, watch, writeFileSync } from 'fs'
|
||||||
import { join, resolve } from 'path'
|
import { join, resolve } from 'path'
|
||||||
import { appLog, hostLog, setApps } from './tui'
|
import { appLog, hostLog, setApps } from './tui'
|
||||||
|
|
||||||
|
|
@ -13,7 +13,6 @@ export const TOES_URL = process.env.TOES_URL ?? `http://localhost:${process.env.
|
||||||
const HEALTH_CHECK_FAILURES_BEFORE_RESTART = 3
|
const HEALTH_CHECK_FAILURES_BEFORE_RESTART = 3
|
||||||
const HEALTH_CHECK_INTERVAL = 30000
|
const HEALTH_CHECK_INTERVAL = 30000
|
||||||
const HEALTH_CHECK_TIMEOUT = 5000
|
const HEALTH_CHECK_TIMEOUT = 5000
|
||||||
const LOG_RETENTION_DAYS = 7
|
|
||||||
const MAX_LOGS = 100
|
const MAX_LOGS = 100
|
||||||
const MAX_PORT = 3100
|
const MAX_PORT = 3100
|
||||||
const MIN_PORT = 3001
|
const MIN_PORT = 3001
|
||||||
|
|
@ -55,36 +54,12 @@ export const runApps = () =>
|
||||||
export const runningApps = (): App[] =>
|
export const runningApps = (): App[] =>
|
||||||
allApps().filter(a => a.state === 'running')
|
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() {
|
export function initApps() {
|
||||||
initPortPool()
|
initPortPool()
|
||||||
setupShutdownHandlers()
|
setupShutdownHandlers()
|
||||||
rotateLogs()
|
|
||||||
discoverApps()
|
discoverApps()
|
||||||
runApps()
|
runApps()
|
||||||
|
watchAppsDir()
|
||||||
}
|
}
|
||||||
|
|
||||||
export function onChange(cb: () => void) {
|
export function onChange(cb: () => void) {
|
||||||
|
|
@ -111,20 +86,6 @@ export function removeApp(dir: string) {
|
||||||
update()
|
update()
|
||||||
}
|
}
|
||||||
|
|
||||||
export function registerApp(dir: string) {
|
|
||||||
if (_apps.has(dir)) return // Already registered
|
|
||||||
|
|
||||||
const { pkg, error } = loadApp(dir)
|
|
||||||
const state: AppState = error ? 'invalid' : 'stopped'
|
|
||||||
const icon = pkg.toes?.icon ?? DEFAULT_EMOJI
|
|
||||||
const tool = pkg.toes?.tool
|
|
||||||
_apps.set(dir, { name: dir, state, icon, error, tool })
|
|
||||||
update()
|
|
||||||
if (!error) {
|
|
||||||
runApp(dir, getPort(dir))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function renameApp(oldName: string, newName: string): { ok: boolean, error?: string } {
|
export function renameApp(oldName: string, newName: string): { ok: boolean, error?: string } {
|
||||||
const app = _apps.get(oldName)
|
const app = _apps.get(oldName)
|
||||||
if (!app) return { ok: false, error: 'App not found' }
|
if (!app) return { ok: false, error: 'App not found' }
|
||||||
|
|
@ -203,12 +164,12 @@ export async function restartApp(dir: string): Promise<void> {
|
||||||
const pollInterval = 100
|
const pollInterval = 100
|
||||||
let waited = 0
|
let waited = 0
|
||||||
|
|
||||||
while (_apps.get(dir)?.state !== 'stopped' && waited < maxWait) {
|
while (app.state !== 'stopped' && waited < maxWait) {
|
||||||
await new Promise(resolve => setTimeout(resolve, pollInterval))
|
await new Promise(resolve => setTimeout(resolve, pollInterval))
|
||||||
waited += pollInterval
|
waited += pollInterval
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_apps.get(dir)?.state !== 'stopped') {
|
if (app.state !== 'stopped') {
|
||||||
throw new Error(`App ${dir} failed to stop after ${maxWait}ms`)
|
throw new Error(`App ${dir} failed to stop after ${maxWait}ms`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -262,20 +223,11 @@ const clearTimers = (app: App) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatLogDate = (date: Date = new Date()) =>
|
|
||||||
date.toISOString().slice(0, 10)
|
|
||||||
|
|
||||||
const info = (app: App, ...msg: string[]) => {
|
const info = (app: App, ...msg: string[]) => {
|
||||||
appLog(app, ...msg)
|
appLog(app, ...msg)
|
||||||
app.logs?.push({ time: Date.now(), text: msg.join(' ') })
|
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 =>
|
const isApp = (dir: string): boolean =>
|
||||||
!loadApp(dir).error
|
!loadApp(dir).error
|
||||||
|
|
||||||
|
|
@ -302,14 +254,6 @@ function discoverApps() {
|
||||||
update()
|
update()
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureLogDir(appName: string): string {
|
|
||||||
const dir = logDir(appName)
|
|
||||||
if (!existsSync(dir)) {
|
|
||||||
mkdirSync(dir, { recursive: true })
|
|
||||||
}
|
|
||||||
return dir
|
|
||||||
}
|
|
||||||
|
|
||||||
function getPort(appName?: string): number {
|
function getPort(appName?: string): number {
|
||||||
// Try to return the same port this app used before
|
// Try to return the same port this app used before
|
||||||
if (appName) {
|
if (appName) {
|
||||||
|
|
@ -415,6 +359,14 @@ function initPortPool() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isDir(path: string): boolean {
|
||||||
|
try {
|
||||||
|
return statSync(path).isDirectory()
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function loadApp(dir: string): LoadResult {
|
function loadApp(dir: string): LoadResult {
|
||||||
try {
|
try {
|
||||||
const pkgPath = join(APPS_DIR, dir, 'current', 'package.json')
|
const pkgPath = join(APPS_DIR, dir, 'current', 'package.json')
|
||||||
|
|
@ -452,32 +404,6 @@ 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) {
|
async function runApp(dir: string, port: number) {
|
||||||
const { error } = loadApp(dir)
|
const { error } = loadApp(dir)
|
||||||
if (error) return
|
if (error) return
|
||||||
|
|
@ -532,7 +458,7 @@ async function runApp(dir: string, port: number) {
|
||||||
// Start health checks
|
// Start health checks
|
||||||
startHealthChecks(app, port)
|
startHealthChecks(app, port)
|
||||||
|
|
||||||
const streamOutput = async (stream: ReadableStream<Uint8Array> | null, streamType: 'stdout' | 'stderr') => {
|
const streamOutput = async (stream: ReadableStream<Uint8Array> | null) => {
|
||||||
if (!stream) return
|
if (!stream) return
|
||||||
const reader = stream.getReader()
|
const reader = stream.getReader()
|
||||||
const decoder = new TextDecoder()
|
const decoder = new TextDecoder()
|
||||||
|
|
@ -543,15 +469,14 @@ async function runApp(dir: string, port: number) {
|
||||||
const lines = chunk.split('\n').map(l => l.trimEnd()).filter(Boolean)
|
const lines = chunk.split('\n').map(l => l.trimEnd()).filter(Boolean)
|
||||||
for (const text of lines) {
|
for (const text of lines) {
|
||||||
info(app, text)
|
info(app, text)
|
||||||
writeLogLine(dir, streamType, text)
|
|
||||||
app.logs = (app.logs ?? []).slice(-MAX_LOGS)
|
app.logs = (app.logs ?? []).slice(-MAX_LOGS)
|
||||||
}
|
}
|
||||||
if (lines.length) update()
|
if (lines.length) update()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
streamOutput(proc.stdout, 'stdout')
|
streamOutput(proc.stdout)
|
||||||
streamOutput(proc.stderr, 'stderr')
|
streamOutput(proc.stderr)
|
||||||
|
|
||||||
// Handle process exit
|
// Handle process exit
|
||||||
proc.exited.then(code => {
|
proc.exited.then(code => {
|
||||||
|
|
@ -561,14 +486,10 @@ async function runApp(dir: string, port: number) {
|
||||||
// Check if app was stable before crashing (for backoff reset)
|
// Check if app was stable before crashing (for backoff reset)
|
||||||
maybeResetBackoff(app)
|
maybeResetBackoff(app)
|
||||||
|
|
||||||
if (code !== 0) {
|
if (code !== 0)
|
||||||
const msg = `Exited with code ${code}`
|
app.logs?.push({ time: Date.now(), text: `Exited with code ${code}` })
|
||||||
app.logs?.push({ time: Date.now(), text: msg })
|
else
|
||||||
writeLogLine(dir, 'system', msg)
|
|
||||||
} else {
|
|
||||||
app.logs?.push({ time: Date.now(), text: 'Stopped' })
|
app.logs?.push({ time: Date.now(), text: 'Stopped' })
|
||||||
writeLogLine(dir, 'system', 'Stopped')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Release port back to pool
|
// Release port back to pool
|
||||||
if (app.port) {
|
if (app.port) {
|
||||||
|
|
@ -678,3 +599,84 @@ function startShutdownTimeout(app: App) {
|
||||||
}, SHUTDOWN_TIMEOUT)
|
}, SHUTDOWN_TIMEOUT)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function watchAppsDir() {
|
||||||
|
watch(APPS_DIR, { recursive: true }, (_event, filename) => {
|
||||||
|
if (!filename) return
|
||||||
|
|
||||||
|
const parts = filename.split('/')
|
||||||
|
const dir = parts[0]!
|
||||||
|
|
||||||
|
// Ignore changes inside old timestamp dirs (but allow current/)
|
||||||
|
if (parts.length > 2 && parts[1] !== 'current') return
|
||||||
|
|
||||||
|
// For versioned apps, only care about changes to "current" directory
|
||||||
|
if (parts.length === 2 && parts[1] !== 'current' && parts[1] !== 'package.json') return
|
||||||
|
|
||||||
|
// Handle new directory appearing
|
||||||
|
if (!_apps.has(dir)) {
|
||||||
|
// Make sure the directory actually exists (avoids race with rename)
|
||||||
|
if (!isDir(join(APPS_DIR, dir))) return
|
||||||
|
|
||||||
|
const { pkg, error } = loadApp(dir)
|
||||||
|
const state: AppState = error ? 'invalid' : 'stopped'
|
||||||
|
const icon = pkg.toes?.icon
|
||||||
|
const tool = pkg.toes?.tool
|
||||||
|
_apps.set(dir, { name: dir, state, icon, error, tool })
|
||||||
|
update()
|
||||||
|
if (!error) {
|
||||||
|
runApp(dir, getPort(dir))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = _apps.get(dir)!
|
||||||
|
|
||||||
|
// check if app was deleted
|
||||||
|
if (!isDir(join(APPS_DIR, dir))) {
|
||||||
|
clearTimers(app)
|
||||||
|
if (app.port) releasePort(app.port)
|
||||||
|
_apps.delete(dir)
|
||||||
|
update()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only care about package.json changes for existing apps
|
||||||
|
if (!filename.endsWith('package.json')) return
|
||||||
|
|
||||||
|
const { pkg, error } = loadApp(dir)
|
||||||
|
|
||||||
|
// Update icon, tool, and error from package.json
|
||||||
|
const iconChanged = app.icon !== pkg.toes?.icon
|
||||||
|
const toolChanged = app.tool !== pkg.toes?.tool
|
||||||
|
app.icon = pkg.toes?.icon
|
||||||
|
app.tool = pkg.toes?.tool
|
||||||
|
app.error = error
|
||||||
|
|
||||||
|
// Broadcast if icon or tool changed
|
||||||
|
if (iconChanged || toolChanged) update()
|
||||||
|
|
||||||
|
// App became valid - start it if stopped
|
||||||
|
if (!error && app.state === 'invalid') {
|
||||||
|
app.state = 'stopped'
|
||||||
|
runApp(dir, getPort(dir))
|
||||||
|
}
|
||||||
|
|
||||||
|
// App became invalid - stop it if running
|
||||||
|
if (error && app.state === 'running') {
|
||||||
|
app.state = 'invalid'
|
||||||
|
clearTimers(app)
|
||||||
|
app.proc?.kill()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update state if already stopped/invalid
|
||||||
|
if (error && app.state === 'stopped') {
|
||||||
|
app.state = 'invalid'
|
||||||
|
update()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!error && app.state === 'invalid') {
|
||||||
|
app.state = 'stopped'
|
||||||
|
update()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import type { App } from '$apps'
|
import type { App } from '$apps'
|
||||||
import { TOES_URL } from '$apps'
|
|
||||||
|
|
||||||
const RENDER_DEBOUNCE = 50
|
const RENDER_DEBOUNCE = 50
|
||||||
|
|
||||||
|
|
@ -81,7 +80,7 @@ function render() {
|
||||||
lines.push('\x1b[2J\x1b[H')
|
lines.push('\x1b[2J\x1b[H')
|
||||||
|
|
||||||
// Header
|
// Header
|
||||||
lines.push(`\x1b[1m🐾 Toes\x1b[0m \x1b[90m${TOES_URL}\x1b[0m`)
|
lines.push('\x1b[1m🐾 Toes\x1b[0m')
|
||||||
lines.push('')
|
lines.push('')
|
||||||
|
|
||||||
// Apps section
|
// Apps section
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user