"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 start_marks?: boolean error?: string } const LINE_OPTIONS = [100, 500, 1000] as const const POLL_MS = 2000 // Written into the log by the container's pre-start hook at every start. const START_MARK_RE = /^=== ProxMenux: container started (\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2}) ===$/ const lastStart = (lines: string[]) => { for (let i = lines.length - 1; i >= 0; i--) if (START_MARK_RE.test(lines[i])) return i return -1 } 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 [sinceStart, setSinceStart] = useState(true) 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}${sinceStart ? "&since_start=1" : ""}`, ) 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, sinceStart]) 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) => { let next = prev.concat(r.lines) // A restart while following opens a new session. const mark = sinceStart ? lastStart(next) : -1 if (mark > 0) next = next.slice(mark) 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, sinceStart]) 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) => START_MARK_RE.test(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) => {
                  const mark = START_MARK_RE.exec(line)
                  if (mark) {
                    const when = new Date(`${mark[1]}T${mark[2]}`)
                    return (
                      
{t("vmLxc.consoleLog.startedAt", { date: Number.isNaN(when.getTime()) ? `${mark[1]} ${mark[2]}` : when.toLocaleString(), })}
) } return
{line || " "}
})}
)}
) }