Files
ProxMenux/AppImage/components/oci-console-log-panel.tsx
T

292 lines
12 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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<number>(500)
const [lines, setLines] = useState<string[]>([])
const [enabled, setEnabled] = useState(true)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(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<HTMLDivElement>(null)
const inFlight = useRef(false)
const load = useCallback(async () => {
setLoading(true)
setError(null)
try {
const r = await fetchApi<ConsoleLogResponse>(
`/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<ConsoleLogResponse>(
`/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 (
<div className="h-full flex flex-col min-h-0">
<Card className="border border-border bg-card/50 flex flex-col flex-1 min-h-0">
<CardContent className="p-4 flex flex-col flex-1 min-h-0">
<div className="flex items-center justify-between mb-2 gap-2 flex-wrap shrink-0">
<div className="flex items-center gap-2">
<div className="p-1.5 rounded-md bg-sky-500/10">
<FileText className="h-4 w-4 text-sky-500" />
</div>
<h3 className="text-sm font-semibold text-foreground">{t("vmLxc.consoleLog.title")}</h3>
{enabled && shown.length > 0 && (
<Badge variant="secondary" className="text-xs h-5 ml-1">{shown.length}</Badge>
)}
</div>
{enabled && (
<div className="flex items-center gap-2 flex-wrap">
<Select value={sinceStart ? "start" : "all"} onValueChange={(v) => setSinceStart(v === "start")}>
<SelectTrigger className="h-7 w-auto text-xs gap-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="start" className="text-xs">{t("vmLxc.consoleLog.sinceStart")}</SelectItem>
<SelectItem value="all" className="text-xs">{t("vmLxc.consoleLog.allHistory")}</SelectItem>
</SelectContent>
</Select>
<Select value={String(lineCount)} onValueChange={(v) => setLineCount(Number(v))}>
<SelectTrigger className="h-7 w-auto text-xs gap-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{LINE_OPTIONS.map((n) => (
<SelectItem key={n} value={String(n)} className="text-xs">
{t("vmLxc.consoleLog.lastLines", { count: n })}
</SelectItem>
))}
</SelectContent>
</Select>
{running && (
<Button
size="sm"
variant="outline"
className="h-7 text-xs gap-1"
onClick={() => setFollow((f) => !f)}
aria-pressed={follow}
>
{follow ? <Pause className="h-3 w-3" /> : <Play className="h-3 w-3" />}
<span>{follow ? t("vmLxc.consoleLog.pause") : t("vmLxc.consoleLog.follow")}</span>
</Button>
)}
<Button size="sm" variant="outline" className="h-7 text-xs gap-1" onClick={load} disabled={loading}>
{loading ? <Loader2 className="h-3 w-3 animate-spin" /> : <RefreshCw className="h-3 w-3" />}
<span>{t("vmLxc.consoleLog.refresh")}</span>
</Button>
<Button
size="sm"
variant="outline"
className="h-7 text-xs gap-1"
onClick={download}
disabled={lines.length === 0}
>
<Download className="h-3 w-3" />
<span>{t("vmLxc.consoleLog.download")}</span>
</Button>
</div>
)}
</div>
<p className="text-xs text-muted-foreground mb-3 shrink-0">
{t("vmLxc.consoleLog.description")}
{enabled && !running && <> {t("vmLxc.consoleLog.stopped")}</>}
</p>
{enabled && (
<Input
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder={t("vmLxc.consoleLog.filter")}
className="h-8 text-xs mb-3 shrink-0"
/>
)}
{loading && lines.length === 0 ? (
<div className="flex-1 flex items-center justify-center text-muted-foreground min-h-0">
<Loader2 className="h-4 w-4 animate-spin mr-2" />
<span className="text-sm">{t("vmLxc.consoleLog.loading")}</span>
</div>
) : error ? (
<div className="rounded-md border border-red-500/30 bg-red-500/5 p-4 text-sm shrink-0">
<div className="flex items-start gap-2">
<AlertCircle className="h-4 w-4 text-red-500 shrink-0 mt-0.5" />
<div>
<p className="font-medium text-red-500 mb-1">{t("vmLxc.consoleLog.readFailed")}</p>
<p className="text-xs text-muted-foreground break-all">{error}</p>
</div>
</div>
</div>
) : !enabled ? (
<div className="rounded-md border border-amber-500/30 bg-amber-500/5 p-4 text-sm shrink-0">
<div className="flex items-start gap-2">
<FileText className="h-4 w-4 text-amber-500 shrink-0 mt-0.5" />
<div className="space-y-2">
<p className="font-medium text-amber-500">{t("vmLxc.consoleLog.notAvailableTitle")}</p>
<p className="text-xs text-muted-foreground leading-relaxed">{t("vmLxc.consoleLog.notAvailableHint")}</p>
</div>
</div>
</div>
) : lines.length === 0 ? (
<div className="flex-1 flex flex-col items-center justify-center text-sm text-muted-foreground min-h-0">
{t("vmLxc.consoleLog.empty")}
<div className="text-xs mt-1">{t("vmLxc.consoleLog.emptyHint")}</div>
</div>
) : shown.length === 0 ? (
<div className="flex-1 flex items-center justify-center text-sm text-muted-foreground min-h-0">
{t("vmLxc.consoleLog.noMatches")}
</div>
) : (
<div
ref={scroller}
onScroll={onScroll}
className="rounded-md border border-border bg-background/50 flex-1 overflow-y-auto min-h-0"
>
<pre className="text-[11px] font-mono leading-snug whitespace-pre-wrap break-all p-3">
{shown.map((line, idx) => {
const mark = START_MARK_RE.exec(line)
if (mark) {
const when = new Date(`${mark[1]}T${mark[2]}`)
return (
<div key={idx} className="my-1.5 flex items-center gap-2 text-sky-400/90 select-none">
<span className="h-px flex-1 bg-sky-500/30" />
<span>{t("vmLxc.consoleLog.startedAt", {
date: Number.isNaN(when.getTime()) ? `${mark[1]} ${mark[2]}` : when.toLocaleString(),
})}</span>
<span className="h-px flex-1 bg-sky-500/30" />
</div>
)
}
return <div key={idx} className={lineTone(line)}>{line || " "}</div>
})}
</pre>
</div>
)}
</CardContent>
</Card>
</div>
)
}