"use client"; import { ArrowDown, Download, RefreshCw, ScrollText, Search, X, } from "@/components/icons/platform-icons"; import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; import { VisuallyHidden } from "@radix-ui/react-visually-hidden"; import { BADGE_SURFACE, PANEL_INSET } from "@/lib/ui/surface-tokens"; import { cn } from "@/lib/utils"; import type { AppActionTarget } from "@/modules/apps/components/app-grid-presenters"; import { useCallback, useEffect, useRef, useState } from "react"; type LogLevel = "ERROR" | "WARN" | "INFO" | "DEBUG"; type LogLine = { id: number; timestamp: string; stream: "stdout" | "stderr"; level: LogLevel | null; message: string; }; type AppLogsDialogProps = { target: AppActionTarget | null; onClose: () => void; }; const LEVEL_CLASSES: Record = { ERROR: "bg-status-red/15 text-status-red border-status-red/25", WARN: "bg-yellow-500/15 text-yellow-400 border-yellow-500/25", INFO: "bg-blue-500/15 text-blue-400 border-blue-500/25", DEBUG: "bg-muted/30 text-muted-foreground border-muted/40", }; const LEVEL_TEXT: Record = { ERROR: "text-status-red", WARN: "text-yellow-300", INFO: "text-blue-300", DEBUG: "text-muted-foreground", }; function formatTimestamp(iso: string): string { try { const d = new Date(iso); return d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit", second: "2-digit", fractionalSecondDigits: 3, }); } catch { return iso; } } let lineIdCounter = 0; export function AppLogsDialog({ target, onClose }: AppLogsDialogProps) { const [lines, setLines] = useState([]); const [search, setSearch] = useState(""); const [autoScroll, setAutoScroll] = useState(true); const [streamError, setStreamError] = useState(null); const [streamEnded, setStreamEnded] = useState(false); const [isConnected, setIsConnected] = useState(false); const logContainerRef = useRef(null); const bottomRef = useRef(null); const esRef = useRef(null); const openStream = useCallback((appId: string, containerName: string, append = false) => { esRef.current?.close(); setStreamError(null); setStreamEnded(false); setIsConnected(false); if (!append) { lineIdCounter = 0; setLines([]); setAutoScroll(true); } const url = `/api/v1/apps/${appId}/logs/stream?container=${encodeURIComponent(containerName)}`; const es = new EventSource(url); esRef.current = es; es.addEventListener("log.line", (event) => { setIsConnected(true); try { const data = JSON.parse(event.data) as { timestamp: string; stream: "stdout" | "stderr"; level: LogLevel | null; message: string; }; setLines((prev) => [ ...prev, { id: ++lineIdCounter, timestamp: data.timestamp, stream: data.stream, level: data.level, message: data.message, }, ]); } catch {} }); es.addEventListener("log.end", () => { setStreamEnded(true); setIsConnected(false); es.close(); }); es.addEventListener("log.error", (event) => { try { const data = JSON.parse(event.data) as { message: string }; setStreamError(data.message); } catch { setStreamError("Log stream error"); } setIsConnected(false); es.close(); }); es.onerror = () => { setStreamError("Connection to log stream lost"); setIsConnected(false); es.close(); }; }, []); useEffect(() => { if (!target) { setLines([]); setSearch(""); setAutoScroll(true); setStreamError(null); setStreamEnded(false); setIsConnected(false); return; } openStream(target.appId, target.containerName); return () => { esRef.current?.close(); esRef.current = null; }; }, [target, openStream]); useEffect(() => { if (autoScroll && bottomRef.current) { bottomRef.current.scrollIntoView({ behavior: "instant" }); } }, [lines, autoScroll]); const handleScroll = useCallback(() => { const el = logContainerRef.current; if (!el) return; const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40; setAutoScroll(atBottom); }, []); const resumeScroll = useCallback(() => { setAutoScroll(true); bottomRef.current?.scrollIntoView({ behavior: "smooth" }); }, []); const handleReconnect = useCallback(() => { if (!target) return; openStream(target.appId, target.containerName, true); }, [target, openStream]); const downloadLogs = useCallback(() => { const text = lines .map((l) => `${l.timestamp} [${l.stream.toUpperCase()}] ${l.message}`) .join("\n"); const blob = new Blob([text], { type: "text/plain" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `${target?.appId ?? "app"}-logs.log`; a.click(); URL.revokeObjectURL(url); }, [lines, target]); const filteredLines = search.trim() ? lines.filter((l) => l.message.toLowerCase().includes(search.toLowerCase()), ) : lines; return ( { if (!open) onClose(); }} > {target?.appName ?? "App"} — Logs {/* Header */}
{target?.appName ?? "App"} — Logs
{/* Search */}
setSearch(e.target.value)} className="bg-transparent pl-2 pr-1 text-xs text-foreground placeholder:text-muted-foreground/60 focus:outline-none w-40" />
{/* Auto-scroll resume */} {!autoScroll && ( )} {/* Download */} {/* Stream status */}
{lines.length} lines
{/* Close */}
{/* Error banner */} {streamError && (
{streamError}
)} {/* Log area */}
{filteredLines.length === 0 && !streamError ? (

{search.trim() ? "No matching log lines." : "Waiting for logs…"}

) : (
{filteredLines.map((line) => (
{/* Timestamp */} {formatTimestamp(line.timestamp)} {/* Level badge */} {line.level ? ( {line.level} ) : line.stream === "stderr" ? ( ERR ) : ( )} {/* Message */} {line.message}
))}
)}
{/* Footer */} {streamEnded && (
Stream ended — container stopped or log tail reached.
)}
); }