mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-10-06 13:36:42 +00:00
OCI catalog verification, console start marks and log cleanup
This commit is contained in:
@@ -24,12 +24,20 @@ interface ConsoleLogResponse {
|
||||
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"
|
||||
@@ -45,6 +53,7 @@ export function OciConsoleLogPanel({ vmid, running }: { vmid: number; running: b
|
||||
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)
|
||||
@@ -53,7 +62,9 @@ export function OciConsoleLogPanel({ vmid, running }: { vmid: number; running: b
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const r = await fetchApi<ConsoleLogResponse>(`/api/lxc/${vmid}/console-log?lines=${lineCount}`)
|
||||
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 }
|
||||
@@ -63,7 +74,7 @@ export function OciConsoleLogPanel({ vmid, running }: { vmid: number; running: b
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [vmid, lineCount])
|
||||
}, [vmid, lineCount, sinceStart])
|
||||
|
||||
const poll = useCallback(async () => {
|
||||
const pos = position.current
|
||||
@@ -79,7 +90,10 @@ export function OciConsoleLogPanel({ vmid, running }: { vmid: number; running: b
|
||||
setLines(r.lines || [])
|
||||
} else if (r.lines?.length) {
|
||||
setLines((prev) => {
|
||||
const next = prev.concat(r.lines)
|
||||
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
|
||||
})
|
||||
}
|
||||
@@ -88,7 +102,7 @@ export function OciConsoleLogPanel({ vmid, running }: { vmid: number; running: b
|
||||
} finally {
|
||||
inFlight.current = false
|
||||
}
|
||||
}, [vmid, lineCount])
|
||||
}, [vmid, lineCount, sinceStart])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
@@ -105,7 +119,7 @@ export function OciConsoleLogPanel({ vmid, running }: { vmid: number; running: b
|
||||
|
||||
const shown = useMemo(() => {
|
||||
const needle = filter.trim().toLowerCase()
|
||||
return needle ? lines.filter((l) => l.toLowerCase().includes(needle)) : lines
|
||||
return needle ? lines.filter((l) => START_MARK_RE.test(l) || l.toLowerCase().includes(needle)) : lines
|
||||
}, [lines, filter])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -145,6 +159,15 @@ export function OciConsoleLogPanel({ vmid, running }: { vmid: number; running: b
|
||||
</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 />
|
||||
@@ -242,9 +265,22 @@ export function OciConsoleLogPanel({ vmid, running }: { vmid: number; running: b
|
||||
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) => (
|
||||
<div key={idx} className={lineTone(line)}>{line || " "}</div>
|
||||
))}
|
||||
{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>
|
||||
)}
|
||||
|
||||
@@ -1178,6 +1178,9 @@
|
||||
"follow": "Folgen",
|
||||
"pause": "Pausieren",
|
||||
"refresh": "Aktualisieren",
|
||||
"sinceStart": "Seit dem letzten Start",
|
||||
"allHistory": "Gesamter Verlauf",
|
||||
"startedAt": "Container gestartet · {date}",
|
||||
"download": "Herunterladen",
|
||||
"filter": "Zeilen filtern",
|
||||
"loading": "Konsolenausgabe wird geladen...",
|
||||
|
||||
@@ -1199,6 +1199,9 @@
|
||||
"follow": "Follow",
|
||||
"pause": "Pause",
|
||||
"refresh": "Refresh",
|
||||
"sinceStart": "Since last start",
|
||||
"allHistory": "Full history",
|
||||
"startedAt": "Container started · {date}",
|
||||
"download": "Download",
|
||||
"filter": "Filter lines",
|
||||
"loading": "Loading console output...",
|
||||
|
||||
@@ -1178,6 +1178,9 @@
|
||||
"follow": "Seguir",
|
||||
"pause": "Pausar",
|
||||
"refresh": "Actualizar",
|
||||
"sinceStart": "Desde el último arranque",
|
||||
"allHistory": "Todo el historial",
|
||||
"startedAt": "Contenedor iniciado · {date}",
|
||||
"download": "Descargar",
|
||||
"filter": "Filtrar líneas",
|
||||
"loading": "Cargando la salida de consola...",
|
||||
|
||||
@@ -1178,6 +1178,9 @@
|
||||
"follow": "Suivre",
|
||||
"pause": "Pause",
|
||||
"refresh": "Actualiser",
|
||||
"sinceStart": "Depuis le dernier démarrage",
|
||||
"allHistory": "Historique complet",
|
||||
"startedAt": "Conteneur démarré · {date}",
|
||||
"download": "Télécharger",
|
||||
"filter": "Filtrer les lignes",
|
||||
"loading": "Chargement de la sortie console...",
|
||||
|
||||
@@ -1178,6 +1178,9 @@
|
||||
"follow": "Segui",
|
||||
"pause": "Pausa",
|
||||
"refresh": "Aggiorna",
|
||||
"sinceStart": "Dall'ultimo avvio",
|
||||
"allHistory": "Cronologia completa",
|
||||
"startedAt": "Container avviato · {date}",
|
||||
"download": "Scarica",
|
||||
"filter": "Filtra righe",
|
||||
"loading": "Caricamento dell'output della console...",
|
||||
|
||||
@@ -1178,6 +1178,9 @@
|
||||
"follow": "Seguir",
|
||||
"pause": "Pausar",
|
||||
"refresh": "Atualizar",
|
||||
"sinceStart": "Desde a última inicialização",
|
||||
"allHistory": "Histórico completo",
|
||||
"startedAt": "Contêiner iniciado · {date}",
|
||||
"download": "Transferir",
|
||||
"filter": "Filtrar linhas",
|
||||
"loading": "A carregar a saída da consola...",
|
||||
|
||||
@@ -1199,6 +1199,9 @@
|
||||
"follow": "Sledovať",
|
||||
"pause": "Pozastaviť",
|
||||
"refresh": "Obnoviť",
|
||||
"sinceStart": "Od posledného spustenia",
|
||||
"allHistory": "Celá história",
|
||||
"startedAt": "Kontajner spustený · {date}",
|
||||
"download": "Stiahnuť",
|
||||
"filter": "Filtrovať riadky",
|
||||
"loading": "Načítava sa výstup konzoly...",
|
||||
|
||||
@@ -1178,6 +1178,9 @@
|
||||
"follow": "Följ",
|
||||
"pause": "Pausa",
|
||||
"refresh": "Uppdatera",
|
||||
"sinceStart": "Sedan senaste start",
|
||||
"allHistory": "Hela historiken",
|
||||
"startedAt": "Containern startade · {date}",
|
||||
"download": "Ladda ned",
|
||||
"filter": "Filtrera rader",
|
||||
"loading": "Läser in konsolutdata...",
|
||||
|
||||
@@ -733,6 +733,36 @@ def _lxc_isolated_ips(vmid):
|
||||
return isolated
|
||||
|
||||
|
||||
def _lxc_shares_host_network(vmid):
|
||||
"""Whether the container uses the host's network namespace instead of its
|
||||
own, as a ProxMenux host-monitor (Glances, Netdata) does: lxc-info then
|
||||
lists every address of the host, private bridges included."""
|
||||
try:
|
||||
with open(f"/etc/pve/lxc/{int(vmid)}.conf", encoding="utf-8") as handle:
|
||||
text = handle.read().split("\n[", 1)[0]
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
return ("lxc.include: /etc/pve/lxc/proxmenux-host-monitor" in text
|
||||
or ("lxc.net.0.type: none" in text and not re.search(r"^net\d+:", text, re.M)))
|
||||
|
||||
|
||||
def _host_default_route_ipv4():
|
||||
"""The host's IPv4 address on the interface that carries its default
|
||||
route: the one a reader on the LAN reaches."""
|
||||
try:
|
||||
route = subprocess.run(['ip', '-4', 'route', 'show', 'default'],
|
||||
capture_output=True, text=True, timeout=3).stdout
|
||||
match = re.search(r"\bdev\s+(\S+)", route)
|
||||
if not match:
|
||||
return None
|
||||
addr = subprocess.run(['ip', '-4', '-o', 'addr', 'show', 'dev', match.group(1), 'scope', 'global'],
|
||||
capture_output=True, text=True, timeout=3).stdout
|
||||
found = re.search(r"\binet\s+([0-9.]+)/", addr)
|
||||
return found.group(1) if found else None
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return None
|
||||
|
||||
|
||||
def get_lxc_ip_from_lxc_info(vmid):
|
||||
"""Get LXC IP addresses using lxc-info command (for DHCP containers)
|
||||
Returns a dict with all IPs and classification"""
|
||||
@@ -763,6 +793,10 @@ def get_lxc_ip_from_lxc_info(vmid):
|
||||
isolated = _lxc_isolated_ips(vmid)
|
||||
if isolated:
|
||||
real_ips.sort(key=lambda ip: ip in isolated)
|
||||
if _lxc_shares_host_network(vmid):
|
||||
host_ip = _host_default_route_ipv4()
|
||||
if host_ip in real_ips:
|
||||
real_ips.sort(key=lambda ip: ip != host_ip)
|
||||
|
||||
return {
|
||||
'all_ips': ips,
|
||||
@@ -15349,7 +15383,9 @@ def api_lxc_console_log(vmid):
|
||||
lines = request.args.get('lines', default=200, type=int)
|
||||
offset = request.args.get('offset', type=int)
|
||||
inode = request.args.get('inode', type=int)
|
||||
return jsonify(oci_console_logs.read(vmid, lines=lines, offset=offset, inode=inode))
|
||||
since_start = request.args.get('since_start', '') in ('1', 'true')
|
||||
return jsonify(oci_console_logs.read(vmid, lines=lines, offset=offset, inode=inode,
|
||||
since_start=since_start))
|
||||
except Exception as e:
|
||||
return jsonify({"ok": False, "error": str(e)}), 500
|
||||
|
||||
|
||||
@@ -78,7 +78,11 @@ def clean(text: str) -> list[str]:
|
||||
return lines
|
||||
|
||||
|
||||
def _read_tail(handle, size: int, lines: int) -> tuple[list[str], bool]:
|
||||
# Written by the container's pre-start hook at every start (oci_console_mark.sh).
|
||||
START_MARK = "=== ProxMenux: container started "
|
||||
|
||||
|
||||
def _read_tail(handle, size: int, lines: int, since_start: bool = False) -> tuple[list[str], bool, bool]:
|
||||
start = max(0, size - TAIL_READ_LIMIT)
|
||||
handle.seek(start)
|
||||
data = handle.read(size - start).decode("utf-8", errors="replace")
|
||||
@@ -87,11 +91,15 @@ def _read_tail(handle, size: int, lines: int) -> tuple[list[str], bool]:
|
||||
result = result[1:] # the first line was cut by the read window
|
||||
if result and result[-1] == "":
|
||||
result = result[:-1]
|
||||
head_cut = start > 0 or len(result) > lines
|
||||
return result[-lines:], head_cut
|
||||
marks = [index for index, line in enumerate(result) if line.startswith(START_MARK)]
|
||||
if since_start and marks:
|
||||
result = result[marks[-1]:]
|
||||
head_cut = (start > 0 and not (since_start and marks)) or len(result) > lines
|
||||
return result[-lines:], head_cut, bool(marks)
|
||||
|
||||
|
||||
def read(vmid: int, lines: int = 200, offset: int | None = None, inode: int | None = None) -> dict:
|
||||
def read(vmid: int, lines: int = 200, offset: int | None = None, inode: int | None = None,
|
||||
since_start: bool = False) -> dict:
|
||||
"""The last `lines` lines, or what was appended after `offset`.
|
||||
|
||||
`lines=0` reads nothing: it only says whether the container has a
|
||||
@@ -115,9 +123,9 @@ def read(vmid: int, lines: int = 200, offset: int | None = None, inode: int | No
|
||||
if offset is None or replaced or offset > size:
|
||||
# First open, or the file the viewer followed is gone: rotated by
|
||||
# copytruncate, or recreated with the container.
|
||||
result, head_cut = _read_tail(handle, size, max(lines, 1))
|
||||
result, head_cut, has_marks = _read_tail(handle, size, max(lines, 1), since_start)
|
||||
return {**base, "lines": result, "offset": size, "reset": offset is not None,
|
||||
"head_truncated": head_cut}
|
||||
"head_truncated": head_cut, "start_marks": has_marks}
|
||||
handle.seek(offset)
|
||||
chunk = handle.read(min(size - offset, FOLLOW_READ_LIMIT))
|
||||
# Only whole lines are returned; an unfinished last line is left for the
|
||||
|
||||
Reference in New Issue
Block a user