mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-10-08 14:36:38 +00:00
ProxMenux 1.2.6.2-beta: OCI containers in the Monitor, docs and fixes
OCI manager Apps - App tab: containers installed from an OCI image are identified from their installation record; the application and image versions are shown and an update is detected by image digest; repository link; Refresh data. - Updates tab for OCI containers: Update and Recreate run the same flow as the OCI menu in the Monitor terminal; the pre-update backup can be kept in a backup storage; scheduled image updates with an optional minimum age. - Logs tab: console output of the application, kept on the host (lxc.console.logfile + logrotate) and followed live. - The Proxmox console opens a shell (cmode: shell) when the image has one. - A damaged image download is fetched again before failing. - Multi-container applications open at their LAN address; volume mount points on block storage report their usage. Monitor - Proxmox notifications are delivered to a loopback-only HTTP listener when HTTPS is enabled, so they no longer fail certificate verification. - Log persistence counts recurring patterns only; an ended burst is not reported as persistent and its warning clears on its own (#386). - Proxmox notification config backups are deduplicated and capped at three. - The update icon on the Apps page opens the container on its Updates tab. - Version 1.2.6.2-beta and its release notes in every Monitor language. Docs - OCI manager Apps and Audit & Report rebuilt as per-page message files, with a new page for OCI containers in the Monitor. - Seven pages fixed where rich-text tags were missing from t.rich. Translations - Spanish fixes across the OCI engine, the Monitor and the TUI menus. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
386d33df6e
commit
4437a671d2
@@ -0,0 +1,187 @@
|
||||
"""Application icons for the catalog, resolved against the jsdelivr icon sets.
|
||||
|
||||
The icon a catalog entry carries used to be a URL built from the application
|
||||
id, which nobody ever requested: seven out of ten answered 404, so the panel
|
||||
drew a broken image instead of a plain placeholder. These two sets publish an
|
||||
index, so an entry only keeps an icon that is known to exist, and every icon
|
||||
comes from the same CDN as the rest of the interface.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import urllib.request
|
||||
|
||||
SELFHST_INDEX = "https://cdn.jsdelivr.net/gh/selfhst/icons@main/index.json"
|
||||
SELFHST_ICON = "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/{name}.webp"
|
||||
HOMARR_INDEX = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@main/tree.json"
|
||||
HOMARR_ICON = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@main/webp/{name}.webp"
|
||||
# The two sources the catalog itself is built from publish their icons beside
|
||||
# the application, and both are served by the same CDN. Their file names are
|
||||
# read from the repository tree rather than derived: linuxserver names them
|
||||
# `<id>-logo.png`, and the `-icon.png` this catalog used to build answered 404
|
||||
# for seven entries out of ten.
|
||||
PUBLISHER_TREES = (
|
||||
("https://api.github.com/repos/IceWhaleTech/CasaOS-AppStore/git/trees/main?recursive=1",
|
||||
r"Apps/(?P<name>[^/]+)/icon\.(?:svg|png|webp)",
|
||||
"https://cdn.jsdelivr.net/gh/IceWhaleTech/CasaOS-AppStore@main/{path}"),
|
||||
("https://api.github.com/repos/linuxserver/docker-templates/git/trees/master?recursive=1",
|
||||
r"linuxserver\.io/img/(?P<name>.+?)-(?:logo|icon)\.(?:png|svg|jpg|webp)",
|
||||
"https://cdn.jsdelivr.net/gh/linuxserver/docker-templates@master/{path}"),
|
||||
)
|
||||
TIMEOUT = 30
|
||||
VARIANT_SUFFIX_RE = re.compile(r"-(?:dark|light)$")
|
||||
# Catalog ids carry the publisher or the accelerator when the same application
|
||||
# ships under several images: emby-official, open-webui-cuda, kavita-jvmilazz0.
|
||||
# They are the same application and they wear the same icon.
|
||||
QUALIFIER_RE = re.compile(
|
||||
r"[-_](?:official|stack|nvidia|cuda|rocm|gpu|ollama|jlesage|jvmilazz0)$")
|
||||
|
||||
|
||||
# Applications neither icon set names, resolved by hand and verified. A few are
|
||||
# only ProxMenux compositions, so they borrow the icon of what they assemble:
|
||||
# HAOS One wears Home Assistant's, and the Arr suite wears Servarr's.
|
||||
CURATED = {
|
||||
"blade-of-agony": "https://cdn.jsdelivr.net/gh/linuxserver/docker-templates@master/linuxserver.io/img/boa-logo.png",
|
||||
"budge": "https://cdn.jsdelivr.net/gh/linuxserver/budge@master/frontend/public/logo512.png",
|
||||
"changedetection.io": "https://cdn.jsdelivr.net/gh/linuxserver/docker-templates@master/linuxserver.io/img/changedetection-icon.png",
|
||||
"dosbox-staging": "https://cdn.jsdelivr.net/gh/linuxserver/docker-templates@master/linuxserver.io/img/dosbox-logo.png",
|
||||
"dsh-harness": "https://cdn.jsdelivr.net/gh/deepseek-ai/deepseek-harness@master/apps/web/public/favicon.svg",
|
||||
"faster-whisper": "https://cdn.jsdelivr.net/gh/home-assistant/brands@master/core_integrations/wyoming/icon.png",
|
||||
"haos-one": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/home-assistant.webp",
|
||||
"luanti": "https://cdn.jsdelivr.net/gh/linuxserver/docker-templates@master/linuxserver.io/img/minetest-icon.png",
|
||||
"msedge": "https://cdn.jsdelivr.net/gh/linuxserver/docker-templates@master/linuxserver.io/img/edge-logo.png",
|
||||
"pyload-ng": "https://cdn.jsdelivr.net/gh/linuxserver/docker-templates@master/linuxserver.io/img/pyload-logo.png",
|
||||
"stable-diffusion-webui-nvidia": "https://cdn.jsdelivr.net/gh/IceWhaleTech/CasaOS-AppStore@main/Apps/StableDiffusionWebUI/icon.svg",
|
||||
"suite-arr": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@main/svg/servarr.svg",
|
||||
# SWAG publishes no icon of its own; what sits under its name is a 424 KB
|
||||
# animated banner. The publisher's mark stands in for it.
|
||||
"swag": "https://cdn.jsdelivr.net/gh/linuxserver/docker-templates@master/linuxserver.io/img/linuxserver-ls-logo.png",
|
||||
"vscodium-web": "https://cdn.jsdelivr.net/gh/linuxserver/docker-templates@master/linuxserver.io/img/vscodium-icon.png",
|
||||
}
|
||||
|
||||
|
||||
def _key(value: str | None) -> str:
|
||||
return re.sub(r"[^a-z0-9]", "", (value or "").lower())
|
||||
|
||||
|
||||
def _read(url: str) -> object:
|
||||
request = urllib.request.Request(url, headers={"User-Agent": "ProxMenux"})
|
||||
with urllib.request.urlopen(request, timeout=TIMEOUT) as response:
|
||||
return json.loads(response.read())
|
||||
|
||||
|
||||
class IconResolver:
|
||||
"""Resolve an application id and title to an icon on cdn.jsdelivr.net.
|
||||
|
||||
selfh.st is preferred because most of the interface already draws from it;
|
||||
dashboard-icons covers what it lacks. Both are consulted by exact id, then
|
||||
by id and title with separators removed, which is what recovers
|
||||
``homeassistant`` from ``home-assistant`` and ``actualbudget`` from
|
||||
``actual-budget``.
|
||||
"""
|
||||
|
||||
def __init__(self, selfhst: object | None = None, homarr: object | None = None,
|
||||
publishers: list[object] | None = None) -> None:
|
||||
self._sets: list[tuple[dict[str, str], set[str], str]] = []
|
||||
self._load_selfhst(selfhst if selfhst is not None else _read(SELFHST_INDEX))
|
||||
self._load_homarr(homarr if homarr is not None else _read(HOMARR_INDEX))
|
||||
for position, (url, pattern, template) in enumerate(PUBLISHER_TREES):
|
||||
tree = publishers[position] if publishers is not None else None
|
||||
if tree is None:
|
||||
try:
|
||||
tree = _read(url)
|
||||
except Exception:
|
||||
# A publisher tree that cannot be read costs coverage, not
|
||||
# correctness: the generic sets already answered first.
|
||||
continue
|
||||
self._load_publisher(tree, pattern, template)
|
||||
|
||||
def _load_publisher(self, tree: object, pattern: str, template: str) -> None:
|
||||
expression = re.compile(pattern)
|
||||
names: dict[str, str] = {}
|
||||
entries = tree.get("tree") if isinstance(tree, dict) else None
|
||||
for entry in entries if isinstance(entries, list) else []:
|
||||
if not isinstance(entry, dict) or entry.get("type") != "blob":
|
||||
continue
|
||||
match = expression.fullmatch(str(entry.get("path") or ""))
|
||||
if not match:
|
||||
continue
|
||||
name = match.group("name")
|
||||
for candidate in (name.lower(), _key(name)):
|
||||
if candidate:
|
||||
names.setdefault(candidate, entry["path"])
|
||||
self._sets.append((names, set(), template.replace("{path}", "{name}")))
|
||||
|
||||
def _load_selfhst(self, index: object) -> None:
|
||||
names: dict[str, str] = {}
|
||||
themed: set[str] = set()
|
||||
for entry in index if isinstance(index, list) else []:
|
||||
if not isinstance(entry, dict) or entry.get("WebP") != "Yes":
|
||||
continue
|
||||
reference = str(entry.get("Reference") or "").strip()
|
||||
if not reference:
|
||||
continue
|
||||
for candidate in (reference.lower(), _key(reference), _key(entry.get("Name"))):
|
||||
if candidate:
|
||||
names.setdefault(candidate, reference)
|
||||
if entry.get("Dark") == "Yes" or entry.get("Light") == "Yes":
|
||||
themed.add(reference)
|
||||
self._sets.append((names, themed, SELFHST_ICON))
|
||||
|
||||
def _load_homarr(self, tree: object) -> None:
|
||||
names: dict[str, str] = {}
|
||||
themed: set[str] = set()
|
||||
files = tree.get("webp") if isinstance(tree, dict) else None
|
||||
for filename in files if isinstance(files, list) else []:
|
||||
stem = str(filename)
|
||||
if not stem.endswith(".webp"):
|
||||
continue
|
||||
stem = stem[: -len(".webp")]
|
||||
base = VARIANT_SUFFIX_RE.sub("", stem)
|
||||
if base != stem:
|
||||
themed.add(base)
|
||||
continue
|
||||
for candidate in (base.lower(), _key(base)):
|
||||
if candidate:
|
||||
names.setdefault(candidate, base)
|
||||
self._sets.append((names, themed, HOMARR_ICON))
|
||||
|
||||
@staticmethod
|
||||
def reachable(url: str | None) -> bool:
|
||||
"""Whether a URL inherited from a publisher still serves an image.
|
||||
|
||||
The linuxserver templates URL was built from the application id and
|
||||
answers 404 for seven out of ten entries, so it is only kept for the
|
||||
ones where the file is really there.
|
||||
"""
|
||||
if not isinstance(url, str) or not url.startswith(("http://", "https://")):
|
||||
return False
|
||||
request = urllib.request.Request(url, method="HEAD",
|
||||
headers={"User-Agent": "ProxMenux"})
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=TIMEOUT) as response:
|
||||
return response.status == 200
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def resolve(self, app_id: str | None, title: str | None = None) -> dict[str, object] | None:
|
||||
"""The icon for one application, or None when neither set has it."""
|
||||
base = str(app_id or "").strip().lower()
|
||||
if base in CURATED:
|
||||
return {"url": CURATED[base], "themed": False}
|
||||
trimmed = base
|
||||
while True:
|
||||
shorter = QUALIFIER_RE.sub("", trimmed)
|
||||
if shorter == trimmed:
|
||||
break
|
||||
trimmed = shorter
|
||||
lookups = [base, _key(app_id), _key(title)]
|
||||
if trimmed != base:
|
||||
lookups += [trimmed, _key(trimmed)]
|
||||
for names, themed, pattern in self._sets:
|
||||
for lookup in lookups:
|
||||
name = names.get(lookup) if lookup else None
|
||||
if name:
|
||||
return {"url": pattern.format(name=name), "themed": name in themed}
|
||||
return None
|
||||
Reference in New Issue
Block a user