OCI catalog verification, console start marks and log cleanup

This commit is contained in:
MacRimi
2026-09-27 21:02:16 +02:00
parent 24617f9924
commit f3c4959fa4
34 changed files with 365 additions and 20 deletions
+44 -8
View File
@@ -24,12 +24,20 @@ interface ConsoleLogResponse {
inode: number | null inode: number | null
reset?: boolean reset?: boolean
head_truncated?: boolean head_truncated?: boolean
start_marks?: boolean
error?: string error?: string
} }
const LINE_OPTIONS = [100, 500, 1000] as const const LINE_OPTIONS = [100, 500, 1000] as const
const POLL_MS = 2000 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) => { const lineTone = (line: string) => {
if (/\b(error|fatal|panic|critical|exception)\b/i.test(line)) return "text-red-400" 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" 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 [error, setError] = useState<string | null>(null)
const [follow, setFollow] = useState(true) const [follow, setFollow] = useState(true)
const [filter, setFilter] = useState("") const [filter, setFilter] = useState("")
const [sinceStart, setSinceStart] = useState(true)
const position = useRef<{ offset: number; inode: number | null } | null>(null) const position = useRef<{ offset: number; inode: number | null } | null>(null)
const scroller = useRef<HTMLDivElement>(null) const scroller = useRef<HTMLDivElement>(null)
const inFlight = useRef(false) const inFlight = useRef(false)
@@ -53,7 +62,9 @@ export function OciConsoleLogPanel({ vmid, running }: { vmid: number; running: b
setLoading(true) setLoading(true)
setError(null) setError(null)
try { 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) setEnabled(r.enabled)
setLines(r.lines || []) setLines(r.lines || [])
position.current = { offset: r.offset, inode: r.inode } position.current = { offset: r.offset, inode: r.inode }
@@ -63,7 +74,7 @@ export function OciConsoleLogPanel({ vmid, running }: { vmid: number; running: b
} finally { } finally {
setLoading(false) setLoading(false)
} }
}, [vmid, lineCount]) }, [vmid, lineCount, sinceStart])
const poll = useCallback(async () => { const poll = useCallback(async () => {
const pos = position.current const pos = position.current
@@ -79,7 +90,10 @@ export function OciConsoleLogPanel({ vmid, running }: { vmid: number; running: b
setLines(r.lines || []) setLines(r.lines || [])
} else if (r.lines?.length) { } else if (r.lines?.length) {
setLines((prev) => { 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 return next.length > lineCount ? next.slice(next.length - lineCount) : next
}) })
} }
@@ -88,7 +102,7 @@ export function OciConsoleLogPanel({ vmid, running }: { vmid: number; running: b
} finally { } finally {
inFlight.current = false inFlight.current = false
} }
}, [vmid, lineCount]) }, [vmid, lineCount, sinceStart])
useEffect(() => { useEffect(() => {
load() load()
@@ -105,7 +119,7 @@ export function OciConsoleLogPanel({ vmid, running }: { vmid: number; running: b
const shown = useMemo(() => { const shown = useMemo(() => {
const needle = filter.trim().toLowerCase() 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]) }, [lines, filter])
useEffect(() => { useEffect(() => {
@@ -145,6 +159,15 @@ export function OciConsoleLogPanel({ vmid, running }: { vmid: number; running: b
</div> </div>
{enabled && ( {enabled && (
<div className="flex items-center gap-2 flex-wrap"> <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))}> <Select value={String(lineCount)} onValueChange={(v) => setLineCount(Number(v))}>
<SelectTrigger className="h-7 w-auto text-xs gap-1"> <SelectTrigger className="h-7 w-auto text-xs gap-1">
<SelectValue /> <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" 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"> <pre className="text-[11px] font-mono leading-snug whitespace-pre-wrap break-all p-3">
{shown.map((line, idx) => ( {shown.map((line, idx) => {
<div key={idx} className={lineTone(line)}>{line || " "}</div> 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> </pre>
</div> </div>
)} )}
+3
View File
@@ -1178,6 +1178,9 @@
"follow": "Folgen", "follow": "Folgen",
"pause": "Pausieren", "pause": "Pausieren",
"refresh": "Aktualisieren", "refresh": "Aktualisieren",
"sinceStart": "Seit dem letzten Start",
"allHistory": "Gesamter Verlauf",
"startedAt": "Container gestartet · {date}",
"download": "Herunterladen", "download": "Herunterladen",
"filter": "Zeilen filtern", "filter": "Zeilen filtern",
"loading": "Konsolenausgabe wird geladen...", "loading": "Konsolenausgabe wird geladen...",
+3
View File
@@ -1199,6 +1199,9 @@
"follow": "Follow", "follow": "Follow",
"pause": "Pause", "pause": "Pause",
"refresh": "Refresh", "refresh": "Refresh",
"sinceStart": "Since last start",
"allHistory": "Full history",
"startedAt": "Container started · {date}",
"download": "Download", "download": "Download",
"filter": "Filter lines", "filter": "Filter lines",
"loading": "Loading console output...", "loading": "Loading console output...",
+3
View File
@@ -1178,6 +1178,9 @@
"follow": "Seguir", "follow": "Seguir",
"pause": "Pausar", "pause": "Pausar",
"refresh": "Actualizar", "refresh": "Actualizar",
"sinceStart": "Desde el último arranque",
"allHistory": "Todo el historial",
"startedAt": "Contenedor iniciado · {date}",
"download": "Descargar", "download": "Descargar",
"filter": "Filtrar líneas", "filter": "Filtrar líneas",
"loading": "Cargando la salida de consola...", "loading": "Cargando la salida de consola...",
+3
View File
@@ -1178,6 +1178,9 @@
"follow": "Suivre", "follow": "Suivre",
"pause": "Pause", "pause": "Pause",
"refresh": "Actualiser", "refresh": "Actualiser",
"sinceStart": "Depuis le dernier démarrage",
"allHistory": "Historique complet",
"startedAt": "Conteneur démarré · {date}",
"download": "Télécharger", "download": "Télécharger",
"filter": "Filtrer les lignes", "filter": "Filtrer les lignes",
"loading": "Chargement de la sortie console...", "loading": "Chargement de la sortie console...",
+3
View File
@@ -1178,6 +1178,9 @@
"follow": "Segui", "follow": "Segui",
"pause": "Pausa", "pause": "Pausa",
"refresh": "Aggiorna", "refresh": "Aggiorna",
"sinceStart": "Dall'ultimo avvio",
"allHistory": "Cronologia completa",
"startedAt": "Container avviato · {date}",
"download": "Scarica", "download": "Scarica",
"filter": "Filtra righe", "filter": "Filtra righe",
"loading": "Caricamento dell'output della console...", "loading": "Caricamento dell'output della console...",
+3
View File
@@ -1178,6 +1178,9 @@
"follow": "Seguir", "follow": "Seguir",
"pause": "Pausar", "pause": "Pausar",
"refresh": "Atualizar", "refresh": "Atualizar",
"sinceStart": "Desde a última inicialização",
"allHistory": "Histórico completo",
"startedAt": "Contêiner iniciado · {date}",
"download": "Transferir", "download": "Transferir",
"filter": "Filtrar linhas", "filter": "Filtrar linhas",
"loading": "A carregar a saída da consola...", "loading": "A carregar a saída da consola...",
+3
View File
@@ -1199,6 +1199,9 @@
"follow": "Sledovať", "follow": "Sledovať",
"pause": "Pozastaviť", "pause": "Pozastaviť",
"refresh": "Obnoviť", "refresh": "Obnoviť",
"sinceStart": "Od posledného spustenia",
"allHistory": "Celá história",
"startedAt": "Kontajner spustený · {date}",
"download": "Stiahnuť", "download": "Stiahnuť",
"filter": "Filtrovať riadky", "filter": "Filtrovať riadky",
"loading": "Načítava sa výstup konzoly...", "loading": "Načítava sa výstup konzoly...",
+3
View File
@@ -1178,6 +1178,9 @@
"follow": "Följ", "follow": "Följ",
"pause": "Pausa", "pause": "Pausa",
"refresh": "Uppdatera", "refresh": "Uppdatera",
"sinceStart": "Sedan senaste start",
"allHistory": "Hela historiken",
"startedAt": "Containern startade · {date}",
"download": "Ladda ned", "download": "Ladda ned",
"filter": "Filtrera rader", "filter": "Filtrera rader",
"loading": "Läser in konsolutdata...", "loading": "Läser in konsolutdata...",
+37 -1
View File
@@ -733,6 +733,36 @@ def _lxc_isolated_ips(vmid):
return isolated 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): def get_lxc_ip_from_lxc_info(vmid):
"""Get LXC IP addresses using lxc-info command (for DHCP containers) """Get LXC IP addresses using lxc-info command (for DHCP containers)
Returns a dict with all IPs and classification""" 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) isolated = _lxc_isolated_ips(vmid)
if isolated: if isolated:
real_ips.sort(key=lambda ip: ip in 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 { return {
'all_ips': ips, 'all_ips': ips,
@@ -15349,7 +15383,9 @@ def api_lxc_console_log(vmid):
lines = request.args.get('lines', default=200, type=int) lines = request.args.get('lines', default=200, type=int)
offset = request.args.get('offset', type=int) offset = request.args.get('offset', type=int)
inode = request.args.get('inode', 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: except Exception as e:
return jsonify({"ok": False, "error": str(e)}), 500 return jsonify({"ok": False, "error": str(e)}), 500
+14 -6
View File
@@ -78,7 +78,11 @@ def clean(text: str) -> list[str]:
return lines 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) start = max(0, size - TAIL_READ_LIMIT)
handle.seek(start) handle.seek(start)
data = handle.read(size - start).decode("utf-8", errors="replace") 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 result = result[1:] # the first line was cut by the read window
if result and result[-1] == "": if result and result[-1] == "":
result = result[:-1] result = result[:-1]
head_cut = start > 0 or len(result) > lines marks = [index for index, line in enumerate(result) if line.startswith(START_MARK)]
return result[-lines:], head_cut 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`. """The last `lines` lines, or what was appended after `offset`.
`lines=0` reads nothing: it only says whether the container has a `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: if offset is None or replaced or offset > size:
# First open, or the file the viewer followed is gone: rotated by # First open, or the file the viewer followed is gone: rotated by
# copytruncate, or recreated with the container. # 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, 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) handle.seek(offset)
chunk = handle.read(min(size - offset, FOLLOW_READ_LIMIT)) chunk = handle.read(min(size - offset, FOLLOW_READ_LIMIT))
# Only whole lines are returned; an unfinished last line is left for the # Only whole lines are returned; an unfinished last line is left for the
+1
View File
@@ -1,4 +1,5 @@
{ {
"Tested by the community": "Von der Community getestet",
"# Alternative if you prefer screen": "# Alternative, wenn Sie den Bildschirm bevorzugen", "# Alternative if you prefer screen": "# Alternative, wenn Sie den Bildschirm bevorzugen",
"# Recommended: avoids disconnection during upgrade": "# Empfohlen: Verhindert Verbindungsabbrüche während des Upgrades", "# Recommended: avoids disconnection during upgrade": "# Empfohlen: Verhindert Verbindungsabbrüche während des Upgrades",
"(Checked entries will be removed. Uncheck to keep in VM.)": "(Überprüfte Einträge werden entfernt. Deaktivieren Sie die Markierung, um sie in VM zu behalten.)", "(Checked entries will be removed. Uncheck to keep in VM.)": "(Überprüfte Einträge werden entfernt. Deaktivieren Sie die Markierung, um sie in VM zu behalten.)",
+1
View File
@@ -1,4 +1,5 @@
{ {
"Tested by the community": "Probado por la comunidad",
"# Alternative if you prefer screen": "# Alternativa si prefieres la pantalla", "# Alternative if you prefer screen": "# Alternativa si prefieres la pantalla",
"# Recommended: avoids disconnection during upgrade": "# Recomendado: evita la desconexión durante la actualización", "# Recommended: avoids disconnection during upgrade": "# Recomendado: evita la desconexión durante la actualización",
"(Checked entries will be removed. Uncheck to keep in VM.)": "(Las entradas marcadas se eliminarán. Desmarque para mantener en VM).", "(Checked entries will be removed. Uncheck to keep in VM.)": "(Las entradas marcadas se eliminarán. Desmarque para mantener en VM).",
+1
View File
@@ -1,4 +1,5 @@
{ {
"Tested by the community": "Testé par la communauté",
"# Alternative if you prefer screen": "# Alternative si vous préférez l'écran", "# Alternative if you prefer screen": "# Alternative si vous préférez l'écran",
"# Recommended: avoids disconnection during upgrade": "# Recommandé : évite la déconnexion lors de la mise à niveau", "# Recommended: avoids disconnection during upgrade": "# Recommandé : évite la déconnexion lors de la mise à niveau",
"(Checked entries will be removed. Uncheck to keep in VM.)": "(Les entrées cochées seront supprimées. Décochez pour conserver dans la VM.)", "(Checked entries will be removed. Uncheck to keep in VM.)": "(Les entrées cochées seront supprimées. Décochez pour conserver dans la VM.)",
+1
View File
@@ -1,4 +1,5 @@
{ {
"Tested by the community": "Testato dalla comunità",
"# Alternative if you prefer screen": "# Alternativa se preferisci lo schermo", "# Alternative if you prefer screen": "# Alternativa se preferisci lo schermo",
"# Recommended: avoids disconnection during upgrade": "# Consigliato: evita la disconnessione durante l'aggiornamento", "# Recommended: avoids disconnection during upgrade": "# Consigliato: evita la disconnessione durante l'aggiornamento",
"(Checked entries will be removed. Uncheck to keep in VM.)": "(Le voci selezionate verranno rimosse. Deseleziona per mantenerle nella VM.)", "(Checked entries will be removed. Uncheck to keep in VM.)": "(Le voci selezionate verranno rimosse. Deseleziona per mantenerle nella VM.)",
+1
View File
@@ -1,4 +1,5 @@
{ {
"Tested by the community": "Testado pela comunidade",
"# Alternative if you prefer screen": "# Alternativa se você preferir tela", "# Alternative if you prefer screen": "# Alternativa se você preferir tela",
"# Recommended: avoids disconnection during upgrade": "# Recomendado: evita desconexão durante a atualização", "# Recommended: avoids disconnection during upgrade": "# Recomendado: evita desconexão durante a atualização",
"(Checked entries will be removed. Uncheck to keep in VM.)": "(As entradas marcadas serão removidas. Desmarque para manter na VM.)", "(Checked entries will be removed. Uncheck to keep in VM.)": "(As entradas marcadas serão removidas. Desmarque para manter na VM.)",
+1
View File
@@ -1,4 +1,5 @@
{ {
"Tested by the community": "Otestované komunitou",
"# Alternative if you prefer screen": "# Alternatíva, ak preferujete screen", "# Alternative if you prefer screen": "# Alternatíva, ak preferujete screen",
"# Recommended: avoids disconnection during upgrade": "# Odporúčané: zabráni odpojeniu počas aktualizácie", "# Recommended: avoids disconnection during upgrade": "# Odporúčané: zabráni odpojeniu počas aktualizácie",
"(Checked entries will be removed. Uncheck to keep in VM.)": "(Označené položky sa odstránia. Zrušte označenie, ak ich chcete ponechať vo VM.)", "(Checked entries will be removed. Uncheck to keep in VM.)": "(Označené položky sa odstránia. Zrušte označenie, ak ich chcete ponechať vo VM.)",
+1
View File
@@ -1,4 +1,5 @@
{ {
"Tested by the community": "Testad av communityn",
"# Alternative if you prefer screen": "# Alternativ om du föredrar skärm", "# Alternative if you prefer screen": "# Alternativ om du föredrar skärm",
"# Recommended: avoids disconnection during upgrade": "# Rekommenderas: undviker frånkoppling under uppgradering", "# Recommended: avoids disconnection during upgrade": "# Rekommenderas: undviker frånkoppling under uppgradering",
"(Checked entries will be removed. Uncheck to keep in VM.)": "(Markerade poster kommer att tas bort. Avmarkera för att behålla i VM.)", "(Checked entries will be removed. Uncheck to keep in VM.)": "(Markerade poster kommer att tas bort. Avmarkera för att behålla i VM.)",
+6
View File
@@ -227,6 +227,12 @@ Generated templates start as `generated-unvalidated`. Promotion requires:
5. Image replacement with persistent volumes preserved. 5. Image replacement with persistent volumes preserved.
6. Review of every platform adaptation and unsupported feature. 6. Review of every platform adaptation and unsupported feature.
Real tests are recorded in `catalog/verification.json`, which the catalog
applies on top of the generated templates and the overlays, so a regeneration
keeps them: `"status": "laboratory-validated"` for an application tested in
the ProxMenux lab, or `"community_tested": {"by": "<GitHub user>", "date":
"<YYYY-MM-DD>"}` for one tested by the community.
The mini changelog comes from the LinuxServer README `Versions` section. At The mini changelog comes from the LinuxServer README `Versions` section. At
installation, the architecture-specific registry digest and image labels are installation, the architecture-specific registry digest and image labels are
recorded back into the local app JSON for future update comparisons. recorded back into the local app JSON for future update comparisons.
+4
View File
@@ -506,5 +506,9 @@
}, },
"change_detection": "compare-compose-sha256-and-resolved-latest-image-digest", "change_detection": "compare-compose-sha256-and-resolved-latest-image-digest",
"automatic_unattended_updates": false "automatic_unattended_updates": false
},
"community_tested": {
"by": "Vaso73",
"date": "2026-09-27"
} }
} }
+4
View File
@@ -396,5 +396,9 @@
}, },
"change_detection": "compare-compose-sha256-and-resolved-latest-image-digest", "change_detection": "compare-compose-sha256-and-resolved-latest-image-digest",
"automatic_unattended_updates": false "automatic_unattended_updates": false
},
"community_tested": {
"by": "Vaso73",
"date": "2026-09-27"
} }
} }
+8
View File
@@ -3644,6 +3644,10 @@
"category_label": "Monitoring & Analytics", "category_label": "Monitoring & Analytics",
"template": "apps/glances.json", "template": "apps/glances.json",
"template_status": "generated-unvalidated", "template_status": "generated-unvalidated",
"community_tested": {
"by": "Vaso73",
"date": "2026-09-27"
},
"automatic_install_candidate": true, "automatic_install_candidate": true,
"untranslated_blockers": [], "untranslated_blockers": [],
"requires_privileged_lxc": true, "requires_privileged_lxc": true,
@@ -8548,6 +8552,10 @@
"category_label": "Databases", "category_label": "Databases",
"template": "apps/pocketbase.json", "template": "apps/pocketbase.json",
"template_status": "generated-unvalidated", "template_status": "generated-unvalidated",
"community_tested": {
"by": "Vaso73",
"date": "2026-09-27"
},
"automatic_install_candidate": true, "automatic_install_candidate": true,
"untranslated_blockers": [], "untranslated_blockers": [],
"requires_privileged_lxc": false, "requires_privileged_lxc": false,
+125
View File
@@ -0,0 +1,125 @@
{
"_comment": "Applications tested for real. \"status\": \"laboratory-validated\" when tested in the ProxMenux lab; \"community_tested\" with the tester's GitHub user and the date when tested by the community. Applied on top of the generated templates and the overlays, so a catalog regeneration keeps it.",
"applications": {
"2fauth": {
"status": "laboratory-validated"
},
"adguardhome-sync": {
"status": "laboratory-validated"
},
"albyhub": {
"status": "laboratory-validated"
},
"alist": {
"status": "laboratory-validated"
},
"amule": {
"status": "laboratory-validated"
},
"codeproject-ai": {
"status": "laboratory-validated"
},
"copyparty": {
"status": "laboratory-validated"
},
"crafty": {
"status": "laboratory-validated"
},
"ddclient": {
"status": "laboratory-validated"
},
"duplicati": {
"status": "laboratory-validated"
},
"etherpad": {
"status": "laboratory-validated"
},
"filebrowser-quantum": {
"status": "laboratory-validated"
},
"flaresolverr": {
"status": "laboratory-validated"
},
"flexget": {
"status": "laboratory-validated"
},
"frigate": {
"status": "laboratory-validated"
},
"glances": {
"community_tested": {
"by": "Vaso73",
"date": "2026-09-27"
}
},
"grafana": {
"status": "laboratory-validated"
},
"immich": {
"status": "laboratory-validated"
},
"jdownloader": {
"status": "laboratory-validated"
},
"jenkins": {
"status": "laboratory-validated"
},
"linkwarden": {
"status": "laboratory-validated"
},
"memos": {
"status": "laboratory-validated"
},
"mineos-node": {
"status": "laboratory-validated"
},
"motioneye": {
"status": "laboratory-validated"
},
"nextcloud": {
"status": "laboratory-validated"
},
"openlist": {
"status": "laboratory-validated"
},
"openssh-server": {
"status": "laboratory-validated"
},
"paperless-ngx": {
"status": "laboratory-validated"
},
"phpmyadmin": {
"status": "laboratory-validated"
},
"pocketbase": {
"community_tested": {
"by": "Vaso73",
"date": "2026-09-27"
}
},
"qbittorrent": {
"status": "laboratory-validated"
},
"rclone": {
"status": "laboratory-validated"
},
"rdtclient": {
"status": "laboratory-validated"
},
"snapotter": {
"status": "laboratory-validated"
},
"thelounge": {
"status": "laboratory-validated"
},
"trilium": {
"status": "laboratory-validated"
},
"wg-easy": {
"status": "laboratory-validated"
},
"wireguard": {
"status": "laboratory-validated"
}
}
}
+22 -2
View File
@@ -25,6 +25,17 @@ import sys
NO_LOGIN = ('nologin', 'false') NO_LOGIN = ('nologin', 'false')
LOG_DIR = Path('/var/log/proxmenux/oci') LOG_DIR = Path('/var/log/proxmenux/oci')
# Each start is marked in the console log by a pre-start hook. The hook runs
# the script only when it exists and always succeeds: a hook that fails would
# stop the container from starting.
START_MARK_SCRIPT = Path(__file__).resolve().with_name('oci_console_mark.sh')
START_MARK = '=== ProxMenux: container started '
def start_mark_hook(vmid: int) -> str:
script = START_MARK_SCRIPT
# `test`, not `[`: a bracket in the configuration reads as a snapshot section.
return f"lxc.hook.pre-start: /bin/sh -c 'test -x {script} && {script} {int(vmid)}; exit 0'"
LOGROTATE = Path('/etc/logrotate.d/proxmenux-oci') LOGROTATE = Path('/etc/logrotate.d/proxmenux-oci')
# copytruncate, because liblxc keeps the file open for as long as the # copytruncate, because liblxc keeps the file open for as long as the
# container runs; moving it away would leave the application writing into the # container runs; moving it away would leave the application writing into the
@@ -165,8 +176,9 @@ def enable_log(vmid: int) -> Path:
text = conf.read_text() text = conf.read_text()
current, _, snapshots = text.partition('\n[') current, _, snapshots = text.partition('\n[')
wanted = f'lxc.console.logfile: {path}' wanted = f'lxc.console.logfile: {path}'
kept = [line for line in current.splitlines() if not line.startswith('lxc.console.logfile:')] kept = [line for line in current.splitlines()
kept.append(wanted) if not line.startswith('lxc.console.logfile:') and START_MARK_SCRIPT.name not in line]
kept += [wanted, start_mark_hook(vmid)]
rebuilt = '\n'.join(kept) + '\n' rebuilt = '\n'.join(kept) + '\n'
if snapshots: if snapshots:
rebuilt += '\n[' + snapshots rebuilt += '\n[' + snapshots
@@ -176,6 +188,14 @@ def enable_log(vmid: int) -> Path:
return path return path
def remove_log(vmid: int) -> None:
"""Delete the console log of a removed container and its rotated copies."""
base = log_path(vmid)
for path in [base, *LOG_DIR.glob(f'{base.name}.*')]:
if path.is_file() and not path.is_symlink():
path.unlink()
def configure(vmid: int) -> dict: def configure(vmid: int) -> dict:
"""Console log and Proxmox terminal of a container being created.""" """Console log and Proxmox terminal of a container being created."""
path = enable_log(vmid) path = enable_log(vmid)
+9
View File
@@ -0,0 +1,9 @@
#!/bin/sh
# Marks each start of an OCI container in its console log, so the log can be
# read from the last start. Run by the container's lxc.hook.pre-start.
case "$1" in
''|*[!0-9]*) exit 0 ;;
esac
printf '\n=== ProxMenux: container started %s ===\n' "$(date '+%Y-%m-%d %H:%M:%S')" \
>> "/var/log/proxmenux/oci/$1.console.log" 2>/dev/null
exit 0
+3 -1
View File
@@ -44,7 +44,9 @@ BASIC = {'arch', 'cmode', 'console', 'tty', 'cores', 'cpulimit', 'cpuunits', 'de
'lxc.signal.halt', 'lxc.environment.runtime', 'lxc.signal.halt', 'lxc.environment.runtime',
# The container's console log, set by the installer on every # The container's console log, set by the installer on every
# creation; the rebuilt container gets it again the same way. # creation; the rebuilt container gets it again the same way.
'lxc.console.logfile'} 'lxc.console.logfile',
# The hook that marks each start in that log, set the same way.
'lxc.hook.pre-start'}
# Their output is data (and may hold saved secrets); it is never logged. # Their output is data (and may hold saved secrets); it is never logged.
DATA_COMMANDS = {('pct', 'config'), ('pvesh', 'get')} DATA_COMMANDS = {('pct', 'config'), ('pvesh', 'get')}
LOG_DIR = Path(os.environ.get('OCI_LOG_DIR', '/var/log/proxmenux/oci')) LOG_DIR = Path(os.environ.get('OCI_LOG_DIR', '/var/log/proxmenux/oci'))
+3
View File
@@ -18,6 +18,7 @@ import sys
import oci_image_cache as image_cache import oci_image_cache as image_cache
import oci_instances as instances import oci_instances as instances
from oci_installation_state import parse_config from oci_installation_state import parse_config
import oci_console
from oci_ui import translate, msg_info, msg_ok, msg_warn, msg_error from oci_ui import translate, msg_info, msg_ok, msg_warn, msg_error
# The private networks ProxMenux creates for multi-container applications. # The private networks ProxMenux creates for multi-container applications.
@@ -118,11 +119,13 @@ def remove(root, vmid):
config = guest_config(member) config = guest_config(member)
if config is None: if config is None:
msg_warn(f"{translate('The container no longer exists:')} CT {member}") msg_warn(f"{translate('The container no longer exists:')} CT {member}")
oci_console.remove_log(member)
elif instances.identity(config) != record['installation_id']: elif instances.identity(config) != record['installation_id']:
msg_warn(f"{translate('The VMID belongs to another container now and is not touched:')} CT {member}") msg_warn(f"{translate('The VMID belongs to another container now and is not touched:')} CT {member}")
else: else:
subprocess.run(['pct', 'stop', str(member), '--skiplock', '1'], check=False, capture_output=True) subprocess.run(['pct', 'stop', str(member), '--skiplock', '1'], check=False, capture_output=True)
run('pct', 'destroy', str(member), '--purge', '1', '--destroy-unreferenced-disks', '1') run('pct', 'destroy', str(member), '--purge', '1', '--destroy-unreferenced-disks', '1')
oci_console.remove_log(member)
msg_ok(f"{translate('Container removed:')} CT {member}") msg_ok(f"{translate('Container removed:')} CT {member}")
if bridge and not bridge_in_use(bridge, set(members)): if bridge and not bridge_in_use(bridge, set(members)):
release_bridge(bridge) release_bridge(bridge)
+9
View File
@@ -23,6 +23,15 @@
"status": { "status": {
"enum": ["generated-unvalidated", "generated-review-required", "laboratory-validated", "stable"] "enum": ["generated-unvalidated", "generated-review-required", "laboratory-validated", "stable"]
}, },
"community_tested": {
"type": "object",
"required": ["by"],
"additionalProperties": false,
"properties": {
"by": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9-]{0,38}$"},
"date": {"type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"}
}
},
"catalog_ui": { "catalog_ui": {
"type": "object", "type": "object",
"required": ["title", "description", "category", "architectures", "launch", "mini_changelog"], "required": ["title", "description", "category", "architectures", "launch", "mini_changelog"],
+29
View File
@@ -414,6 +414,7 @@ class Catalog:
ui = template["catalog_ui"] ui = template["catalog_ui"]
item["hidden"] = overlay_ui.get("hidden", ui.get("hidden", False)) item["hidden"] = overlay_ui.get("hidden", ui.get("hidden", False))
item["template_status"] = template["status"] item["template_status"] = template["status"]
self._index_community_tested(item, template)
item["automatic_install_candidate"] = compatibility[ item["automatic_install_candidate"] = compatibility[
"automatic_install_candidate" "automatic_install_candidate"
] ]
@@ -639,6 +640,7 @@ class Catalog:
item = index_items[app_id] item = index_items[app_id]
item["template"] = f"apps/{app_id}.json" item["template"] = f"apps/{app_id}.json"
item["template_status"] = template["status"] item["template_status"] = template["status"]
self._index_community_tested(item, template)
item["automatic_install_candidate"] = template["compatibility"][ item["automatic_install_candidate"] = template["compatibility"][
"automatic_install_candidate" "automatic_install_candidate"
] ]
@@ -792,6 +794,33 @@ class Catalog:
apply_stack_support(template) apply_stack_support(template)
from .gpu import apply_gpu_contract from .gpu import apply_gpu_contract
apply_gpu_contract(template) apply_gpu_contract(template)
# Last, so the stack compiler does not reset it.
self._apply_verification(app_id, template)
def _apply_verification(self, app_id: str, template: dict[str, Any]) -> None:
"""Real tests recorded in verification.json, kept across regenerations."""
path = self.catalog_dir / "verification.json"
if not path.exists():
return
entry = json.loads(path.read_text(encoding="utf-8")).get("applications", {}).get(app_id)
if not isinstance(entry, dict):
return
# An application that can no longer be installed is not shown as verified.
installable = template.get("compatibility", {}).get("automatic_install_candidate", False)
if entry.get("status") == "laboratory-validated" and installable:
template["status"] = "laboratory-validated"
if isinstance(entry.get("community_tested"), dict):
template["community_tested"] = dict(entry["community_tested"])
@staticmethod
def _index_community_tested(item: dict[str, Any], template: dict[str, Any]) -> None:
"""Who tested the application for real outside the ProxMenux lab, and
when, as recorded in its overlay."""
tested = template.get("community_tested")
if isinstance(tested, dict) and tested.get("by"):
item["community_tested"] = {"by": str(tested["by"]), "date": str(tested.get("date") or "")}
else:
item.pop("community_tested", None)
@classmethod @classmethod
def _deep_merge(cls, target: dict[str, Any], overlay: dict[str, Any]) -> None: def _deep_merge(cls, target: dict[str, Any], overlay: dict[str, Any]) -> None:
+7 -2
View File
@@ -167,8 +167,13 @@ def _app_detail_text(catalog: Catalog, item: dict[str, Any], template: dict[str,
lines.append(f"{label + ':':<17} {value}") lines.append(f"{label + ':':<17} {value}")
row(translate("Source"), publisher(item)) row(translate("Source"), publisher(item))
row(translate("Status"), translate("Verified by ProxMenux") if is_tested(item) # Only a real test is shown; OCI manager Apps is labelled beta as a whole.
else translate("Not yet verified by ProxMenux (beta)")) community = item.get("community_tested") or {}
if is_tested(item):
row(translate("Status"), translate("Verified by ProxMenux"))
elif community.get("by"):
who = " · ".join(part for part in (f"@{community['by']}", community.get("date")) if part)
row(translate("Status"), f"{translate('Tested by the community')} ({who})")
row(translate("Architectures"), _display_architectures(ui)) row(translate("Architectures"), _display_architectures(ui))
endpoints = template.get("first_run", {}).get("endpoints", []) endpoints = template.get("first_run", {}).get("endpoints", [])
if endpoints: if endpoints:
@@ -55,6 +55,9 @@
}, },
{ {
"p": "Selecting an application shows its description, the image it runs and two installation modes: <strong>Install with default settings</strong>, which asks almost nothing, and <strong>Install with advanced settings</strong>, which offers the real storage, bridge and resource selectors of the node." "p": "Selecting an application shows its description, the image it runs and two installation modes: <strong>Install with default settings</strong>, which asks almost nothing, and <strong>Install with advanced settings</strong>, which offers the real storage, bridge and resource selectors of the node."
},
{
"p": "An application that has been tested for real shows it in that view: <strong>Verified by ProxMenux</strong> when it was tested in the ProxMenux lab, marked with ✓ in the lists, or <strong>Tested by the community</strong>, with who tested it and when."
} }
] ]
}, },
@@ -150,6 +150,8 @@
"items": [ "items": [
"The output is kept on the host in <code>/var/log/proxmenux/oci/VMID.console.log</code> (mode 0600), from the first start and across restarts, so it can be read with the container stopped.", "The output is kept on the host in <code>/var/log/proxmenux/oci/VMID.console.log</code> (mode 0600), from the first start and across restarts, so it can be read with the container stopped.",
"The file is rotated at 10 MB, keeping three compressed copies (<code>/etc/logrotate.d/proxmenux-oci</code>).", "The file is rotated at 10 MB, keeping three compressed copies (<code>/etc/logrotate.d/proxmenux-oci</code>).",
"Each start of the container is marked in the output. The tab shows the output since the last start by default; <strong>Full history</strong> also shows the previous ones.",
"When the application is removed, its output is removed with it.",
"The last 100, 500 or 1000 lines are shown. While the container runs, new lines are followed live; scrolling up pauses the follow, and <strong>Follow</strong> resumes it.", "The last 100, 500 or 1000 lines are shown. While the container runs, new lines are followed live; scrolling up pauses the follow, and <strong>Follow</strong> resumes it.",
"A filter shows only the lines that contain a text, and <strong>Download</strong> saves the lines loaded.", "A filter shows only the lines that contain a text, and <strong>Download</strong> saves the lines loaded.",
"Colour codes are removed and a line that a progress bar redraws is shown in its final state.", "Colour codes are removed and a line that a progress bar redraws is shown in its final state.",
@@ -55,6 +55,9 @@
}, },
{ {
"p": "Al seleccionar una aplicación se muestra su descripción, la imagen que ejecuta y dos modos de instalación: <strong>Instalar con la configuración predeterminada</strong>, que apenas hace preguntas, e <strong>Instalar con la configuración avanzada</strong>, que ofrece los selectores reales de almacenamiento, bridge y recursos del nodo." "p": "Al seleccionar una aplicación se muestra su descripción, la imagen que ejecuta y dos modos de instalación: <strong>Instalar con la configuración predeterminada</strong>, que apenas hace preguntas, e <strong>Instalar con la configuración avanzada</strong>, que ofrece los selectores reales de almacenamiento, bridge y recursos del nodo."
},
{
"p": "Una aplicación probada en real lo indica en esa vista: <strong>Verificado por ProxMenux</strong> si se probó en el laboratorio de ProxMenux, marcada con ✓ en las listas, o <strong>Probado por la comunidad</strong>, con quién la probó y cuándo."
} }
] ]
}, },
@@ -150,6 +150,8 @@
"items": [ "items": [
"La salida se guarda en el host en <code>/var/log/proxmenux/oci/VMID.console.log</code> (modo 0600), desde el primer arranque y entre reinicios, así que puede leerse con el contenedor detenido.", "La salida se guarda en el host en <code>/var/log/proxmenux/oci/VMID.console.log</code> (modo 0600), desde el primer arranque y entre reinicios, así que puede leerse con el contenedor detenido.",
"El archivo se rota al llegar a 10 MB y se conservan tres copias comprimidas (<code>/etc/logrotate.d/proxmenux-oci</code>).", "El archivo se rota al llegar a 10 MB y se conservan tres copias comprimidas (<code>/etc/logrotate.d/proxmenux-oci</code>).",
"Cada arranque del contenedor queda marcado en la salida. La pestaña muestra por defecto la salida desde el último arranque; <strong>Todo el historial</strong> muestra también los anteriores.",
"Al eliminar la aplicación, su salida se elimina con ella.",
"Se muestran las últimas 100, 500 o 1000 líneas. Con el contenedor en marcha, las líneas nuevas se siguen en directo; al desplazarse hacia arriba el seguimiento se pausa, y <strong>Seguir</strong> lo reanuda.", "Se muestran las últimas 100, 500 o 1000 líneas. Con el contenedor en marcha, las líneas nuevas se siguen en directo; al desplazarse hacia arriba el seguimiento se pausa, y <strong>Seguir</strong> lo reanuda.",
"Un filtro muestra solo las líneas que contienen un texto, y <strong>Descargar</strong> guarda las líneas cargadas.", "Un filtro muestra solo las líneas que contienen un texto, y <strong>Descargar</strong> guarda las líneas cargadas.",
"Los códigos de color se eliminan y una línea que redibuja una barra de progreso se muestra en su estado final.", "Los códigos de color se eliminan y una línea que redibuja una barra de progreso se muestra en su estado final.",