137 lines
5.1 KiB
TypeScript
137 lines
5.1 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect, useMemo } from "react";
|
|
import { usePoll } from "@/lib/use-poll";
|
|
import { fetchAPI } from "@/lib/api";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
import { cn } from "@/lib/utils";
|
|
import { CardSkeleton } from "@/components/skeleton";
|
|
|
|
interface LogFile {
|
|
name: string;
|
|
filename?: string;
|
|
size?: string;
|
|
size_bytes?: number;
|
|
modified?: number;
|
|
}
|
|
|
|
export default function LogsPage() {
|
|
const { data: logsRaw } = usePoll<LogFile[] | { files: LogFile[] }>("/api/logs", 60000);
|
|
const logFiles = Array.isArray(logsRaw) ? logsRaw : (logsRaw?.files ?? []);
|
|
|
|
const [selected, setSelected] = useState<string | null>(null);
|
|
const [content, setContent] = useState<string>("");
|
|
const [loadingContent, setLoadingContent] = useState(false);
|
|
const [search, setSearch] = useState("");
|
|
|
|
useEffect(() => {
|
|
if (!selected) {
|
|
setContent("");
|
|
return;
|
|
}
|
|
let cancelled = false;
|
|
setLoadingContent(true);
|
|
fetchAPI<{ lines?: string[]; content?: string } | string>(`/api/logs/${encodeURIComponent(selected)}?tail=200`)
|
|
.then((data) => {
|
|
if (cancelled) return;
|
|
if (typeof data === "string") setContent(data);
|
|
else if (Array.isArray((data as Record<string,unknown>).lines)) setContent(((data as Record<string,unknown>).lines as string[]).join("\n"));
|
|
else if ((data as Record<string,unknown>).content) setContent(String((data as Record<string,unknown>).content));
|
|
else setContent(JSON.stringify(data, null, 2));
|
|
})
|
|
.catch((err) => {
|
|
if (cancelled) return;
|
|
setContent(`Error loading log: ${err}`);
|
|
})
|
|
.finally(() => {
|
|
if (!cancelled) setLoadingContent(false);
|
|
});
|
|
return () => { cancelled = true; };
|
|
}, [selected]);
|
|
|
|
const filteredLines = useMemo(() => {
|
|
if (!content) return [];
|
|
const lines = content.split("\n");
|
|
if (!search.trim()) return lines;
|
|
const lower = search.toLowerCase();
|
|
return lines.filter(line => line.toLowerCase().includes(lower));
|
|
}, [content, search]);
|
|
|
|
return (
|
|
<div className="space-y-8">
|
|
<h1 className="text-lg font-semibold">Logs</h1>
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-4 gap-5" style={{ minHeight: "500px" }}>
|
|
{/* Left sidebar: log file list */}
|
|
<Card className="lg:col-span-1">
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="text-base font-semibold">Log Files</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<ScrollArea className="h-[460px]">
|
|
{logFiles.length === 0 ? (
|
|
<CardSkeleton lines={8} />
|
|
) : (
|
|
<div className="space-y-1">
|
|
{logFiles.map((file) => (
|
|
<button
|
|
key={file.name}
|
|
onClick={() => setSelected(file.name)}
|
|
className={cn(
|
|
"w-full text-left rounded-lg px-3 py-2 text-sm transition-all duration-200",
|
|
selected === file.name
|
|
? "bg-white/[0.06] text-foreground"
|
|
: "text-muted-foreground hover:bg-white/[0.03] hover:text-foreground"
|
|
)}
|
|
>
|
|
<p className="font-medium truncate">{file.name}</p>
|
|
{(file.size || file.size_bytes != null) && (
|
|
<p className="text-[10px] text-muted-foreground/60">
|
|
{file.size ?? (file.size_bytes != null ? `${(file.size_bytes / 1024).toFixed(0)} KB` : "")}
|
|
</p>
|
|
)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</ScrollArea>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Right: log content viewer */}
|
|
<Card className="lg:col-span-3">
|
|
<CardHeader className="pb-2 flex flex-row items-center justify-between gap-4">
|
|
<CardTitle className="text-base font-semibold">
|
|
{selected ?? "Select a log file"}
|
|
</CardTitle>
|
|
{selected && (
|
|
<input
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
placeholder="Filter lines..."
|
|
className="rounded-lg glass-input px-3 py-1.5 text-xs w-48"
|
|
/>
|
|
)}
|
|
</CardHeader>
|
|
<CardContent>
|
|
<ScrollArea className="h-[460px]">
|
|
{!selected ? (
|
|
<p className="text-xs text-muted-foreground/60 py-4 text-center">
|
|
Select a log file from the sidebar
|
|
</p>
|
|
) : loadingContent ? (
|
|
<CardSkeleton lines={10} />
|
|
) : (
|
|
<pre className="text-[11px] font-mono text-foreground whitespace-pre-wrap break-all leading-relaxed">
|
|
{filteredLines.join("\n") || "No matching lines"}
|
|
</pre>
|
|
)}
|
|
</ScrollArea>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|