4bnode
Version:
4bnode is a CLI-powered backend development platform with a built-in visual dashboard to generate, manage, and test Node.js/Express APIs faster.
239 lines (217 loc) • 8.76 kB
JavaScript
import React, { useState, useEffect, useRef, useCallback } from "react";
import { html, api, API, getStoredPasskey } from "../lib.js";
import { useApp } from "../context.js";
const TYPE_COLORS = {
log: "var(--text)",
info: "var(--info)",
warn: "var(--warning)",
error: "var(--error)",
};
const TYPE_BADGES = {
log: "LOG",
info: "INFO",
warn: "WARN",
error: "ERR",
};
export function LogsPage() {
const { toast } = useApp();
const [logs, setLogs] = useState([]);
const [filter, setFilter] = useState("all");
const [search, setSearch] = useState("");
const [autoScroll, setAutoScroll] = useState(true);
const [connected, setConnected] = useState(false);
const bottomRef = useRef(null);
const containerRef = useRef(null);
// Load existing logs
useEffect(() => {
api("/logs").then(setLogs).catch(() => {});
}, []);
// SSE stream for real-time logs.
// Uses fetch() so the passkey travels in the x-passkey header instead of the
// query string (EventSource can't set headers, which leaked it into logs).
useEffect(() => {
const controller = new AbortController();
let aborted = false;
let retryTimer = null;
async function stream() {
const passkey = getStoredPasskey();
try {
const res = await fetch(API + "/logs/stream", {
headers: passkey ? { "x-passkey": passkey } : {},
signal: controller.signal,
});
if (!res.ok || !res.body) throw new Error("stream failed");
setConnected(true);
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let idx;
while ((idx = buffer.indexOf("\n")) !== -1) {
const line = buffer.slice(0, idx);
buffer = buffer.slice(idx + 1);
if (!line.startsWith("data: ")) continue;
try {
const entry = JSON.parse(line.slice(6));
setLogs((prev) => {
const next = [...prev, entry];
return next.length > 500 ? next.slice(-500) : next;
});
} catch {}
}
}
} catch {
// network/parse error — fall through to reconnect below
}
if (!aborted) {
setConnected(false);
retryTimer = setTimeout(stream, 2000);
}
}
stream();
return () => {
aborted = true;
controller.abort();
if (retryTimer) clearTimeout(retryTimer);
};
}, []);
// Auto-scroll to bottom
useEffect(() => {
if (autoScroll && bottomRef.current) {
bottomRef.current.scrollIntoView({ behavior: "smooth" });
}
}, [logs, autoScroll]);
// Detect manual scroll
const handleScroll = useCallback(() => {
if (!containerRef.current) return;
const { scrollTop, scrollHeight, clientHeight } = containerRef.current;
setAutoScroll(scrollHeight - scrollTop - clientHeight < 60);
}, []);
const handleClear = async () => {
try {
await api("/logs", { method: "DELETE" });
setLogs([]);
toast("Logs cleared", "success");
} catch (err) {
toast(err.message, "error");
}
};
const filtered = logs.filter((l) => {
if (filter !== "all" && l.type !== filter) return false;
if (search && !l.message.toLowerCase().includes(search.toLowerCase())) return false;
return true;
});
const counts = {
all: logs.length,
log: logs.filter((l) => l.type === "log").length,
error: logs.filter((l) => l.type === "error").length,
warn: logs.filter((l) => l.type === "warn").length,
info: logs.filter((l) => l.type === "info").length,
};
const formatTime = (ts) => {
try {
const d = new Date(ts);
return d.toLocaleTimeString([], { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" })
+ "." + String(d.getMilliseconds()).padStart(3, "0");
} catch { return ""; }
};
return html`<div style=${{ display: "flex", flexDirection: "column", height: "100%", gap: "12px" }}>
<!-- Toolbar -->
<div style=${{ display: "flex", alignItems: "center", gap: "10px", flexWrap: "wrap" }}>
<div style=${{ display: "flex", gap: "4px", background: "var(--bg-surface)", borderRadius: "8px", padding: "3px" }}>
${["all", "log", "error", "warn", "info"].map(
(f) => html`<button key=${f} onClick=${() => setFilter(f)}
style=${{
padding: "4px 10px", fontSize: "11px", borderRadius: "6px", border: "none", cursor: "pointer",
background: filter === f ? "var(--accent)" : "transparent",
color: filter === f ? "#fff" : "var(--text-muted)",
fontWeight: filter === f ? 600 : 400,
}}>
${f.toUpperCase()}${counts[f] > 0 ? ` (${counts[f]})` : ""}
</button>`
)}
</div>
<input
type="text"
placeholder="Search logs..."
value=${search}
onInput=${(e) => setSearch(e.target.value)}
style=${{
flex: 1, minWidth: "160px", padding: "6px 10px", fontSize: "12px",
background: "var(--bg-surface)", border: "1px solid var(--border)", borderRadius: "6px",
color: "var(--text)", outline: "none",
}}
/>
<div style=${{ display: "flex", alignItems: "center", gap: "8px" }}>
<span style=${{
width: "8px", height: "8px", borderRadius: "50%",
background: connected ? "var(--success)" : "var(--error)",
display: "inline-block",
}} />
<span style=${{ fontSize: "11px", color: "var(--text-muted)" }}>
${connected ? "Live" : "Disconnected"}
</span>
</div>
<button onClick=${() => setAutoScroll(!autoScroll)}
style=${{
padding: "4px 10px", fontSize: "11px", borderRadius: "6px", border: "1px solid var(--border)",
cursor: "pointer",
background: autoScroll ? "var(--accent)" : "var(--bg-surface)",
color: autoScroll ? "#fff" : "var(--text-muted)",
}}>
Auto-scroll ${autoScroll ? "ON" : "OFF"}
</button>
<button onClick=${handleClear}
style=${{
padding: "4px 10px", fontSize: "11px", borderRadius: "6px",
border: "1px solid var(--border)", cursor: "pointer",
background: "var(--bg-surface)", color: "var(--error)",
}}>
Clear
</button>
</div>
<!-- Log Output -->
<div ref=${containerRef} onScroll=${handleScroll}
style=${{
flex: 1, overflow: "auto", background: "#0a0a0f", borderRadius: "8px",
border: "1px solid var(--border)", fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace",
fontSize: "12px", lineHeight: "1.7", padding: "12px 0",
}}>
${filtered.length === 0 && html`
<div style=${{ padding: "40px", textAlign: "center", color: "var(--text-muted)", fontSize: "12px" }}>
${logs.length === 0 ? "No logs yet. Console output from your app will appear here." : "No logs match the current filter."}
</div>
`}
${filtered.map(
(entry) => html`<div key=${entry.id}
style=${{
padding: "1px 14px", display: "flex", gap: "10px", alignItems: "flex-start",
borderLeft: entry.type === "error" ? "2px solid var(--error)" : entry.type === "warn" ? "2px solid var(--warning)" : "2px solid transparent",
background: entry.type === "error" ? "rgba(239,68,68,0.04)" : entry.type === "warn" ? "rgba(245,158,11,0.04)" : "transparent",
}}>
<span style=${{ color: "var(--text-muted)", fontSize: "10px", minWidth: "80px", paddingTop: "2px", userSelect: "none" }}>
${formatTime(entry.timestamp)}
</span>
<span style=${{
fontSize: "9px", fontWeight: 700, minWidth: "32px", textAlign: "center",
padding: "1px 4px", borderRadius: "3px", letterSpacing: "0.5px", paddingTop: "3px",
background: entry.type === "error" ? "rgba(239,68,68,0.15)" : entry.type === "warn" ? "rgba(245,158,11,0.15)" : entry.type === "info" ? "rgba(59,130,246,0.15)" : "rgba(255,255,255,0.06)",
color: TYPE_COLORS[entry.type] || "var(--text)",
}}>
${TYPE_BADGES[entry.type] || "LOG"}
</span>
<span style=${{
color: TYPE_COLORS[entry.type] || "var(--text)",
whiteSpace: "pre-wrap", wordBreak: "break-all", flex: 1,
}}>
${entry.message}
</span>
</div>`
)}
<div ref=${bottomRef} />
</div>
</div>`;
}