mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-09-29 10:06:41 +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
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"Tested by the community": "Von der Community getestet",
|
||||
"# Alternative if you prefer screen": "# Alternative, wenn Sie den Bildschirm bevorzugen",
|
||||
"# 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.)",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"Tested by the community": "Probado por la comunidad",
|
||||
"# Alternative if you prefer screen": "# Alternativa si prefieres la pantalla",
|
||||
"# 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).",
|
||||
|
||||
@@ -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",
|
||||
"# 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.)",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"Tested by the community": "Testato dalla comunità",
|
||||
"# Alternative if you prefer screen": "# Alternativa se preferisci lo schermo",
|
||||
"# 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.)",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"Tested by the community": "Testado pela comunidade",
|
||||
"# Alternative if you prefer screen": "# Alternativa se você preferir tela",
|
||||
"# 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.)",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"Tested by the community": "Otestované komunitou",
|
||||
"# Alternative if you prefer screen": "# Alternatíva, ak preferujete screen",
|
||||
"# 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.)",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"Tested by the community": "Testad av communityn",
|
||||
"# Alternative if you prefer screen": "# Alternativ om du föredrar skärm",
|
||||
"# 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.)",
|
||||
|
||||
@@ -227,6 +227,12 @@ Generated templates start as `generated-unvalidated`. Promotion requires:
|
||||
5. Image replacement with persistent volumes preserved.
|
||||
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
|
||||
installation, the architecture-specific registry digest and image labels are
|
||||
recorded back into the local app JSON for future update comparisons.
|
||||
|
||||
@@ -506,5 +506,9 @@
|
||||
},
|
||||
"change_detection": "compare-compose-sha256-and-resolved-latest-image-digest",
|
||||
"automatic_unattended_updates": false
|
||||
},
|
||||
"community_tested": {
|
||||
"by": "Vaso73",
|
||||
"date": "2026-09-27"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -396,5 +396,9 @@
|
||||
},
|
||||
"change_detection": "compare-compose-sha256-and-resolved-latest-image-digest",
|
||||
"automatic_unattended_updates": false
|
||||
},
|
||||
"community_tested": {
|
||||
"by": "Vaso73",
|
||||
"date": "2026-09-27"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3644,6 +3644,10 @@
|
||||
"category_label": "Monitoring & Analytics",
|
||||
"template": "apps/glances.json",
|
||||
"template_status": "generated-unvalidated",
|
||||
"community_tested": {
|
||||
"by": "Vaso73",
|
||||
"date": "2026-09-27"
|
||||
},
|
||||
"automatic_install_candidate": true,
|
||||
"untranslated_blockers": [],
|
||||
"requires_privileged_lxc": true,
|
||||
@@ -8548,6 +8552,10 @@
|
||||
"category_label": "Databases",
|
||||
"template": "apps/pocketbase.json",
|
||||
"template_status": "generated-unvalidated",
|
||||
"community_tested": {
|
||||
"by": "Vaso73",
|
||||
"date": "2026-09-27"
|
||||
},
|
||||
"automatic_install_candidate": true,
|
||||
"untranslated_blockers": [],
|
||||
"requires_privileged_lxc": false,
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,17 @@ import sys
|
||||
|
||||
NO_LOGIN = ('nologin', 'false')
|
||||
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')
|
||||
# copytruncate, because liblxc keeps the file open for as long as 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()
|
||||
current, _, snapshots = text.partition('\n[')
|
||||
wanted = f'lxc.console.logfile: {path}'
|
||||
kept = [line for line in current.splitlines() if not line.startswith('lxc.console.logfile:')]
|
||||
kept.append(wanted)
|
||||
kept = [line for line in current.splitlines()
|
||||
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'
|
||||
if snapshots:
|
||||
rebuilt += '\n[' + snapshots
|
||||
@@ -176,6 +188,14 @@ def enable_log(vmid: int) -> 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:
|
||||
"""Console log and Proxmox terminal of a container being created."""
|
||||
path = enable_log(vmid)
|
||||
|
||||
Executable
+9
@@ -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
|
||||
@@ -44,7 +44,9 @@ BASIC = {'arch', 'cmode', 'console', 'tty', 'cores', 'cpulimit', 'cpuunits', 'de
|
||||
'lxc.signal.halt', 'lxc.environment.runtime',
|
||||
# The container's console log, set by the installer on every
|
||||
# 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.
|
||||
DATA_COMMANDS = {('pct', 'config'), ('pvesh', 'get')}
|
||||
LOG_DIR = Path(os.environ.get('OCI_LOG_DIR', '/var/log/proxmenux/oci'))
|
||||
|
||||
@@ -18,6 +18,7 @@ import sys
|
||||
import oci_image_cache as image_cache
|
||||
import oci_instances as instances
|
||||
from oci_installation_state import parse_config
|
||||
import oci_console
|
||||
from oci_ui import translate, msg_info, msg_ok, msg_warn, msg_error
|
||||
|
||||
# The private networks ProxMenux creates for multi-container applications.
|
||||
@@ -118,11 +119,13 @@ def remove(root, vmid):
|
||||
config = guest_config(member)
|
||||
if config is None:
|
||||
msg_warn(f"{translate('The container no longer exists:')} CT {member}")
|
||||
oci_console.remove_log(member)
|
||||
elif instances.identity(config) != record['installation_id']:
|
||||
msg_warn(f"{translate('The VMID belongs to another container now and is not touched:')} CT {member}")
|
||||
else:
|
||||
subprocess.run(['pct', 'stop', str(member), '--skiplock', '1'], check=False, capture_output=True)
|
||||
run('pct', 'destroy', str(member), '--purge', '1', '--destroy-unreferenced-disks', '1')
|
||||
oci_console.remove_log(member)
|
||||
msg_ok(f"{translate('Container removed:')} CT {member}")
|
||||
if bridge and not bridge_in_use(bridge, set(members)):
|
||||
release_bridge(bridge)
|
||||
|
||||
@@ -23,6 +23,15 @@
|
||||
"status": {
|
||||
"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": {
|
||||
"type": "object",
|
||||
"required": ["title", "description", "category", "architectures", "launch", "mini_changelog"],
|
||||
|
||||
@@ -414,6 +414,7 @@ class Catalog:
|
||||
ui = template["catalog_ui"]
|
||||
item["hidden"] = overlay_ui.get("hidden", ui.get("hidden", False))
|
||||
item["template_status"] = template["status"]
|
||||
self._index_community_tested(item, template)
|
||||
item["automatic_install_candidate"] = compatibility[
|
||||
"automatic_install_candidate"
|
||||
]
|
||||
@@ -639,6 +640,7 @@ class Catalog:
|
||||
item = index_items[app_id]
|
||||
item["template"] = f"apps/{app_id}.json"
|
||||
item["template_status"] = template["status"]
|
||||
self._index_community_tested(item, template)
|
||||
item["automatic_install_candidate"] = template["compatibility"][
|
||||
"automatic_install_candidate"
|
||||
]
|
||||
@@ -792,6 +794,33 @@ class Catalog:
|
||||
apply_stack_support(template)
|
||||
from .gpu import apply_gpu_contract
|
||||
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
|
||||
def _deep_merge(cls, target: dict[str, Any], overlay: dict[str, Any]) -> None:
|
||||
|
||||
@@ -167,8 +167,13 @@ def _app_detail_text(catalog: Catalog, item: dict[str, Any], template: dict[str,
|
||||
lines.append(f"{label + ':':<17} {value}")
|
||||
|
||||
row(translate("Source"), publisher(item))
|
||||
row(translate("Status"), translate("Verified by ProxMenux") if is_tested(item)
|
||||
else translate("Not yet verified by ProxMenux (beta)"))
|
||||
# Only a real test is shown; OCI manager Apps is labelled beta as a whole.
|
||||
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))
|
||||
endpoints = template.get("first_run", {}).get("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": "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": [
|
||||
"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>).",
|
||||
"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.",
|
||||
"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.",
|
||||
|
||||
@@ -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": "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": [
|
||||
"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>).",
|
||||
"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.",
|
||||
"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.",
|
||||
|
||||
Reference in New Issue
Block a user