toes/src/client/components/UnifiedLogs.tsx

149 lines
3.9 KiB
TypeScript

import { isNarrow } from '../state'
import {
LogApp,
LogEntry,
LogsBody,
LogsHeader,
LogsSection,
LogsTab,
LogsTabs,
LogsTitle,
LogText,
LogTimestamp,
} from '../styles'
import { update } from '../update'
export interface UnifiedLogLine {
time: number
app: string
text: string
}
type LogFilter = 'all' | 'toes'
const MAX_LOGS = 200
let _filter: LogFilter = 'all'
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)
const narrow = isNarrow || undefined
return (
<LogEntry narrow={narrow}>
<LogTimestamp narrow={narrow}>{formatTime(log.time)}</LogTimestamp>
<LogApp narrow={narrow}>{log.app}</LogApp>
<LogText style={statusColor ? { color: statusColor } : undefined}>
{log.text}
</LogText>
</LogEntry>
)
}
const filteredLogs = (): UnifiedLogLine[] =>
_filter === 'toes' ? _logs.filter(l => l.app === 'toes') : _logs
function setFilter(filter: LogFilter) {
_filter = filter
renderLogs()
}
function LogsBodyContent() {
const logs = filteredLogs()
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-tabs', <LogsTabsBar />)
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 scrollLogsToBottom() {
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 {}
}
}
function LogsTabsBar() {
return (
<LogsTabs>
<LogsTab variant={_filter === 'all' ? 'active' : undefined} onClick={() => setFilter('all')}>All</LogsTab>
<LogsTab variant={_filter === 'toes' ? 'active' : undefined} onClick={() => setFilter('toes')}>Toes</LogsTab>
</LogsTabs>
)
}
export function UnifiedLogs() {
return (
<LogsSection>
<LogsHeader>
<LogsTitle>Logs</LogsTitle>
<div id="unified-logs-tabs"><LogsTabsBar /></div>
</LogsHeader>
<LogsBody id="unified-logs-body">
<LogsBodyContent />
</LogsBody>
</LogsSection>
)
}