toes/src/cli/commands/logs.ts
2026-02-01 22:15:25 -08:00

153 lines
3.7 KiB
TypeScript

import type { LogLine } from '@types'
import { get, handleError, makeUrl } from '../http'
import { resolveAppName } from '../name'
interface LogOptions {
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}`)
}
export async function logApp(arg: string | undefined, options: LogOptions) {
const name = resolveAppName(arg)
if (!name) return
if (options.follow) {
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}`)
return
}
if (logs.length === 0) {
console.log('No logs yet')
return
}
for (const line of logs) {
printLog(line, options.grep)
}
}
export async function tailLogs(name: string, grep?: string) {
try {
const url = makeUrl(`/api/apps/${name}/logs/stream`)
const res = await fetch(url)
if (!res.ok) {
console.error(`App not found: ${name}`)
return
}
if (!res.body) return
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n\n')
buffer = lines.pop() ?? ''
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = JSON.parse(line.slice(6)) as LogLine
printLog(data, grep)
}
}
}
} catch (error) {
handleError(error)
}
}