"use client"; import { useQuery } from "@tanstack/react-query"; import { useMemo, useRef, useEffect, useState } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "@/components/ui/card"; type Level = "ERROR" | "WARN" | "INFO" | "DEBUG" | "OTHER"; const LEVEL_ORDER: Level[] = ["ERROR", "WARN", "INFO", "DEBUG", "OTHER"]; const levelClass: Record = { ERROR: "text-red-300", WARN: "text-amber-300", INFO: "text-muted-foreground", DEBUG: "text-blue-300/70", OTHER: "text-muted-foreground/70", }; function detectLevel(line: string): Level { if (/\b(ERROR|FATAL|SEVERE|Exception|Traceback)\b/i.test(line)) return "ERROR"; if (/\b(WARN|WARNING)\b/i.test(line)) return "WARN"; if (/\bINFO\b/i.test(line)) return "INFO"; if (/\bDEBUG\b/i.test(line)) return "DEBUG"; return "OTHER"; } export function LogViewer() { const logRef = useRef(null); const [autoRefresh, setAutoRefresh] = useState(false); const [lines, setLines] = useState(100); const [search, setSearch] = useState(""); const [levelFilter, setLevelFilter] = useState>( new Set(LEVEL_ORDER) ); const [autoScroll, setAutoScroll] = useState(true); const [copied, setCopied] = useState(false); const autoScrollRef = useRef(true); const { data, refetch, isFetching, isError, error } = useQuery<{ logs: string }>({ queryKey: ["logs", lines], queryFn: async () => { const res = await fetch(`/api/logs?lines=${lines}`); if (!res.ok) throw new Error(`Failed to load logs (${res.status})`); return res.json(); }, refetchInterval: autoRefresh ? 3000 : false, }); const parsed = useMemo(() => { if (!data?.logs) return [] as { line: string; level: Level }[]; return data.logs .split("\n") .filter((l) => l.length > 0) .map((line) => ({ line, level: detectLevel(line) })); }, [data?.logs]); const filtered = useMemo(() => { const q = search.trim().toLowerCase(); return parsed.filter( (r) => levelFilter.has(r.level) && (!q || r.line.toLowerCase().includes(q)) ); }, [parsed, search, levelFilter]); useEffect(() => { if (autoScroll && logRef.current && autoScrollRef.current) { logRef.current.scrollTop = logRef.current.scrollHeight; } }, [filtered, autoScroll]); const handleScroll = () => { if (!logRef.current) return; const { scrollTop, scrollHeight, clientHeight } = logRef.current; autoScrollRef.current = scrollHeight - scrollTop - clientHeight < 80; }; const toggleLevel = (lvl: Level) => { setLevelFilter((prev) => { const next = new Set(prev); if (next.has(lvl)) next.delete(lvl); else next.add(lvl); return next; }); }; const copyAll = async () => { try { await navigator.clipboard.writeText(filtered.map((r) => r.line).join("\n")); setCopied(true); setTimeout(() => setCopied(false), 1500); } catch {} }; return (
Console Logs Server output from journalctl
setSearch(e.target.value)} className="h-8 text-sm max-w-[220px]" />
{LEVEL_ORDER.map((lvl) => { const active = levelFilter.has(lvl); return ( ); })}
{isError ? (

Failed to load logs:{" "} {error instanceof Error ? error.message : "unknown error"}

) : filtered.length === 0 ? (

{isFetching ? "Loading logs..." : "No log lines match the current filter."}

) : ( filtered.map((r, i) => (
{r.line}
)) )}

{filtered.length} / {parsed.length} lines

); }