mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-10-04 20:46:43 +00:00
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>
188 lines
9.5 KiB
Python
188 lines
9.5 KiB
Python
"""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
|