"use client" // Console output of a native OCI container: what the image's main process // writes to stdout/stderr, kept on the host by liblxc. The first request // brings the last N lines; while following, each poll sends back the byte // offset and inode it holds and receives only what was appended. Nothing is // cached — the backend reads the file on every request. import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { Card, CardContent } from "./ui/card" import { Badge } from "./ui/badge" import { Button } from "./ui/button" import { Input } from "./ui/input" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select" import { AlertCircle, Download, FileText, Loader2, Pause, Play, RefreshCw } from "lucide-react" import { fetchApi } from "../lib/api-config" import { useT } from "@/lib/i18n/provider" interface ConsoleLogResponse { ok: boolean enabled: boolean lines: string[] offset: number inode: number | null reset?: boolean head_truncated?: boolean error?: string } const LINE_OPTIONS = [100, 500, 1000] as const const POLL_MS = 2000 const lineTone = (line: string) => { if (/\b(error|fatal|panic|critical|exception)\b/i.test(line)) return "text-red-400" if (/\bwarn(ing)?\b/i.test(line)) return "text-amber-400" return "text-foreground/90" } export function OciConsoleLogPanel({ vmid, running }: { vmid: number; running: boolean }) { const t = useT() const [lineCount, setLineCount] = useState(500) const [lines, setLines] = useState([]) const [enabled, setEnabled] = useState(true) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [follow, setFollow] = useState(true) const [filter, setFilter] = useState("") const position = useRef<{ offset: number; inode: number | null } | null>(null) const scroller = useRef(null) const inFlight = useRef(false) const load = useCallback(async () => { setLoading(true) setError(null) try { const r = await fetchApi(`/api/lxc/${vmid}/console-log?lines=${lineCount}`) setEnabled(r.enabled) setLines(r.lines || []) position.current = { offset: r.offset, inode: r.inode } if (r.error) setError(r.error) } catch (e) { setError(e instanceof Error ? e.message : String(e)) } finally { setLoading(false) } }, [vmid, lineCount]) const poll = useCallback(async () => { const pos = position.current if (!pos || inFlight.current) return inFlight.current = true try { const inode = pos.inode === null ? "" : `&inode=${pos.inode}` const r = await fetchApi( `/api/lxc/${vmid}/console-log?lines=${lineCount}&offset=${pos.offset}${inode}`, ) position.current = { offset: r.offset, inode: r.inode } if (r.reset) { setLines(r.lines || []) } else if (r.lines?.length) { setLines((prev) => { const next = prev.concat(r.lines) return next.length > lineCount ? next.slice(next.length - lineCount) : next }) } } catch { // A failed poll leaves the view as it is; the next one retries. } finally { inFlight.current = false } }, [vmid, lineCount]) useEffect(() => { load() }, [load]) useEffect(() => { if (!follow || !running || !enabled || loading) return const timer = setInterval(() => { if (document.visibilityState === "visible") poll() }, POLL_MS) poll() return () => clearInterval(timer) }, [follow, running, enabled, loading, poll]) const shown = useMemo(() => { const needle = filter.trim().toLowerCase() return needle ? lines.filter((l) => l.toLowerCase().includes(needle)) : lines }, [lines, filter]) useEffect(() => { if (follow && scroller.current) scroller.current.scrollTop = scroller.current.scrollHeight }, [shown, follow]) // Scrolling up to read stops following; the Follow button resumes it. const onScroll = () => { const el = scroller.current if (!el || !follow) return if (el.scrollHeight - el.scrollTop - el.clientHeight > 40) setFollow(false) } const download = () => { const blob = new Blob([lines.join("\n") + "\n"], { type: "text/plain;charset=utf-8" }) const url = URL.createObjectURL(blob) const a = document.createElement("a") a.href = url a.download = `ct-${vmid}-console.log` a.click() URL.revokeObjectURL(url) } return (

{t("vmLxc.consoleLog.title")}

{enabled && shown.length > 0 && ( {shown.length} )}
{enabled && (
{running && ( )}
)}

{t("vmLxc.consoleLog.description")} {enabled && !running && <> {t("vmLxc.consoleLog.stopped")}}

{enabled && ( setFilter(e.target.value)} placeholder={t("vmLxc.consoleLog.filter")} className="h-8 text-xs mb-3 shrink-0" /> )} {loading && lines.length === 0 ? (
{t("vmLxc.consoleLog.loading")}
) : error ? (

{t("vmLxc.consoleLog.readFailed")}

{error}

) : !enabled ? (

{t("vmLxc.consoleLog.notAvailableTitle")}

{t("vmLxc.consoleLog.notAvailableHint")}

) : lines.length === 0 ? (
{t("vmLxc.consoleLog.empty")}
{t("vmLxc.consoleLog.emptyHint")}
) : shown.length === 0 ? (
{t("vmLxc.consoleLog.noMatches")}
) : (
                {shown.map((line, idx) => (
                  
{line || " "}
))}
)}
) }