/** * Logs Command * * View database logs from postgres.do * * @module cli/commands/logs */ import { requireToken } from '../cli-auth.js' import { getApiUrl } from '../config.js' import { printResult, printInfo, type OutputOptions } from '../output.js' /** * Logs command options */ export interface LogsOptions extends OutputOptions { /** Database name or ID */ name: string /** Tail logs (follow mode) */ tail?: boolean | undefined /** Number of lines to show */ lines?: number | undefined /** Show logs since timestamp or duration (e.g., "1h", "30m", "2024-01-15T10:00:00Z") */ since?: string | undefined /** Show logs until timestamp */ until?: string | undefined /** Filter by log level */ level?: 'debug' | 'info' | 'warning' | 'error' | undefined /** Filter by search query */ query?: string | undefined /** Show timestamps */ timestamps?: boolean | undefined /** API URL override */ apiUrl?: string | undefined } /** * Log entry structure */ export interface LogEntry { timestamp: string level: 'debug' | 'info' | 'warning' | 'error' message: string source?: string metadata?: Record } /** * Run logs command */ export async function runLogs(options: LogsOptions): Promise { const { name, tail = false, lines = 100, since, until, level, query, timestamps = true, } = options if (!name) { printResult({ success: false, error: 'Database name is required', }, options) process.exit(1) } try { const token = requireToken(options.apiUrl) const apiUrl = getApiUrl(options.apiUrl) if (tail) { await runTailLogs(name, token, apiUrl, options) } else { const fetchOptions: LogsOptions & { lines: number; timestamps: boolean } = { ...options, name, lines, timestamps, } if (since) fetchOptions.since = since if (until) fetchOptions.until = until if (level) fetchOptions.level = level if (query) fetchOptions.query = query await runFetchLogs(name, token, apiUrl, fetchOptions) } } catch (error) { printResult({ success: false, error: error instanceof Error ? error.message : 'Unknown error fetching logs', }, options) process.exit(1) } } /** * Fetch historical logs */ async function runFetchLogs( name: string, token: string, apiUrl: string, options: LogsOptions & { lines: number; timestamps: boolean } ): Promise { const { lines, since, until, level, query, timestamps } = options printInfo(`Fetching logs for '${name}'...`, options) // Build query parameters const params = new URLSearchParams() params.set('limit', String(lines)) if (since) params.set('since', parseDuration(since)) if (until) params.set('until', until) if (level) params.set('level', level) if (query) params.set('query', query) const response = await fetch(`${apiUrl}/v1/databases/${name}/logs?${params}`, { headers: { 'Authorization': `Bearer ${token}`, }, }) if (!response.ok) { const error = await response.json().catch(() => ({ message: 'Failed to fetch logs' })) printResult({ success: false, error: (error as { message?: string }).message || 'Failed to fetch logs', }, options) process.exit(1) } const data = await response.json() as { logs: LogEntry[]; hasMore?: boolean } if (options.format === 'json') { printResult({ success: true, data: data.logs, }, options) return } // Print logs in human-readable format if (data.logs.length === 0) { printInfo('No logs found', options) return } for (const log of data.logs) { printLogEntry(log, { ...options, timestamps }) } if (data.hasMore) { printInfo(`\nShowing ${data.logs.length} logs. Use --lines to see more.`, options) } } /** * Tail logs in real-time */ async function runTailLogs( name: string, token: string, apiUrl: string, options: LogsOptions ): Promise { const { since, level, query, timestamps = true } = options printInfo(`Tailing logs for '${name}' (Ctrl+C to stop)...`, options) console.log('') // Build query parameters for SSE endpoint const params = new URLSearchParams() if (since) params.set('since', parseDuration(since)) if (level) params.set('level', level) if (query) params.set('query', query) const response = await fetch(`${apiUrl}/v1/databases/${name}/logs/stream?${params}`, { headers: { 'Authorization': `Bearer ${token}`, 'Accept': 'text/event-stream', }, }) if (!response.ok) { const error = await response.json().catch(() => ({ message: 'Failed to connect to log stream' })) printResult({ success: false, error: (error as { message?: string }).message || 'Failed to connect to log stream', }, options) process.exit(1) } if (!response.body) { printResult({ success: false, error: 'No response body received', }, options) process.exit(1) } // Handle graceful shutdown const controller = new AbortController() process.on('SIGINT', () => { console.log('\nStopping log tail...') controller.abort() }) // Read SSE stream const reader = response.body.getReader() const decoder = new TextDecoder() let buffer = '' try { while (true) { const { done, value } = await reader.read() if (done) break buffer += decoder.decode(value, { stream: true }) // Process complete SSE events const events = buffer.split('\n\n') buffer = events.pop() || '' // Keep incomplete event in buffer for (const event of events) { const lines = event.split('\n') let data = '' for (const line of lines) { if (line.startsWith('data: ')) { data = line.slice(6) } } if (data) { try { const log = JSON.parse(data) as LogEntry printLogEntry(log, { ...options, timestamps: timestamps ?? true }) } catch { // Skip invalid JSON } } } } } catch (error) { if ((error as Error).name === 'AbortError') { // Normal shutdown return } throw error } } /** * Print a single log entry */ function printLogEntry(log: LogEntry, options: { timestamps: boolean } & OutputOptions): void { const levelColors: Record = { debug: '\x1b[90m', // gray info: '\x1b[36m', // cyan warning: '\x1b[33m', // yellow error: '\x1b[31m', // red } const reset = '\x1b[0m' const supportsColor = process.stdout.isTTY && !process.env['NO_COLOR'] && !options.noColor let output = '' // Timestamp if (options.timestamps) { const timestamp = new Date(log.timestamp).toISOString() output += supportsColor ? `\x1b[90m${timestamp}\x1b[0m ` : `${timestamp} ` } // Level const levelStr = log.level.toUpperCase().padEnd(7) if (supportsColor) { output += `${levelColors[log.level] || ''}${levelStr}${reset} ` } else { output += `${levelStr} ` } // Source if (log.source) { output += supportsColor ? `\x1b[35m[${log.source}]\x1b[0m ` : `[${log.source}] ` } // Message output += log.message // Metadata (if verbose) if (options.verbose && log.metadata && Object.keys(log.metadata).length > 0) { output += supportsColor ? `\n \x1b[90m${JSON.stringify(log.metadata)}\x1b[0m` : `\n ${JSON.stringify(log.metadata)}` } console.log(output) } /** * Parse duration string to ISO timestamp * * Supports formats like: * - "1h" (1 hour ago) * - "30m" (30 minutes ago) * - "2d" (2 days ago) * - ISO timestamp (passed through) */ function parseDuration(input: string): string { // Check if it's already an ISO timestamp if (input.includes('T') || input.includes('-')) { return input } const match = input.match(/^(\d+)([smhd])$/i) if (!match) { return input } const value = parseInt(match[1]!, 10) const unit = match[2]!.toLowerCase() const multipliers: Record = { s: 1000, m: 60 * 1000, h: 60 * 60 * 1000, d: 24 * 60 * 60 * 1000, } const ms = value * (multipliers[unit] || 0) const since = new Date(Date.now() - ms) return since.toISOString() } /** * Get log statistics */ export async function runLogStats( name: string, options: OutputOptions & { apiUrl?: string; since?: string; until?: string } ): Promise { try { const token = requireToken(options.apiUrl) const apiUrl = getApiUrl(options.apiUrl) printInfo(`Fetching log statistics for '${name}'...`, options) const params = new URLSearchParams() if (options.since) params.set('since', parseDuration(options.since)) if (options.until) params.set('until', options.until) const response = await fetch(`${apiUrl}/v1/databases/${name}/logs/stats?${params}`, { headers: { 'Authorization': `Bearer ${token}`, }, }) if (!response.ok) { const error = await response.json().catch(() => ({ message: 'Failed to fetch log stats' })) printResult({ success: false, error: (error as { message?: string }).message || 'Failed to fetch log stats', }, options) process.exit(1) } const stats = await response.json() printResult({ success: true, data: stats, }, options) } catch (error) { printResult({ success: false, error: error instanceof Error ? error.message : 'Unknown error fetching log stats', }, options) process.exit(1) } }