feat: add LogViewer composite for timestamped, severity-colored log display

Scrollable log viewer with auto-scroll behavior, level badges (info/warn/
error/debug) with semantic colors, monospace font, and role="log" for
accessibility. Includes 7 tests and barrel exports.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-24 12:27:03 +01:00
parent 4abf80144e
commit 8c1c953259
4 changed files with 210 additions and 0 deletions

View File

@@ -0,0 +1,75 @@
.container {
overflow-y: auto;
background: var(--bg-inset);
border-radius: var(--radius-md);
padding: 8px 0;
font-family: var(--font-mono);
}
.line {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 3px 12px;
line-height: 1.5;
}
.line:hover {
background: var(--bg-hover);
}
.timestamp {
flex-shrink: 0;
font-size: 11px;
color: var(--text-muted);
min-width: 56px;
}
.levelBadge {
flex-shrink: 0;
font-size: 9px;
font-weight: 600;
font-family: var(--font-mono);
text-transform: uppercase;
letter-spacing: 0.3px;
padding: 1px 6px;
border-radius: 9999px;
line-height: 1.5;
white-space: nowrap;
}
.levelInfo {
color: var(--running);
background: color-mix(in srgb, var(--running) 12%, transparent);
}
.levelWarn {
color: var(--warning);
background: color-mix(in srgb, var(--warning) 12%, transparent);
}
.levelError {
color: var(--error);
background: color-mix(in srgb, var(--error) 12%, transparent);
}
.levelDebug {
color: var(--text-muted);
background: color-mix(in srgb, var(--text-muted) 10%, transparent);
}
.message {
font-size: 12px;
font-family: var(--font-mono);
color: var(--text-primary);
word-break: break-word;
line-height: 1.5;
}
.empty {
padding: 24px;
text-align: center;
color: var(--text-faint);
font-size: 12px;
font-family: var(--font-body);
}

View File

@@ -0,0 +1,56 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { LogViewer, type LogEntry } from './LogViewer'
const entries: LogEntry[] = [
{ timestamp: '2024-01-15T10:30:00Z', level: 'info', message: 'Server started' },
{ timestamp: '2024-01-15T10:30:05Z', level: 'warn', message: 'High memory usage' },
{ timestamp: '2024-01-15T10:30:10Z', level: 'error', message: 'Connection failed' },
{ timestamp: '2024-01-15T10:30:15Z', level: 'debug', message: 'Query executed in 3ms' },
]
describe('LogViewer', () => {
it('renders entries with timestamps and messages', () => {
render(<LogViewer entries={entries} />)
expect(screen.getByText('Server started')).toBeInTheDocument()
expect(screen.getByText('High memory usage')).toBeInTheDocument()
expect(screen.getByText('Connection failed')).toBeInTheDocument()
expect(screen.getByText('Query executed in 3ms')).toBeInTheDocument()
})
it('renders level badges with correct text (INFO, WARN, ERROR, DEBUG)', () => {
render(<LogViewer entries={entries} />)
expect(screen.getByText('INFO')).toBeInTheDocument()
expect(screen.getByText('WARN')).toBeInTheDocument()
expect(screen.getByText('ERROR')).toBeInTheDocument()
expect(screen.getByText('DEBUG')).toBeInTheDocument()
})
it('renders with custom maxHeight (number)', () => {
const { container } = render(<LogViewer entries={entries} maxHeight={300} />)
const el = container.firstElementChild as HTMLElement
expect(el.style.maxHeight).toBe('300px')
})
it('renders with string maxHeight', () => {
const { container } = render(<LogViewer entries={entries} maxHeight="50vh" />)
const el = container.firstElementChild as HTMLElement
expect(el.style.maxHeight).toBe('50vh')
})
it('handles empty entries', () => {
render(<LogViewer entries={[]} />)
expect(screen.getByText('No log entries.')).toBeInTheDocument()
})
it('accepts className prop', () => {
const { container } = render(<LogViewer entries={entries} className="custom-class" />)
const el = container.firstElementChild as HTMLElement
expect(el.classList.contains('custom-class')).toBe(true)
})
it('has role="log" for accessibility', () => {
render(<LogViewer entries={entries} />)
expect(screen.getByRole('log')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,77 @@
import { useRef, useEffect, useCallback } from 'react'
import styles from './LogViewer.module.css'
export interface LogEntry {
timestamp: string
level: 'info' | 'warn' | 'error' | 'debug'
message: string
}
export interface LogViewerProps {
entries: LogEntry[]
maxHeight?: number | string
className?: string
}
const LEVEL_CLASS: Record<LogEntry['level'], string> = {
info: styles.levelInfo,
warn: styles.levelWarn,
error: styles.levelError,
debug: styles.levelDebug,
}
function formatTime(iso: string): string {
try {
return new Date(iso).toLocaleTimeString('en-GB', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
})
} catch {
return iso
}
}
export function LogViewer({ entries, maxHeight = 400, className }: LogViewerProps) {
const scrollRef = useRef<HTMLDivElement>(null)
const isAtBottomRef = useRef(true)
const handleScroll = useCallback(() => {
const el = scrollRef.current
if (!el) return
isAtBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 20
}, [])
useEffect(() => {
const el = scrollRef.current
if (el && isAtBottomRef.current) {
el.scrollTop = el.scrollHeight
}
}, [entries])
const heightStyle = typeof maxHeight === 'number' ? `${maxHeight}px` : maxHeight
return (
<div
ref={scrollRef}
className={[styles.container, className].filter(Boolean).join(' ')}
style={{ maxHeight: heightStyle }}
onScroll={handleScroll}
role="log"
>
{entries.map((entry, i) => (
<div key={i} className={styles.line}>
<span className={styles.timestamp}>{formatTime(entry.timestamp)}</span>
<span className={[styles.levelBadge, LEVEL_CLASS[entry.level]].join(' ')}>
{entry.level.toUpperCase()}
</span>
<span className={styles.message}>{entry.message}</span>
</div>
))}
{entries.length === 0 && (
<div className={styles.empty}>No log entries.</div>
)}
</div>
)
}

View File

@@ -20,6 +20,8 @@ export type { KpiItem, KpiStripProps } from './KpiStrip/KpiStrip'
export type { FeedEvent } from './EventFeed/EventFeed'
export { FilterBar } from './FilterBar/FilterBar'
export { LineChart } from './LineChart/LineChart'
export { LogViewer } from './LogViewer/LogViewer'
export type { LogEntry, LogViewerProps } from './LogViewer/LogViewer'
export { LoginDialog } from './LoginForm/LoginDialog'
export type { LoginDialogProps } from './LoginForm/LoginDialog'
export { LoginForm } from './LoginForm/LoginForm'