Building a Log Aggregation Viewer in React
How to build a log aggregation viewer with filtering, search, real-time log tailing, and structured log parsing — with patterns from Klivvr's Web Ops Console.
Logs are the primary debugging tool during incidents. When a service is misbehaving, the first thing an engineer does is check the logs. But raw logs from multiple services, environments, and instances create a firehose of text that is impossible to parse without proper tooling. A log aggregation viewer transforms raw log data into a searchable, filterable, and real-time debugging interface.
This article covers the log viewer implementation in Klivvr's Web Ops Console.
Log Data Model
Structured logs provide consistent fields that enable filtering and search. Every log entry in Klivvr's systems follows a standard schema.
interface LogEntry {
id: string;
timestamp: string;
level: "debug" | "info" | "warn" | "error" | "fatal";
service: string;
instance: string;
message: string;
traceId?: string;
spanId?: string;
metadata: Record<string, unknown>;
}
interface LogQuery {
services?: string[];
levels?: LogEntry["level"][];
search?: string;
traceId?: string;
startTime?: string;
endTime?: string;
limit: number;
offset: number;
}
interface LogResponse {
entries: LogEntry[];
total: number;
hasMore: boolean;
}Log Viewer Component
The log viewer combines search, filters, and a virtualized log list for performance.
import { useState, useCallback, useRef, useEffect } from "react";
function LogViewer() {
const [query, setQuery] = useState<LogQuery>({
limit: 100,
offset: 0,
});
const [logs, setLogs] = useState<LogEntry[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isTailing, setIsTailing] = useState(false);
const fetchLogs = useCallback(async (q: LogQuery) => {
setIsLoading(true);
try {
const params = new URLSearchParams();
if (q.services?.length) params.set("services", q.services.join(","));
if (q.levels?.length) params.set("levels", q.levels.join(","));
if (q.search) params.set("search", q.search);
if (q.traceId) params.set("traceId", q.traceId);
if (q.startTime) params.set("startTime", q.startTime);
if (q.endTime) params.set("endTime", q.endTime);
params.set("limit", String(q.limit));
params.set("offset", String(q.offset));
const response = await fetch(`/api/logs?${params}`);
const data: LogResponse = await response.json();
setLogs((prev) => (q.offset === 0 ? data.entries : [...prev, ...data.entries]));
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
fetchLogs(query);
}, [query, fetchLogs]);
return (
<div className="flex h-full flex-col">
<LogFilters
query={query}
onChange={(updates) => setQuery({ ...query, ...updates, offset: 0 })}
/>
<LogToolbar
isTailing={isTailing}
onToggleTail={() => setIsTailing(!isTailing)}
onClear={() => setLogs([])}
resultCount={logs.length}
/>
<LogList
entries={logs}
isLoading={isLoading}
isTailing={isTailing}
onLoadMore={() =>
setQuery((q) => ({ ...q, offset: q.offset + q.limit }))
}
/>
</div>
);
}Filter Panel
The filter panel provides controls for narrowing down log output by service, level, and time range.
function LogFilters({ query, onChange }: {
query: LogQuery;
onChange: (updates: Partial<LogQuery>) => void;
}) {
const [searchInput, setSearchInput] = useState(query.search ?? "");
const handleSearch = () => {
onChange({ search: searchInput || undefined });
};
return (
<div className="flex flex-wrap items-center gap-3 border-b bg-gray-50 px-4 py-3">
<div className="flex-1">
<input
type="text"
placeholder="Search logs... (supports regex)"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
className="w-full rounded border px-3 py-1.5 text-sm font-mono"
/>
</div>
<ServiceSelector
selected={query.services ?? []}
onChange={(services) => onChange({ services })}
/>
<LevelSelector
selected={query.levels ?? []}
onChange={(levels) => onChange({ levels })}
/>
<TimeRangeSelector
startTime={query.startTime}
endTime={query.endTime}
onChange={(start, end) => onChange({ startTime: start, endTime: end })}
/>
<button onClick={handleSearch} className="btn-primary text-sm">
Search
</button>
</div>
);
}
function LevelSelector({ selected, onChange }: {
selected: LogEntry["level"][];
onChange: (levels: LogEntry["level"][]) => void;
}) {
const levels: Array<{ value: LogEntry["level"]; color: string }> = [
{ value: "debug", color: "bg-gray-100 text-gray-600" },
{ value: "info", color: "bg-blue-100 text-blue-700" },
{ value: "warn", color: "bg-yellow-100 text-yellow-700" },
{ value: "error", color: "bg-red-100 text-red-700" },
{ value: "fatal", color: "bg-red-200 text-red-900" },
];
const toggle = (level: LogEntry["level"]) => {
if (selected.includes(level)) {
onChange(selected.filter((l) => l !== level));
} else {
onChange([...selected, level]);
}
};
return (
<div className="flex gap-1">
{levels.map(({ value, color }) => (
<button
key={value}
onClick={() => toggle(value)}
className={`rounded px-2 py-1 text-xs font-medium ${
selected.includes(value) ? color : "bg-gray-50 text-gray-400"
}`}
>
{value.toUpperCase()}
</button>
))}
</div>
);
}Log Entry Rendering
Each log entry is rendered with syntax highlighting, expandable metadata, and clickable trace IDs for distributed tracing.
function LogEntryRow({ entry }: { entry: LogEntry }) {
const [expanded, setExpanded] = useState(false);
const levelColors: Record<string, string> = {
debug: "text-gray-400",
info: "text-blue-500",
warn: "text-yellow-500",
error: "text-red-500",
fatal: "text-red-700 font-bold",
};
return (
<div
className={`border-b px-4 py-1.5 font-mono text-xs hover:bg-gray-50 cursor-pointer ${
entry.level === "error" || entry.level === "fatal" ? "bg-red-50/30" : ""
}`}
onClick={() => setExpanded(!expanded)}
>
<div className="flex gap-3">
<span className="text-gray-400 shrink-0">
{new Date(entry.timestamp).toISOString().slice(11, 23)}
</span>
<span className={`w-12 shrink-0 uppercase ${levelColors[entry.level]}`}>
{entry.level}
</span>
<span className="text-purple-600 shrink-0 w-28 truncate">
{entry.service}
</span>
<span className="text-gray-800 flex-1 truncate">{entry.message}</span>
{entry.traceId && (
<a
href={`/traces/${entry.traceId}`}
onClick={(e) => e.stopPropagation()}
className="text-blue-500 hover:underline shrink-0"
>
{entry.traceId.slice(0, 8)}
</a>
)}
</div>
{expanded && Object.keys(entry.metadata).length > 0 && (
<div className="mt-2 ml-20 rounded bg-gray-100 p-2 text-gray-700">
<pre>{JSON.stringify(entry.metadata, null, 2)}</pre>
</div>
)}
</div>
);
}Real-Time Log Tailing
Log tailing streams new entries as they arrive, keeping the view at the bottom of the log — the way tail -f works in a terminal.
function useLogTailing(
query: LogQuery,
enabled: boolean,
onNewEntries: (entries: LogEntry[]) => void
) {
const { subscribe } = useWebSocket();
useEffect(() => {
if (!enabled) return;
return subscribe("logs.stream", (data: unknown) => {
const entry = data as LogEntry;
// Apply client-side filters
if (query.services?.length && !query.services.includes(entry.service)) return;
if (query.levels?.length && !query.levels.includes(entry.level)) return;
if (query.search && !entry.message.includes(query.search)) return;
onNewEntries([entry]);
});
}, [enabled, query, subscribe, onNewEntries]);
}Conclusion
A log aggregation viewer is one of the most critical tools in an operations dashboard. It is the first place engineers look during incidents and the primary interface for understanding system behavior. The Web Ops Console's log viewer combines structured log parsing, multi-dimensional filtering, real-time tailing, and expandable metadata into an interface that makes debugging fast and intuitive. When an incident occurs, the operations team can filter to the relevant service, search for error patterns, and trace individual requests across services — all from a single view.
Related Articles
Build vs Buy for Internal Operations Tools
A framework for deciding whether to build or buy internal operations tools, covering total cost of ownership, customization needs, and the strategic value of purpose-built tooling.
Improving Incident Response with Ops Dashboards
How operations dashboards improve incident response efficiency through faster detection, structured workflows, MTTR reduction, and postmortem processes.
Data Visualization Patterns for Ops Dashboards
How to choose and implement effective data visualizations for operations dashboards, covering chart selection, color systems, responsive layouts, and accessibility.