UI Components Medium
Log Viewer
Scrollable log output panel with level filters (info/warn/error/debug), search, auto-scroll toggle, and color-coded lines.
Open in Lab
MCP
vanilla-js css react tailwind svelte vue
Targets: TS JS HTML React Svelte Vue
Code
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #0d1117;
min-height: 100vh;
padding: 32px 16px;
display: flex;
justify-content: center;
}
.demo {
width: 100%;
max-width: 900px;
display: flex;
flex-direction: column;
}
.lv-toolbar {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: #21262d;
border: 1px solid #30363d;
border-radius: 10px 10px 0 0;
border-bottom: none;
flex-wrap: wrap;
}
.lv-title {
font-size: 13px;
font-weight: 700;
color: #e6edf3;
flex-shrink: 0;
}
.lv-controls {
display: flex;
gap: 8px;
align-items: center;
flex: 1;
flex-wrap: wrap;
justify-content: flex-end;
}
.lv-search {
background: #161b22;
border: 1px solid #30363d;
border-radius: 6px;
color: #e6edf3;
font-size: 12px;
padding: 5px 10px;
outline: none;
width: 140px;
}
.lv-search:focus {
border-color: #6366f1;
}
.level-filters {
display: flex;
gap: 4px;
}
.lvl-btn {
background: #30363d;
border: none;
color: #8b949e;
font-size: 11px;
font-weight: 700;
padding: 4px 10px;
border-radius: 6px;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.lvl-btn.active {
background: #484f58;
color: #e6edf3;
}
.lvl-btn.lvl-info.active {
background: rgba(56, 139, 253, 0.15);
color: #58a6ff;
}
.lvl-btn.lvl-warn.active {
background: rgba(210, 153, 34, 0.15);
color: #d29922;
}
.lvl-btn.lvl-error.active {
background: rgba(248, 81, 73, 0.15);
color: #f85149;
}
.lvl-btn.lvl-debug.active {
background: rgba(163, 113, 247, 0.15);
color: #a371f7;
}
.auto-scroll-label {
display: flex;
align-items: center;
gap: 5px;
font-size: 12px;
color: #8b949e;
cursor: pointer;
}
.lv-clear-btn {
background: none;
border: 1px solid #30363d;
color: #8b949e;
font-size: 12px;
padding: 4px 10px;
border-radius: 6px;
cursor: pointer;
}
.lv-clear-btn:hover {
color: #e6edf3;
border-color: #8b949e;
}
/* Log body */
.lv-body {
flex: 1;
height: 400px;
overflow-y: auto;
background: #161b22;
border: 1px solid #30363d;
font-family: "JetBrains Mono", Menlo, monospace;
font-size: 12.5px;
line-height: 1.6;
padding: 8px 0;
}
.log-line {
display: flex;
gap: 10px;
padding: 2px 16px;
align-items: baseline;
}
.log-line:hover {
background: rgba(255, 255, 255, 0.03);
}
.log-line.hidden {
display: none;
}
.log-ts {
color: #4a555f;
flex-shrink: 0;
}
.log-lvl {
width: 48px;
text-align: center;
font-weight: 700;
font-size: 11px;
border-radius: 3px;
padding: 0 2px;
flex-shrink: 0;
}
.log-msg {
color: #e6edf3;
flex: 1;
}
.lvl-INFO {
color: #58a6ff;
}
.lvl-WARN {
color: #d29922;
}
.lvl-ERROR {
color: #f85149;
background: rgba(248, 81, 73, 0.08);
}
.lvl-DEBUG {
color: #a371f7;
}
.log-line.lvl-ERROR-line {
background: rgba(248, 81, 73, 0.04);
}
/* Footer */
.lv-footer {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 16px;
background: #21262d;
border: 1px solid #30363d;
border-radius: 0 0 10px 10px;
border-top: none;
}
#logCount {
font-size: 12px;
color: #8b949e;
flex: 1;
}
.sim-btn {
padding: 6px 14px;
background: #6366f1;
color: #fff;
border: none;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.sim-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.sim-btn--stop {
background: #30363d;
color: #8b949e;
}
.sim-btn--stop:not(:disabled) {
color: #f85149;
}const body = document.getElementById("lvBody");
const logCountEl = document.getElementById("logCount");
const autoScrollChk = document.getElementById("autoScroll");
const searchInput = document.getElementById("lvSearch");
const simBtn = document.getElementById("simBtn");
const stopBtn = document.getElementById("stopBtn");
let activeLevel = "all";
let searchTerm = "";
let logLines = [];
let simInterval = null;
let lineCount = 0;
const SAMPLE_LOGS = [
["INFO", "Server started on port 3000"],
["INFO", "Database connection established"],
["DEBUG", "Cache initialized with 512MB limit"],
["INFO", "User auth-service ready"],
["INFO", "GET /api/users 200 12ms"],
["DEBUG", "Session token validated for user #42"],
["INFO", "POST /api/orders 201 34ms"],
["WARN", "Rate limit threshold at 80% for IP 192.168.1.5"],
["INFO", "GET /api/products 200 8ms"],
["DEBUG", "Cache hit for key: products:all"],
["ERROR", "Failed to connect to payment gateway: timeout after 30s"],
["INFO", "Retry attempt 1/3 for payment gateway"],
["WARN", "Memory usage at 78% (6.2GB / 8GB)"],
["INFO", "PUT /api/users/42 200 22ms"],
["ERROR", "Unhandled exception in worker thread: RangeError: Maximum call stack size exceeded"],
["INFO", "Worker thread restarted"],
["INFO", "DELETE /api/sessions/old 200 5ms"],
["DEBUG", "GC cycle completed, freed 124MB"],
["WARN", "Slow query detected: SELECT * FROM orders took 2300ms"],
["INFO", 'Scheduled job "email-digest" completed in 1.2s'],
];
function pad(n) {
return String(n).padStart(2, "0");
}
function getTimestamp() {
const d = new Date();
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${String(d.getMilliseconds()).padStart(3, "0")}`;
}
function addLog(level, msg) {
lineCount++;
const entry = { id: lineCount, level, msg, ts: getTimestamp() };
logLines.push(entry);
const line = document.createElement("div");
line.className = `log-line${level === "ERROR" ? " lvl-ERROR-line" : ""}`;
line.dataset.level = level;
line.dataset.msg = msg.toLowerCase();
line.innerHTML = `
<span class="log-ts">${entry.ts}</span>
<span class="log-lvl lvl-${level}">${level}</span>
<span class="log-msg">${escHtml(msg)}</span>
`;
applyVisibility(line);
body.appendChild(line);
updateCount();
if (autoScrollChk.checked)
requestAnimationFrame(() => {
body.scrollTop = body.scrollHeight;
});
}
function applyVisibility(line) {
const lvMatch = activeLevel === "all" || line.dataset.level === activeLevel;
const sMatch = !searchTerm || line.dataset.msg.includes(searchTerm);
line.classList.toggle("hidden", !(lvMatch && sMatch));
}
function applyAllFilters() {
body.querySelectorAll(".log-line").forEach(applyVisibility);
updateCount();
}
function updateCount() {
const visible = body.querySelectorAll(".log-line:not(.hidden)").length;
logCountEl.textContent = `${visible} / ${lineCount} lines`;
}
function escHtml(s) {
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
}
// Level filter
document.getElementById("levelFilters").addEventListener("click", (e) => {
const btn = e.target.closest(".lvl-btn");
if (!btn) return;
document.querySelectorAll(".lvl-btn").forEach((b) => b.classList.remove("active"));
btn.classList.add("active");
activeLevel = btn.dataset.level;
applyAllFilters();
});
// Search
searchInput.addEventListener("input", (e) => {
searchTerm = e.target.value.toLowerCase();
applyAllFilters();
});
// Clear
document.getElementById("clearLogs").addEventListener("click", () => {
body.innerHTML = "";
logLines = [];
lineCount = 0;
updateCount();
});
// Simulate
let sampleIdx = 0;
simBtn.addEventListener("click", () => {
if (simInterval) return;
simBtn.disabled = true;
stopBtn.disabled = false;
simInterval = setInterval(() => {
const [level, msg] = SAMPLE_LOGS[sampleIdx % SAMPLE_LOGS.length];
addLog(level, msg);
sampleIdx++;
}, 600);
});
stopBtn.addEventListener("click", () => {
clearInterval(simInterval);
simInterval = null;
simBtn.disabled = false;
stopBtn.disabled = true;
});
// Seed initial logs
const SEED = SAMPLE_LOGS.slice(0, 8);
SEED.forEach(([l, m]) => addLog(l, m));<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Log Viewer</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="demo">
<div class="lv-toolbar">
<span class="lv-title">Application Logs</span>
<div class="lv-controls">
<input class="lv-search" id="lvSearch" type="search" placeholder="Search…" />
<div class="level-filters" id="levelFilters">
<button class="lvl-btn active" data-level="all">All</button>
<button class="lvl-btn lvl-info" data-level="INFO">INFO</button>
<button class="lvl-btn lvl-warn" data-level="WARN">WARN</button>
<button class="lvl-btn lvl-error" data-level="ERROR">ERROR</button>
<button class="lvl-btn lvl-debug" data-level="DEBUG">DEBUG</button>
</div>
<label class="auto-scroll-label">
<input type="checkbox" id="autoScroll" checked />
Auto-scroll
</label>
<button class="lv-clear-btn" id="clearLogs">Clear</button>
</div>
</div>
<div class="lv-body" id="lvBody"></div>
<div class="lv-footer">
<span id="logCount">0 lines</span>
<button class="sim-btn" id="simBtn">▶ Simulate</button>
<button class="sim-btn sim-btn--stop" id="stopBtn" disabled>■ Stop</button>
</div>
</div>
<script src="script.js"></script>
</body>
</html>import { useState, useEffect, useRef, useCallback } from "react";
type Level = "info" | "warn" | "error" | "debug";
interface LogEntry {
id: number;
level: Level;
time: string;
message: string;
}
const LEVEL_COLORS: Record<Level, string> = {
info: "text-[#58a6ff]",
warn: "text-[#e3b341]",
error: "text-[#f85149]",
debug: "text-[#8b949e]",
};
const LEVEL_BG: Record<Level, string> = {
info: "bg-[#58a6ff]/10",
warn: "bg-[#e3b341]/10",
error: "bg-[#f85149]/10",
debug: "",
};
const LEVEL_BADGE: Record<Level, string> = {
info: "border-[#58a6ff]/40 text-[#58a6ff]",
warn: "border-[#e3b341]/40 text-[#e3b341]",
error: "border-[#f85149]/40 text-[#f85149]",
debug: "border-[#484f58] text-[#8b949e]",
};
const DEMO_MESSAGES: { level: Level; message: string }[] = [
{ level: "info", message: "Server started on port 3000" },
{ level: "debug", message: "DB connection pool initialized (max: 10)" },
{ level: "info", message: "GET /api/users 200 12ms" },
{ level: "debug", message: "Cache hit: user:42" },
{ level: "warn", message: "Rate limit approaching: 85/100 req/min" },
{ level: "info", message: "POST /api/auth/login 200 48ms" },
{ level: "error", message: "Failed to connect to Redis: ECONNREFUSED" },
{ level: "warn", message: "Retrying Redis in 5s (attempt 1/3)" },
{ level: "info", message: "GET /api/products 200 23ms" },
{ level: "debug", message: "Query executed in 4ms: SELECT * FROM users" },
{ level: "error", message: "Unhandled rejection: Cannot read property 'id' of null" },
{ level: "info", message: "Redis reconnected successfully" },
{ level: "warn", message: "Slow query detected: 1.2s — SELECT * FROM logs" },
{ level: "info", message: "DELETE /api/sessions/77 204 8ms" },
{ level: "debug", message: "Middleware: auth token validated (exp: 3600s)" },
];
let counter = 0;
function makeEntry(level: Level, message: string): LogEntry {
const now = new Date();
const pad = (n: number) => String(n).padStart(2, "0");
const time = `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}.${String(now.getMilliseconds()).padStart(3, "0")}`;
return { id: ++counter, level, time, message };
}
const LEVELS: Level[] = ["info", "warn", "error", "debug"];
export default function LogViewerRC() {
const [logs, setLogs] = useState<LogEntry[]>(() =>
DEMO_MESSAGES.slice(0, 8).map((m) => makeEntry(m.level, m.message))
);
const [activeFilters, setActiveFilters] = useState<Set<Level>>(new Set(LEVELS));
const [search, setSearch] = useState("");
const [autoScroll, setAutoScroll] = useState(true);
const [streaming, setStreaming] = useState(true);
const bodyRef = useRef<HTMLDivElement>(null);
const msgIdx = useRef(8);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const scrollToBottom = useCallback(() => {
requestAnimationFrame(() => {
if (bodyRef.current) {
bodyRef.current.scrollTop = bodyRef.current.scrollHeight;
}
});
}, []);
useEffect(() => {
if (autoScroll) scrollToBottom();
}, [logs, autoScroll, scrollToBottom]);
useEffect(() => {
if (!streaming) {
if (intervalRef.current) clearInterval(intervalRef.current);
return;
}
intervalRef.current = setInterval(() => {
const next = DEMO_MESSAGES[msgIdx.current % DEMO_MESSAGES.length];
msgIdx.current++;
setLogs((prev) => [...prev.slice(-199), makeEntry(next.level, next.message)]);
}, 1200);
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, [streaming]);
const toggleFilter = (level: Level) => {
setActiveFilters((prev) => {
const next = new Set(prev);
if (next.has(level)) next.delete(level);
else next.add(level);
return next;
});
};
const filtered = logs.filter(
(l) =>
activeFilters.has(l.level) &&
(!search ||
l.message.toLowerCase().includes(search.toLowerCase()) ||
l.level.includes(search.toLowerCase()))
);
const highlight = (text: string) => {
if (!search) return text;
const idx = text.toLowerCase().indexOf(search.toLowerCase());
if (idx === -1) return text;
return (
text.slice(0, idx) +
`<mark class="bg-yellow-400/30 text-yellow-200 rounded">${text.slice(idx, idx + search.length)}</mark>` +
text.slice(idx + search.length)
);
};
return (
<div className="min-h-screen bg-[#0d1117] p-6 flex justify-center">
<div className="w-full max-w-[860px] space-y-3">
{/* Toolbar */}
<div className="flex flex-wrap items-center gap-2">
{/* Level filters */}
<div className="flex gap-1">
{LEVELS.map((lvl) => (
<button
key={lvl}
onClick={() => toggleFilter(lvl)}
className={`px-2.5 py-1 rounded-md text-[11px] font-bold uppercase tracking-wide border transition-all ${
activeFilters.has(lvl) ? LEVEL_BADGE[lvl] : "border-transparent text-[#484f58]"
}`}
>
{lvl}
</button>
))}
</div>
{/* Search */}
<input
type="text"
placeholder="Filter logs…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="flex-1 min-w-[160px] bg-[#21262d] border border-[#30363d] rounded-lg px-3 py-1.5 text-[12px] text-[#e6edf3] placeholder-[#484f58] focus:outline-none focus:border-[#58a6ff] transition-colors"
/>
{/* Auto-scroll */}
<button
onClick={() => setAutoScroll((v) => !v)}
className={`px-2.5 py-1.5 rounded-lg text-[12px] font-semibold border transition-colors ${
autoScroll
? "bg-[#58a6ff]/10 border-[#58a6ff]/40 text-[#58a6ff]"
: "border-[#30363d] text-[#8b949e] hover:text-[#e6edf3]"
}`}
>
Auto-scroll
</button>
{/* Stream toggle */}
<button
onClick={() => setStreaming((v) => !v)}
className={`px-2.5 py-1.5 rounded-lg text-[12px] font-semibold border transition-colors ${
streaming
? "bg-green-500/10 border-green-500/30 text-green-400"
: "border-[#30363d] text-[#8b949e] hover:text-[#e6edf3]"
}`}
>
{streaming ? "● Live" : "Paused"}
</button>
{/* Clear */}
<button
onClick={() => setLogs([])}
className="px-2.5 py-1.5 rounded-lg text-[12px] font-semibold border border-[#30363d] text-[#8b949e] hover:text-[#f85149] hover:border-[#f85149]/40 transition-colors"
>
Clear
</button>
</div>
{/* Log body */}
<div className="bg-[#0d1117] border border-[#30363d] rounded-xl overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-4 py-2 bg-[#161b22] border-b border-[#30363d]">
<span className="text-[11px] font-mono font-bold text-[#8b949e] uppercase tracking-wider">
application.log
</span>
<span className="text-[11px] text-[#484f58]">{filtered.length} lines</span>
</div>
{/* Lines */}
<div
ref={bodyRef}
className="h-[420px] overflow-y-auto font-mono text-[12px] leading-[1.7] p-2 space-y-0.5"
>
{filtered.length === 0 ? (
<div className="flex items-center justify-center h-full text-[#484f58]">
No matching logs
</div>
) : (
filtered.map((entry) => (
<div
key={entry.id}
className={`flex items-start gap-2 px-2 py-0.5 rounded ${LEVEL_BG[entry.level]}`}
>
<span className="text-[#484f58] flex-shrink-0 select-none">{entry.time}</span>
<span
className={`font-bold uppercase text-[10px] leading-[1.9] flex-shrink-0 w-10 ${LEVEL_COLORS[entry.level]}`}
>
{entry.level}
</span>
<span
className="text-[#e6edf3] break-all"
dangerouslySetInnerHTML={{ __html: highlight(entry.message) }}
/>
</div>
))
)}
</div>
</div>
</div>
</div>
);
}<script>
import { onMount, onDestroy, afterUpdate, tick } from "svelte";
const LEVEL_COLORS = {
info: "color: #58a6ff;",
warn: "color: #e3b341;",
error: "color: #f85149;",
debug: "color: #8b949e;",
};
const LEVEL_BG = {
info: "background: rgba(88,166,255,0.1);",
warn: "background: rgba(227,179,65,0.1);",
error: "background: rgba(248,81,73,0.1);",
debug: "",
};
const LEVEL_BADGE_ACTIVE = {
info: "border-color: rgba(88,166,255,0.4); color: #58a6ff;",
warn: "border-color: rgba(227,179,65,0.4); color: #e3b341;",
error: "border-color: rgba(248,81,73,0.4); color: #f85149;",
debug: "border-color: #484f58; color: #8b949e;",
};
const DEMO_MESSAGES = [
{ level: "info", message: "Server started on port 3000" },
{ level: "debug", message: "DB connection pool initialized (max: 10)" },
{ level: "info", message: "GET /api/users 200 12ms" },
{ level: "debug", message: "Cache hit: user:42" },
{ level: "warn", message: "Rate limit approaching: 85/100 req/min" },
{ level: "info", message: "POST /api/auth/login 200 48ms" },
{ level: "error", message: "Failed to connect to Redis: ECONNREFUSED" },
{ level: "warn", message: "Retrying Redis in 5s (attempt 1/3)" },
{ level: "info", message: "GET /api/products 200 23ms" },
{ level: "debug", message: "Query executed in 4ms: SELECT * FROM users" },
{ level: "error", message: "Unhandled rejection: Cannot read property 'id' of null" },
{ level: "info", message: "Redis reconnected successfully" },
{ level: "warn", message: "Slow query detected: 1.2s \u2014 SELECT * FROM logs" },
{ level: "info", message: "DELETE /api/sessions/77 204 8ms" },
{ level: "debug", message: "Middleware: auth token validated (exp: 3600s)" },
];
const LEVELS = ["info", "warn", "error", "debug"];
let counter = 0;
function makeEntry(level, message) {
const now = new Date();
const pad = (n) => String(n).padStart(2, "0");
const time = `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}.${String(now.getMilliseconds()).padStart(3, "0")}`;
return { id: ++counter, level, time, message };
}
let logs = DEMO_MESSAGES.slice(0, 8).map((m) => makeEntry(m.level, m.message));
let activeFilters = new Set(LEVELS);
let search = "";
let autoScroll = true;
let streaming = true;
let bodyEl;
let msgIdx = 8;
let intervalId = null;
$: filtered = logs.filter(
(l) =>
activeFilters.has(l.level) &&
(!search ||
l.message.toLowerCase().includes(search.toLowerCase()) ||
l.level.includes(search.toLowerCase()))
);
function toggleFilter(level) {
if (activeFilters.has(level)) {
activeFilters.delete(level);
} else {
activeFilters.add(level);
}
activeFilters = new Set(activeFilters);
}
function highlight(text) {
if (!search) return text;
const idx = text.toLowerCase().indexOf(search.toLowerCase());
if (idx === -1) return text;
return (
text.slice(0, idx) +
`<mark style="background: rgba(250,204,21,0.3); color: #fde68a; border-radius: 2px;">${text.slice(idx, idx + search.length)}</mark>` +
text.slice(idx + search.length)
);
}
function startStreaming() {
if (intervalId) clearInterval(intervalId);
intervalId = setInterval(() => {
const next = DEMO_MESSAGES[msgIdx % DEMO_MESSAGES.length];
msgIdx++;
logs = [...logs.slice(-199), makeEntry(next.level, next.message)];
}, 1200);
}
function stopStreaming() {
if (intervalId) clearInterval(intervalId);
intervalId = null;
}
$: if (streaming) {
startStreaming();
} else {
stopStreaming();
}
afterUpdate(() => {
if (autoScroll && bodyEl) {
bodyEl.scrollTop = bodyEl.scrollHeight;
}
});
onDestroy(() => {
stopStreaming();
});
</script>
<div class="min-h-screen bg-[#0d1117] p-6 flex justify-center">
<div class="w-full max-w-[860px] space-y-3">
<!-- Toolbar -->
<div class="flex flex-wrap items-center gap-2">
<!-- Level filters -->
<div class="flex gap-1">
{#each LEVELS as lvl}
<button
on:click={() => toggleFilter(lvl)}
class="px-2.5 py-1 rounded-md text-[11px] font-bold uppercase tracking-wide border transition-all"
style={activeFilters.has(lvl) ? LEVEL_BADGE_ACTIVE[lvl] : "border-color: transparent; color: #484f58;"}
>
{lvl}
</button>
{/each}
</div>
<!-- Search -->
<input
type="text"
placeholder="Filter logs..."
bind:value={search}
class="flex-1 min-w-[160px] bg-[#21262d] border border-[#30363d] rounded-lg px-3 py-1.5 text-[12px] text-[#e6edf3] placeholder-[#484f58] focus:outline-none focus:border-[#58a6ff] transition-colors"
/>
<!-- Auto-scroll -->
<button
on:click={() => (autoScroll = !autoScroll)}
class="px-2.5 py-1.5 rounded-lg text-[12px] font-semibold border transition-colors"
style={autoScroll
? "background: rgba(88,166,255,0.1); border-color: rgba(88,166,255,0.4); color: #58a6ff;"
: "border-color: #30363d; color: #8b949e;"}
>
Auto-scroll
</button>
<!-- Stream toggle -->
<button
on:click={() => (streaming = !streaming)}
class="px-2.5 py-1.5 rounded-lg text-[12px] font-semibold border transition-colors"
style={streaming
? "background: rgba(34,197,94,0.1); border-color: rgba(34,197,94,0.3); color: #4ade80;"
: "border-color: #30363d; color: #8b949e;"}
>
{streaming ? "\u25CF Live" : "Paused"}
</button>
<!-- Clear -->
<button
on:click={() => (logs = [])}
class="px-2.5 py-1.5 rounded-lg text-[12px] font-semibold border border-[#30363d] text-[#8b949e] hover:text-[#f85149] hover:border-[#f85149]/40 transition-colors"
>
Clear
</button>
</div>
<!-- Log body -->
<div class="bg-[#0d1117] border border-[#30363d] rounded-xl overflow-hidden">
<!-- Header -->
<div class="flex items-center justify-between px-4 py-2 bg-[#161b22] border-b border-[#30363d]">
<span class="text-[11px] font-mono font-bold text-[#8b949e] uppercase tracking-wider">
application.log
</span>
<span class="text-[11px] text-[#484f58]">{filtered.length} lines</span>
</div>
<!-- Lines -->
<div
bind:this={bodyEl}
class="h-[420px] overflow-y-auto font-mono text-[12px] leading-[1.7] p-2 space-y-0.5"
>
{#if filtered.length === 0}
<div class="flex items-center justify-center h-full text-[#484f58]">
No matching logs
</div>
{:else}
{#each filtered as entry (entry.id)}
<div
class="flex items-start gap-2 px-2 py-0.5 rounded"
style={LEVEL_BG[entry.level]}
>
<span class="text-[#484f58] flex-shrink-0 select-none">{entry.time}</span>
<span
class="font-bold uppercase text-[10px] leading-[1.9] flex-shrink-0 w-10"
style={LEVEL_COLORS[entry.level]}
>
{entry.level}
</span>
<span class="text-[#e6edf3] break-all">
{@html highlight(entry.message)}
</span>
</div>
{/each}
{/if}
</div>
</div>
</div>
</div><script setup>
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from "vue";
const LEVEL_COLORS = {
info: { color: "#58a6ff" },
warn: { color: "#e3b341" },
error: { color: "#f85149" },
debug: { color: "#8b949e" },
};
const LEVEL_BG = {
info: { background: "rgba(88,166,255,0.1)" },
warn: { background: "rgba(227,179,65,0.1)" },
error: { background: "rgba(248,81,73,0.1)" },
debug: {},
};
const LEVEL_BADGE_ACTIVE = {
info: "border-[#58a6ff]/40 text-[#58a6ff]",
warn: "border-[#e3b341]/40 text-[#e3b341]",
error: "border-[#f85149]/40 text-[#f85149]",
debug: "border-[#484f58] text-[#8b949e]",
};
const DEMO_MESSAGES = [
{ level: "info", message: "Server started on port 3000" },
{ level: "debug", message: "DB connection pool initialized (max: 10)" },
{ level: "info", message: "GET /api/users 200 12ms" },
{ level: "debug", message: "Cache hit: user:42" },
{ level: "warn", message: "Rate limit approaching: 85/100 req/min" },
{ level: "info", message: "POST /api/auth/login 200 48ms" },
{ level: "error", message: "Failed to connect to Redis: ECONNREFUSED" },
{ level: "warn", message: "Retrying Redis in 5s (attempt 1/3)" },
{ level: "info", message: "GET /api/products 200 23ms" },
{ level: "debug", message: "Query executed in 4ms: SELECT * FROM users" },
{ level: "error", message: "Unhandled rejection: Cannot read property 'id' of null" },
{ level: "info", message: "Redis reconnected successfully" },
{ level: "warn", message: "Slow query detected: 1.2s \u2014 SELECT * FROM logs" },
{ level: "info", message: "DELETE /api/sessions/77 204 8ms" },
{ level: "debug", message: "Middleware: auth token validated (exp: 3600s)" },
];
const LEVELS = ["info", "warn", "error", "debug"];
let counter = 0;
function makeEntry(level, message) {
const now = new Date();
const pad = (n) => String(n).padStart(2, "0");
const time = `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}.${String(now.getMilliseconds()).padStart(3, "0")}`;
return { id: ++counter, level, time, message };
}
const logs = ref(DEMO_MESSAGES.slice(0, 8).map((m) => makeEntry(m.level, m.message)));
const activeFilters = ref(new Set(LEVELS));
const search = ref("");
const autoScroll = ref(true);
const streaming = ref(true);
const bodyRef = ref(null);
let msgIdx = 8;
let intervalId = null;
const filtered = computed(() =>
logs.value.filter(
(l) =>
activeFilters.value.has(l.level) &&
(!search.value ||
l.message.toLowerCase().includes(search.value.toLowerCase()) ||
l.level.includes(search.value.toLowerCase()))
)
);
function toggleFilter(level) {
const next = new Set(activeFilters.value);
if (next.has(level)) next.delete(level);
else next.add(level);
activeFilters.value = next;
}
function highlight(text) {
if (!search.value) return text;
const idx = text.toLowerCase().indexOf(search.value.toLowerCase());
if (idx === -1) return text;
return (
text.slice(0, idx) +
`<mark style="background: rgba(250,204,21,0.3); color: #fde68a; border-radius: 2px;">${text.slice(idx, idx + search.value.length)}</mark>` +
text.slice(idx + search.value.length)
);
}
function scrollToBottom() {
nextTick(() => {
if (bodyRef.value) bodyRef.value.scrollTop = bodyRef.value.scrollHeight;
});
}
watch(filtered, () => {
if (autoScroll.value) scrollToBottom();
});
watch(
streaming,
(val) => {
if (val) {
intervalId = setInterval(() => {
const next = DEMO_MESSAGES[msgIdx % DEMO_MESSAGES.length];
msgIdx++;
logs.value = [...logs.value.slice(-199), makeEntry(next.level, next.message)];
}, 1200);
} else {
if (intervalId) clearInterval(intervalId);
}
},
{ immediate: true }
);
onUnmounted(() => {
if (intervalId) clearInterval(intervalId);
});
</script>
<template>
<div class="min-h-screen bg-[#0d1117] p-6 flex justify-center">
<div class="w-full max-w-[860px] space-y-3">
<!-- Toolbar -->
<div class="flex flex-wrap items-center gap-2">
<!-- Level filters -->
<div class="flex gap-1">
<button
v-for="lvl in LEVELS"
:key="lvl"
@click="toggleFilter(lvl)"
class="px-2.5 py-1 rounded-md text-[11px] font-bold uppercase tracking-wide border transition-all"
:class="activeFilters.has(lvl) ? LEVEL_BADGE_ACTIVE[lvl] : 'border-transparent text-[#484f58]'"
>
{{ lvl }}
</button>
</div>
<!-- Search -->
<input
type="text"
placeholder="Filter logs..."
v-model="search"
class="flex-1 min-w-[160px] bg-[#21262d] border border-[#30363d] rounded-lg px-3 py-1.5 text-[12px] text-[#e6edf3] placeholder-[#484f58] focus:outline-none focus:border-[#58a6ff] transition-colors"
/>
<!-- Auto-scroll -->
<button
@click="autoScroll = !autoScroll"
class="px-2.5 py-1.5 rounded-lg text-[12px] font-semibold border transition-colors"
:class="autoScroll
? 'bg-[#58a6ff]/10 border-[#58a6ff]/40 text-[#58a6ff]'
: 'border-[#30363d] text-[#8b949e] hover:text-[#e6edf3]'"
>
Auto-scroll
</button>
<!-- Stream toggle -->
<button
@click="streaming = !streaming"
class="px-2.5 py-1.5 rounded-lg text-[12px] font-semibold border transition-colors"
:class="streaming
? 'bg-green-500/10 border-green-500/30 text-green-400'
: 'border-[#30363d] text-[#8b949e] hover:text-[#e6edf3]'"
>
{{ streaming ? '\u25CF Live' : 'Paused' }}
</button>
<!-- Clear -->
<button
@click="logs = []"
class="px-2.5 py-1.5 rounded-lg text-[12px] font-semibold border border-[#30363d] text-[#8b949e] hover:text-[#f85149] hover:border-[#f85149]/40 transition-colors"
>
Clear
</button>
</div>
<!-- Log body -->
<div class="bg-[#0d1117] border border-[#30363d] rounded-xl overflow-hidden">
<!-- Header -->
<div class="flex items-center justify-between px-4 py-2 bg-[#161b22] border-b border-[#30363d]">
<span class="text-[11px] font-mono font-bold text-[#8b949e] uppercase tracking-wider">
application.log
</span>
<span class="text-[11px] text-[#484f58]">{{ filtered.length }} lines</span>
</div>
<!-- Lines -->
<div
ref="bodyRef"
class="h-[420px] overflow-y-auto font-mono text-[12px] leading-[1.7] p-2 space-y-0.5"
>
<div v-if="filtered.length === 0" class="flex items-center justify-center h-full text-[#484f58]">
No matching logs
</div>
<div
v-else
v-for="entry in filtered"
:key="entry.id"
class="flex items-start gap-2 px-2 py-0.5 rounded"
:style="LEVEL_BG[entry.level]"
>
<span class="text-[#484f58] flex-shrink-0 select-none">{{ entry.time }}</span>
<span
class="font-bold uppercase text-[10px] leading-[1.9] flex-shrink-0 w-10"
:style="LEVEL_COLORS[entry.level]"
>
{{ entry.level }}
</span>
<span class="text-[#e6edf3] break-all" v-html="highlight(entry.message)"></span>
</div>
</div>
</div>
</div>
</div>
</template>Live log viewer with color-coded INFO/WARN/ERROR/DEBUG lines, level filter buttons, text search, auto-scroll toggle, and a clear button. Simulates streaming log output in the demo.