Sanitized mirror from private repository - 2026-04-18 11:19:59 UTC
This commit is contained in:
136
dashboard/ui/app/logs/page.tsx
Normal file
136
dashboard/ui/app/logs/page.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user