toes/src/client/components/UnifiedLogs.tsx
2026-02-13 09:02:21 -08:00

116 lines
2.9 KiB
TypeScript

import {
LogApp,
LogEntry,
LogsBody,
LogsHeader,
LogsSection,
LogsTitle,
LogText,
LogTimestamp,
} from '../styles'
import { update } from '../update'
export interface UnifiedLogLine {
time: number
app: string
text: string
}
const MAX_LOGS = 200
let _logs: UnifiedLogLine[] = []
let _source: EventSource | undefined
const formatTime = (timestamp: number): string => {
const d = new Date(timestamp)
const pad = (n: number) => String(n).padStart(2, '0')
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
}
const getStatusColor = (status?: number): string | undefined => {
if (!status) return undefined
if (status >= 200 && status < 300) return '#22c55e'
if (status >= 400 && status < 500) return '#f59e0b'
if (status >= 500) return '#ef4444'
return undefined
}
function parseLogText(text: string): { method?: string, path?: string, status?: number, rest: string } {
// Match patterns like "GET /api/time 200" or "200 GET http://... (0ms)"
const httpMatch = text.match(/^(\d{3})\s+(GET|POST|PUT|DELETE|PATCH)\s+\S+/)
|| text.match(/^(GET|POST|PUT|DELETE|PATCH)\s+(\S+)\s+(\d{3})/)
if (httpMatch) {
if (httpMatch[1]?.match(/^\d{3}$/)) {
return { method: httpMatch[2], status: parseInt(httpMatch[1], 10), rest: text }
} else {
return { method: httpMatch[1], path: httpMatch[2], status: parseInt(httpMatch[3]!, 10), rest: text }
}
}
return { rest: text }
}
function LogLineEntry({ log }: { log: UnifiedLogLine }) {
const parsed = parseLogText(log.text)
const statusColor = getStatusColor(parsed.status)
return (
<LogEntry>
<LogTimestamp>{formatTime(log.time)}</LogTimestamp>
<LogApp>{log.app}</LogApp>
<LogText style={statusColor ? { color: statusColor } : undefined}>
{log.text}
</LogText>
</LogEntry>
)
}
function LogsBodyContent() {
return (
<>
{_logs.length === 0 ? (
<LogEntry>
<LogText style={{ color: 'var(--colors-textFaint)' }}>No activity yet</LogText>
</LogEntry>
) : (
_logs.map((log, i) => <LogLineEntry key={i} log={log} />)
)}
</>
)
}
function renderLogs() {
update('#unified-logs-body', <LogsBodyContent />)
// Auto-scroll after render
requestAnimationFrame(() => {
const el = document.getElementById('unified-logs-body')
if (el) el.scrollTop = el.scrollHeight
})
}
export function initUnifiedLogs() {
if (_source) return
_source = new EventSource('/api/system/logs/stream')
_source.onmessage = e => {
try {
const line = JSON.parse(e.data) as UnifiedLogLine
_logs = [..._logs.slice(-(MAX_LOGS - 1)), line]
renderLogs()
} catch {}
}
}
export function UnifiedLogs() {
return (
<LogsSection>
<LogsHeader>
<LogsTitle>Logs</LogsTitle>
</LogsHeader>
<LogsBody id="unified-logs-body">
<LogsBodyContent />
</LogsBody>
</LogsSection>
)
}