toes/src/client/components/LogsSection.tsx
2026-02-01 22:15:25 -08:00

245 lines
6.4 KiB
TypeScript

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>
)
}