247 lines
5.8 KiB
TypeScript
247 lines
5.8 KiB
TypeScript
|
|
import * as React from "react";
|
||
|
|
|
||
|
|
const { useCallback, useEffect, useRef, useState } = React;
|
||
|
|
|
||
|
|
export interface LogEntry {
|
||
|
|
timestamp: string;
|
||
|
|
level: string;
|
||
|
|
message: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
interface ServerLogsPanelProps {
|
||
|
|
logs: LogEntry[];
|
||
|
|
}
|
||
|
|
|
||
|
|
function levelColor(level: string): string {
|
||
|
|
switch (level.toUpperCase()) {
|
||
|
|
case "ERROR":
|
||
|
|
return "#e06c75";
|
||
|
|
case "WARN":
|
||
|
|
return "#e5c07b";
|
||
|
|
default:
|
||
|
|
return "#98c379";
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
export function ServerLogsPanel({ logs }: ServerLogsPanelProps) {
|
||
|
|
const [isOpen, setIsOpen] = useState(false);
|
||
|
|
const [filter, setFilter] = useState("");
|
||
|
|
const [severityFilter, setSeverityFilter] = useState<string>("ALL");
|
||
|
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||
|
|
const userScrolledUpRef = useRef(false);
|
||
|
|
const lastScrollTopRef = useRef(0);
|
||
|
|
|
||
|
|
const filteredLogs = logs.filter((entry) => {
|
||
|
|
const matchesSeverity =
|
||
|
|
severityFilter === "ALL" || entry.level.toUpperCase() === severityFilter;
|
||
|
|
const matchesFilter =
|
||
|
|
filter === "" ||
|
||
|
|
entry.message.toLowerCase().includes(filter.toLowerCase()) ||
|
||
|
|
entry.timestamp.includes(filter);
|
||
|
|
return matchesSeverity && matchesFilter;
|
||
|
|
});
|
||
|
|
|
||
|
|
const scrollToBottom = useCallback(() => {
|
||
|
|
const el = scrollRef.current;
|
||
|
|
if (el) {
|
||
|
|
el.scrollTop = el.scrollHeight;
|
||
|
|
lastScrollTopRef.current = el.scrollTop;
|
||
|
|
}
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
// Auto-scroll when new entries arrive (unless user scrolled up).
|
||
|
|
useEffect(() => {
|
||
|
|
if (!isOpen) return;
|
||
|
|
if (!userScrolledUpRef.current) {
|
||
|
|
scrollToBottom();
|
||
|
|
}
|
||
|
|
}, [filteredLogs.length, isOpen, scrollToBottom]);
|
||
|
|
|
||
|
|
const handleScroll = () => {
|
||
|
|
const el = scrollRef.current;
|
||
|
|
if (!el) return;
|
||
|
|
const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 5;
|
||
|
|
if (el.scrollTop < lastScrollTopRef.current) {
|
||
|
|
userScrolledUpRef.current = true;
|
||
|
|
}
|
||
|
|
if (isAtBottom) {
|
||
|
|
userScrolledUpRef.current = false;
|
||
|
|
}
|
||
|
|
lastScrollTopRef.current = el.scrollTop;
|
||
|
|
};
|
||
|
|
|
||
|
|
const severityButtons = ["ALL", "INFO", "WARN", "ERROR"] as const;
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div
|
||
|
|
data-testid="server-logs-panel"
|
||
|
|
style={{
|
||
|
|
borderRadius: "8px",
|
||
|
|
border: "1px solid #333",
|
||
|
|
overflow: "hidden",
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
{/* Header / toggle */}
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
data-testid="server-logs-panel-toggle"
|
||
|
|
onClick={() => setIsOpen((v) => !v)}
|
||
|
|
style={{
|
||
|
|
width: "100%",
|
||
|
|
display: "flex",
|
||
|
|
alignItems: "center",
|
||
|
|
justifyContent: "space-between",
|
||
|
|
padding: "8px 12px",
|
||
|
|
background: "#1e1e1e",
|
||
|
|
border: "none",
|
||
|
|
cursor: "pointer",
|
||
|
|
color: "#ccc",
|
||
|
|
fontSize: "0.85em",
|
||
|
|
fontWeight: 600,
|
||
|
|
textAlign: "left",
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<span>Server Logs</span>
|
||
|
|
<span style={{ color: "#666", fontSize: "0.85em" }}>
|
||
|
|
{logs.length > 0 && (
|
||
|
|
<span style={{ marginRight: "8px", color: "#555" }}>
|
||
|
|
{logs.length}
|
||
|
|
</span>
|
||
|
|
)}
|
||
|
|
{isOpen ? "▲" : "▼"}
|
||
|
|
</span>
|
||
|
|
</button>
|
||
|
|
|
||
|
|
{isOpen && (
|
||
|
|
<div style={{ background: "#0d1117" }}>
|
||
|
|
{/* Filter controls */}
|
||
|
|
<div
|
||
|
|
style={{
|
||
|
|
display: "flex",
|
||
|
|
gap: "6px",
|
||
|
|
padding: "8px",
|
||
|
|
borderBottom: "1px solid #1e1e1e",
|
||
|
|
flexWrap: "wrap",
|
||
|
|
alignItems: "center",
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<input
|
||
|
|
type="text"
|
||
|
|
data-testid="server-logs-filter-input"
|
||
|
|
value={filter}
|
||
|
|
onChange={(e) => setFilter(e.target.value)}
|
||
|
|
placeholder="Filter logs..."
|
||
|
|
style={{
|
||
|
|
flex: 1,
|
||
|
|
minWidth: "80px",
|
||
|
|
padding: "4px 8px",
|
||
|
|
borderRadius: "4px",
|
||
|
|
border: "1px solid #333",
|
||
|
|
background: "#161b22",
|
||
|
|
color: "#ccc",
|
||
|
|
fontSize: "0.8em",
|
||
|
|
outline: "none",
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
{severityButtons.map((sev) => (
|
||
|
|
<button
|
||
|
|
key={sev}
|
||
|
|
type="button"
|
||
|
|
data-testid={`server-logs-severity-${sev.toLowerCase()}`}
|
||
|
|
onClick={() => setSeverityFilter(sev)}
|
||
|
|
style={{
|
||
|
|
padding: "3px 8px",
|
||
|
|
borderRadius: "4px",
|
||
|
|
border: "1px solid",
|
||
|
|
borderColor:
|
||
|
|
severityFilter === sev ? levelColor(sev) : "#333",
|
||
|
|
background:
|
||
|
|
severityFilter === sev
|
||
|
|
? "rgba(255,255,255,0.06)"
|
||
|
|
: "transparent",
|
||
|
|
color:
|
||
|
|
sev === "ALL"
|
||
|
|
? severityFilter === "ALL"
|
||
|
|
? "#ccc"
|
||
|
|
: "#555"
|
||
|
|
: levelColor(sev),
|
||
|
|
fontSize: "0.75em",
|
||
|
|
cursor: "pointer",
|
||
|
|
fontWeight: severityFilter === sev ? 700 : 400,
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
{sev}
|
||
|
|
</button>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Log entries */}
|
||
|
|
<div
|
||
|
|
ref={scrollRef}
|
||
|
|
onScroll={handleScroll}
|
||
|
|
data-testid="server-logs-entries"
|
||
|
|
style={{
|
||
|
|
maxHeight: "240px",
|
||
|
|
overflowY: "auto",
|
||
|
|
padding: "4px 0",
|
||
|
|
fontFamily: "monospace",
|
||
|
|
fontSize: "0.75em",
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
{filteredLogs.length === 0 ? (
|
||
|
|
<div
|
||
|
|
style={{
|
||
|
|
padding: "16px",
|
||
|
|
color: "#444",
|
||
|
|
textAlign: "center",
|
||
|
|
fontSize: "0.9em",
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
No log entries
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
filteredLogs.map((entry, idx) => (
|
||
|
|
<div
|
||
|
|
key={`${entry.timestamp}-${idx}`}
|
||
|
|
style={{
|
||
|
|
display: "flex",
|
||
|
|
gap: "6px",
|
||
|
|
padding: "1px 8px",
|
||
|
|
lineHeight: "1.5",
|
||
|
|
borderBottom: "1px solid #111",
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<span
|
||
|
|
style={{ color: "#444", flexShrink: 0, minWidth: "70px" }}
|
||
|
|
>
|
||
|
|
{entry.timestamp.replace("T", " ").replace("Z", "")}
|
||
|
|
</span>
|
||
|
|
<span
|
||
|
|
style={{
|
||
|
|
color: levelColor(entry.level),
|
||
|
|
flexShrink: 0,
|
||
|
|
minWidth: "38px",
|
||
|
|
fontWeight: 700,
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
{entry.level}
|
||
|
|
</span>
|
||
|
|
<span
|
||
|
|
style={{
|
||
|
|
color: "#c9d1d9",
|
||
|
|
wordBreak: "break-word",
|
||
|
|
whiteSpace: "pre-wrap",
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
{entry.message}
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
))
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|