Use the apps' real logos as stack icons, fetched server-side (0.52.0)
CI / check (push) Successful in 12m8s
CI / build-and-push (push) Successful in 2m1s

0.51.0 gave every stack an icon, but a generic one: jellyfin got a clapperboard,
not the Jellyfin logo. Glyphs make a list readable; they do not make a stack
recognisable, which was the point. This resolves stacks against the selfh.st
icon catalog (~2900 self-hosted apps, the set Homarr and Homepage draw on), so
the row shows the thing people already recognise. All 83 bundled templates
resolve to their own logo.

The whole design question was *who* talks to the CDN. If the <img> points at
jsdelivr, then every client needs internet, every page load leaks the names of
somebody's stacks to a third party, and an air-gapped box gets nothing. So the
backend does it: the catalog on startup and weekly after, each logo once on
first use, both into ${DATA_DIR}/stack-icons/. Browsers keep reading icons from
the authenticated endpoint that already existed for uploads, and after the first
fetch the feature is fully offline. Logos are cached per *app*, not per stack —
verified: two stacks resolving to jellyfin produce one download.

Nothing here can fail loudly. Every entry point returns None rather than raising
when the network is absent, the catalog refresh is a task the lifespan does not
await, and an install with no outbound internet simply keeps 0.51.0's glyphs.
That fallback is also what covers a name the catalog does not know
("Mediaserver Wohnzimmer" is still a clapperboard), and the seconds after a
fresh install before the catalog lands. The glyph is derived even for stacks
that *do* have a logo, so an image that cannot be fetched degrades to something
meaningful instead of a box.

Matching gained a second source that turned out to matter more than expected:
the compose images. A stack called "medienserver" says nothing, but it pulls
lscr.io/linuxserver/jellyfin — strip the registry, the vendor and the tag and
the app is right there. Name first, then the longest run of words inside it,
then the images. It is deliberately cautious: a single word shorter than four
characters never claims a logo, because "web", "app" and "db" are all catalog
entries and a *wrong* logo is worse than a neutral glyph. A short alias table
covers what the catalog spells differently from Docker Hub (postgres →
postgresql, pihole → pi-hole, wg-easy → wireguard).

A slug arrives from the database and from query strings and then becomes a
filename, so it is pattern-checked before it is ever joined to a path, catalog
entries that are not slug-shaped are dropped on load, and a downloaded logo is
verified to start with the PNG magic bytes before being cached.

The picker searches the catalog too — pre-seeded with the stack's own name, so
opening it on "jellyfin" offers the Jellyfin logo first — which is how a wrong
match gets corrected, and how a stack can be given any app's logo on purpose.

Verified end to end against the live catalog and real downloads: list rows carry
the resolved logo, the icon endpoint serves real PNG bytes, an unmatched stack
404s (and falls through to its glyph), a hand-picked logo round-trips, reset
clears it, and a traversal slug 404s. 30 new backend tests and 12 new frontend
ones run without any network at all.

0.52.0 rather than amending 0.51.0: those images are already in the registry,
and rebuilding a published version tag with different content is exactly what
breaks the self-update checker.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
menzelj
2026-09-17 10:39:14 +02:00
co-authored by Claude Opus 5
parent 7682460b4f
commit b629d1b2c2
15 changed files with 1060 additions and 73 deletions
+64 -16
View File
@@ -13,6 +13,37 @@ as intuitive as Dockge, as capable as Portainer for Compose workflows.
> (Auto-update) + Phase 23 (Secrets & configs) + Phase 24 (Design System v2)
> complete.
## Upgrading to 0.52.0 — nothing to do
The icons 0.51.0 introduced are the **real app logos** now. A stack called
`jellyfin` shows the Jellyfin logo, `vaultwarden` the Vaultwarden shield,
`postgres` the elephant — drawn from the selfh.st icon set, the same ~2900-app
catalog Homarr and Homepage use. All 83 bundled templates resolve to their own
logo.
**Your browser never talks to the icon CDN.** The backend downloads the catalog
on startup (and weekly after that), then each logo once, the first time any
stack needs it. Both land in `${DATA_DIR}/stack-icons/`, and logos are cached by
app rather than by stack, so ten Postgres stacks share one file. From then on
the whole thing works offline, and the browser fetches logos from StackPilot's
own authenticated endpoint like any other icon.
**An install with no outbound internet keeps working**, it just keeps the
built-in glyphs: every lookup returns "no logo" instead of failing, and the
name-derived glyph from 0.51.0 is still the backstop — for an unrecognised name
(`Mediaserver Wohnzimmer`), for an air-gapped box, and for the first seconds
after a fresh install while the catalog downloads.
Matching got a second source: when the **name** says nothing, the **compose
images** are asked. A stack called `medienserver` running
`lscr.io/linuxserver/jellyfin` gets the Jellyfin logo anyway. Matching is
deliberately cautious — a single short word never claims a logo, because a wrong
one is worse than a neutral glyph.
The picker now searches that catalog too, so you can correct a match or give a
stack any app's logo by hand. Automatic, built-in glyph and your own upload all
still work exactly as before.
## Upgrading to 0.51.0 — nothing to do
Stacks have icons now, and your existing ones already have theirs. The icon is
@@ -185,9 +216,10 @@ it is what your saved destination credentials are encrypted with.
- **Live status** — running / partial / stopped / error / updating, computed from
Docker container labels. In the stack list the status is worn by the stack's
**icon**, as a halo in the status colour, instead of a separate dot.
- **Stack icons** — every stack gets an icon, derived from its name (a stack
called `jellyfin` gets a clapperboard, `postgres` a database) or picked by
hand from a built-in catalog, or uploaded as your own image.
- **Stack icons** — every stack gets the real logo of the app it runs, found
from its name or its compose images (~2900 apps, fetched once by the server
and cached), or a name-derived glyph when nothing matches — or whatever you
pick or upload yourself.
- **One operation per stack** — a lifecycle call takes a lock (a row, so it
holds across workers and across a restart) and a second one gets `409` while
it is held; auto-update skips a stack somebody is already deploying. Locks
@@ -348,21 +380,35 @@ it is what your saved destination credentials are encrypted with.
### Stack icons
- **Derived from the name.** Every stack shows an icon; with nothing configured
it comes from matching the stack's name (then its id) against a keyword table
of ~700 keywords — self-hosted app names in 79 groups, plus generic English
and German terms. `jellyfin` → clapperboard, `vaultwarden` → key,
`home-assistant` → house, `Mediaserver Wohnzimmer` → clapperboard. The longest
match wins, so `photoprism` beats a bare `photo`; a name that matches nothing
falls back to a neutral mark.
- **The app's real logo, found from the name.** With nothing configured, the
stack's name — and failing that the images its compose file pulls — is matched
against the [selfh.st icon catalog](https://selfh.st/icons/) (~2900 apps, the
set Homarr and Homepage draw on). `jellyfin` → the Jellyfin logo,
`AdGuard Home` → the AdGuard logo, `medienserver` running
`lscr.io/linuxserver/jellyfin` → the Jellyfin logo. All 83 bundled templates
resolve. Matching is conservative on purpose: exact name, then the name with
punctuation rearranged, then the longest run of words inside it, then the
images — and a single short word never claims a logo, because a wrong logo is
worse than a neutral glyph.
- **Fetched once, by the server.** The catalog is downloaded on startup and
refreshed weekly; each logo is downloaded the first time a stack needs it.
Both live in `${DATA_DIR}/stack-icons/`, logos keyed by app rather than by
stack, so ten Postgres stacks share one file. Browsers never reach the CDN —
they read logos from the authenticated icon endpoint. With no outbound
internet nothing breaks; the built-in glyphs simply stay.
- **Glyph fallback.** A name no catalog knows still gets something better than a
box: ~700 keywords in 79 groups (English and German) map it to a built-in
glyph — `Mediaserver Wohnzimmer` → clapperboard, `backup nas` → archive. The
longest match wins, so `photoprism` beats a bare `photo`.
- **Nothing to migrate.** The derivation runs at render time, so stacks that
existed before this feature have icons immediately; the `stack.icon` column
stays empty until somebody makes an explicit choice. Renaming a stack moves
its automatic icon with it.
- **Pick or upload.** Clicking the icon on the stack detail page — or the one
next to the name field in the editor — opens a picker: keep it automatic,
choose from the searchable built-in catalog, or upload a PNG / JPEG / GIF /
WebP / SVG up to 512 KiB. Uploads live in `${DATA_DIR}/stack-icons/` and are
search the app-logo catalog (it opens pre-searched for the stack's own name),
choose a built-in glyph, or upload a PNG / JPEG / GIF / WebP / SVG up to
512 KiB. Uploads live in `${DATA_DIR}/stack-icons/` and are
classified by their actual bytes, not by the filename or Content-Type the
browser claims. Cloning a stack copies its icon; deleting one removes it.
- **The status moved onto the icon.** In the stacks list and on the detail page
@@ -741,10 +787,12 @@ GET /api/dashboard/summary (containers, uptime series, ops acti
### Stack icon endpoints
```
GET /api/stacks/{id}/icon (the uploaded image; bearer token required)
POST /api/stacks/{id}/icon (multipart "file", admin, <= 512 KiB)
DELETE /api/stacks/{id}/icon (back to the name-derived icon, admin)
PUT /api/stacks/{id} ({"icon": "lucide:<name>"} or "" for automatic)
GET /api/stacks/{id}/icon (the app logo or upload; token required)
POST /api/stacks/{id}/icon (multipart "file", admin, <= 512 KiB)
DELETE /api/stacks/{id}/icon (back to the automatic icon, admin)
PUT /api/stacks/{id} ({"icon": "logo:<slug>" | "lucide:<name>" | ""})
GET /api/stacks/icons/search?q= (the app-logo catalog)
GET /api/stacks/icons/logo/{slug} (one catalog logo, served from our cache)
```
## Security notes
+6
View File
@@ -38,6 +38,7 @@ from routers import (
from services import (
backup_destination_service,
image_status_store,
logo_service,
schedule_service,
stack_lock_service,
template_service,
@@ -88,10 +89,15 @@ async def lifespan(app: FastAPI):
update_task = asyncio.create_task(update_service.background_loop())
schedule_task = asyncio.create_task(schedule_service.scheduler_loop())
# App logos. Deliberately a task and not awaited: the catalog is a network
# download, and a box with no outbound internet must still start instantly
# (it just keeps the built-in glyphs).
logo_task = asyncio.create_task(logo_service.catalog_loop())
logger.info("StackPilot backend ready on port %s", settings.PORT)
yield
update_task.cancel()
schedule_task.cancel()
logo_task.cancel()
app = FastAPI(title="StackPilot", version=APP_VERSION, lifespan=lifespan)
+61 -8
View File
@@ -32,6 +32,7 @@ from services import (
auto_update_service,
compose_service,
icon_service,
logo_service,
notify_service,
stack_lock_service,
stats_service,
@@ -68,6 +69,14 @@ def _get_stack_or_404(session: Session, stack_id: str) -> Stack:
return stack
def _auto_icon(stack: Stack) -> str | None:
"""The logo a stack gets when nothing is configured, as an icon value."""
if stack.icon:
return None
slug = logo_service.auto_slug(stack.id, stack.name)
return f"logo:{slug}" if slug else None
def _stack_summary(
stack: Stack, summaries: dict | None = None, busy: dict[str, str] | None = None
) -> dict:
@@ -101,6 +110,9 @@ def _stack_summary(
"name": stack.name,
"description": stack.description,
"icon": stack.icon,
# With no explicit choice, the app logo the name resolves to (the
# frontend falls back to a name-derived glyph when this is null).
"auto_icon": _auto_icon(stack),
"status": status,
"service_count": total,
"running_count": running,
@@ -172,6 +184,36 @@ def stacks_updates(_user: User = Depends(get_current_user)) -> dict:
return update_service.stacks_update_summary()
@router.get("/icons/search")
def search_app_logos(
q: str = Query("", max_length=64),
limit: int = Query(60, ge=1, le=200),
_user: User = Depends(get_current_user),
) -> dict:
"""Search the app-logo catalog (Jellyfin, Postgres, Gitea, …).
``ready`` is false when the catalog has not been downloaded yet — a box with
no outbound internet, or the very first minute after a fresh install. The
picker says so instead of looking empty and broken.
"""
return {
"ready": logo_service.load_catalog() is not None,
"icons": logo_service.search(q, limit),
}
@router.get("/icons/logo/{slug}")
async def get_app_logo(
slug: str,
_user: User = Depends(get_current_user),
) -> FileResponse:
"""One catalog logo by slug, for the picker's result grid."""
path = await logo_service.ensure_logo(slug)
if not path:
raise HTTPException(status_code=404, detail=f"No logo for '{slug}'")
return _icon_response(path, "image/png", f"{slug}.png")
@router.get("/{stack_id}")
def get_stack(
stack_id: str,
@@ -194,6 +236,7 @@ def get_stack(
"name": stack.name,
"description": stack.description,
"icon": stack.icon,
"auto_icon": _auto_icon(stack),
"status": status,
"yaml": compose_service.read_compose(stack_id),
# The .env is where credentials live by convention, so it is withheld
@@ -260,6 +303,7 @@ async def delete_stack(
if delete_files:
compose_service.delete_stack_files(stack_id)
icon_service.remove(stack_id)
logo_service.forget(stack_id)
session.delete(stack)
session.commit()
audit_service.record(
@@ -306,28 +350,37 @@ def clone_stack(
@router.get("/{stack_id}/icon")
def get_stack_icon(
async def get_stack_icon(
stack_id: str,
session: Session = Depends(get_session),
_user: User = Depends(get_current_user),
) -> FileResponse:
"""Serve a stack's uploaded icon.
"""Serve a stack's image icon — an upload, or the app logo it resolved to.
Authenticated like everything else, which is why the frontend fetches it
through the API client and renders the blob rather than pointing an
``<img src>`` straight at this URL (that request would carry no token).
It is also what keeps the browser off the icon CDN: an app logo is
downloaded once by this process and served from disk from then on.
"""
stack = _get_stack_or_404(session, stack_id)
path = icon_service.file_for(stack_id, stack.icon)
if not path:
raise HTTPException(status_code=404, detail="This stack has no custom icon")
ext = icon_service.custom_ext(stack.icon) or ""
if (path := icon_service.file_for(stack_id, stack.icon)):
ext = icon_service.custom_ext(stack.icon) or ""
return _icon_response(path, icon_service.content_type(ext), f"{stack_id}.{ext}")
slug = icon_service.logo_slug(stack.icon) or icon_service.logo_slug(_auto_icon(stack))
if slug and (path := await logo_service.ensure_logo(slug)):
return _icon_response(path, "image/png", f"{slug}.png")
raise HTTPException(status_code=404, detail="This stack has no image icon")
def _icon_response(path: str, media_type: str, filename: str) -> FileResponse:
return FileResponse(
path,
media_type=icon_service.content_type(ext),
media_type=media_type,
# An SVG opened as a top-level document would run its own script in the
# API's origin. Nothing here is ever meant to be a document.
headers={"Content-Disposition": f'attachment; filename="{stack_id}.{ext}"'},
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
+23 -11
View File
@@ -3,10 +3,15 @@
A stack's ``icon`` column holds one of three things:
``None``
Automatic. Nothing is stored and the UI derives an icon from the stack's
name (see ``frontend/src/lib/stackIcons.ts``). Every stack that existed
before this feature lands here, so an upgraded install shows sensible icons
without a data migration.
Automatic: the app logo the stack's name resolves to, and failing that the
glyph the frontend derives from the name (``frontend/src/lib/stackIcons.ts``).
Nothing is stored, so every stack that existed before this feature lands
here and an upgraded install shows sensible icons without a data migration.
``logo:<slug>``
The real logo of a known app, from the selfh.st catalog (see
``services/logo_service.py``). The file is cached per *slug*, not per stack,
so every Postgres stack shares one download.
``lucide:<name>``
A built-in icon the user picked explicitly. The catalog of names lives in
@@ -45,6 +50,7 @@ MAX_ICON_BYTES = 512 * 1024
_EXTENSIONS = {"png", "jpg", "gif", "webp", "svg"}
_BUILTIN_RE = re.compile(r"^lucide:[a-z0-9-]{1,48}$")
_LOGO_RE = re.compile(r"^logo:[a-z0-9][a-z0-9-]{0,63}$")
_CUSTOM_RE = re.compile(r"^custom:(png|jpg|gif|webp|svg):(\d{1,12})$")
_SAFE_ID_RE = re.compile(r"^[A-Za-z0-9._-]{1,128}$")
@@ -116,22 +122,28 @@ def file_for(stack_id: str, value: Optional[str]) -> Optional[str]:
def normalize_choice(value: str) -> Optional[str]:
"""Validate an icon chosen through the API.
An empty string means "back to automatic" and maps to ``None``. Only the
built-in form is accepted here: a ``custom:`` value is minted by
:func:`store_upload` and never taken from a client, or a stack could be
pointed at another stack's icon file.
An empty string means "back to automatic" and maps to ``None``. A built-in
glyph and an app logo are both fine to take from a client — they name a
catalog entry, not a file this stack owns. A ``custom:`` value is not: it is
minted by :func:`store_upload`, or a stack could be pointed at another
stack's uploaded image.
"""
value = (value or "").strip()
if not value:
return None
if _BUILTIN_RE.match(value):
if _BUILTIN_RE.match(value) or _LOGO_RE.match(value):
return value
raise IconError(
"Icon must be empty (automatic) or 'lucide:<name>'; upload custom "
"images through POST /api/stacks/{id}/icon"
"Icon must be empty (automatic), 'lucide:<name>' or 'logo:<slug>'; "
"upload custom images through POST /api/stacks/{id}/icon"
)
def logo_slug(value: Optional[str]) -> Optional[str]:
"""The catalog slug of a ``logo:`` icon value, or None for the rest."""
return value[len("logo:"):] if _LOGO_RE.match(value or "") else None
# --------------------------------------------------------------------------- #
# Uploads
# --------------------------------------------------------------------------- #
+428
View File
@@ -0,0 +1,428 @@
"""Real app logos for stacks, fetched once and then served from disk.
A stack called ``jellyfin`` should show *the Jellyfin logo*, not a generic
clapperboard. The logos come from the selfh.st icon set (~2900 self-hosted
apps), which is the same catalog Homarr, Homepage and Dashy draw on.
Everything crosses the network exactly once and on the server:
* the **catalog** (a JSON index of slugs and display names) is downloaded on
startup and refreshed weekly into ``${DATA_DIR}/stack-icons/catalog.json``,
* a **logo** is downloaded the first time something asks for it and cached at
``${DATA_DIR}/stack-icons/logos/<slug>.png``, keyed by slug rather than by
stack so ten Postgres stacks share one file.
Browsers therefore never talk to the CDN: they fetch logos from StackPilot's
own authenticated icon endpoint, like an uploaded image. After the first fetch
the whole feature works offline, and an installation with no outbound internet
degrades to the built-in glyphs rather than breaking — every entry point here
returns None instead of raising when the network is not there.
Matching a stack to a slug is deliberately conservative: an exact name, then the
name with punctuation rearranged, then the longest run of words inside it, then
the images its compose file pulls (``lscr.io/linuxserver/jellyfin:latest`` →
``jellyfin``). A stack whose name means nothing to the catalog gets no logo and
falls back to the keyword-derived glyph in the frontend.
"""
from __future__ import annotations
import asyncio
import json
import logging
import os
import re
import time
from typing import Iterable, Optional
import httpx
import yaml
from config import settings
from services import compose_service
logger = logging.getLogger("stackpilot.logos")
CATALOG_URL = "https://cdn.jsdelivr.net/gh/selfhst/icons/index.json"
LOGO_URL = "https://cdn.jsdelivr.net/gh/selfhst/icons/png/{slug}.png"
#: The catalog gains a handful of apps a week; there is nothing to gain from
#: checking more often, and a failed refresh simply keeps the previous copy.
CATALOG_TTL = 7 * 24 * 3600
CATALOG_MAX_BYTES = 8 * 1024 * 1024
LOGO_MAX_BYTES = 2 * 1024 * 1024
TIMEOUT = httpx.Timeout(15.0)
_SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,63}$")
#: Docker image names that are not what the catalog calls the app. Kept short on
#: purpose — this is for the cases the matcher genuinely cannot reach, not a
#: second catalog.
ALIASES = {
"postgres": "postgresql",
"pgsql": "postgresql",
"mongo": "mongodb",
"trilium": "trilium-notes",
"wg-easy": "wireguard",
"wg": "wireguard",
"homeassistant": "home-assistant",
"hass": "home-assistant",
"pihole": "pi-hole",
"openwebui": "open-webui",
"nextcloud-aio": "nextcloud",
"paperless": "paperless-ngx",
"paperless-ng": "paperless-ngx",
"code-server": "coder",
"filebrowser": "file-browser",
"qbit": "qbittorrent",
"sab": "sabnzbd",
}
#: Image name components that say nothing about the app.
_IMAGE_NOISE = {
"latest", "linuxserver", "lscr", "ghcr", "docker", "io", "com", "library",
"hotio", "alpine", "amd64", "arm64v8", "bitnami", "official",
}
# --------------------------------------------------------------------------- #
# Paths
# --------------------------------------------------------------------------- #
def _root() -> str:
return os.path.join(settings.DATA_DIR, "stack-icons")
def catalog_path() -> str:
return os.path.join(_root(), "catalog.json")
def logo_dir() -> str:
return os.path.join(_root(), "logos")
def logo_path(slug: str) -> Optional[str]:
"""Where a slug's logo is cached, or None if the slug is malformed.
The slug reaches this from the database and from query strings, and it is
about to become a filename.
"""
if not _SLUG_RE.match(slug or ""):
return None
return os.path.join(logo_dir(), f"{slug}.png")
# --------------------------------------------------------------------------- #
# The catalog
# --------------------------------------------------------------------------- #
class Catalog:
"""Slug lookups built once per catalog file."""
def __init__(self, entries: list[dict]):
self.entries = entries
self.slugs: set[str] = set()
self.by_name: dict[str, str] = {}
self.compact: dict[str, str] = {}
for entry in entries:
slug = (entry.get("Reference") or "").strip().lower()
if not _SLUG_RE.match(slug):
continue
self.slugs.add(slug)
# "AdGuard Home" → "adguard home", so a stack named that matches
# even though the slug is hyphenated.
name = _normalize(entry.get("Name") or "")
self.by_name.setdefault(name, slug)
# "pihole" → "pi-hole": people drop the punctuation the catalog keeps.
self.compact.setdefault(slug.replace("-", ""), slug)
self.compact.setdefault(name.replace(" ", ""), slug)
def display_name(self, slug: str) -> str:
for entry in self.entries:
if (entry.get("Reference") or "").lower() == slug:
return entry.get("Name") or slug
return slug
def __len__(self) -> int:
return len(self.slugs)
_catalog: Optional[Catalog] = None
_catalog_mtime: float = 0.0
def load_catalog() -> Optional[Catalog]:
"""The cached catalog, re-read only when the file on disk changed."""
global _catalog, _catalog_mtime
path = catalog_path()
try:
mtime = os.path.getmtime(path)
except OSError:
return _catalog # never downloaded, or removed under us
if _catalog is not None and mtime == _catalog_mtime:
return _catalog
try:
with open(path, "r", encoding="utf-8") as fh:
entries = json.load(fh)
except (OSError, json.JSONDecodeError) as exc:
logger.warning("Icon catalog is unreadable (%s); ignoring it", exc)
return _catalog
if not isinstance(entries, list):
return _catalog
_catalog = Catalog(entries)
_catalog_mtime = mtime
logger.info("Loaded %d app logos from the icon catalog", len(_catalog))
return _catalog
def catalog_age() -> Optional[float]:
try:
return time.time() - os.path.getmtime(catalog_path())
except OSError:
return None
async def refresh_catalog(force: bool = False) -> bool:
"""Download the catalog unless the copy on disk is still fresh."""
age = catalog_age()
if not force and age is not None and age < CATALOG_TTL:
return False
try:
async with httpx.AsyncClient(follow_redirects=True, timeout=TIMEOUT) as client:
response = await client.get(CATALOG_URL)
response.raise_for_status()
if len(response.content) > CATALOG_MAX_BYTES:
raise ValueError("catalog is implausibly large")
entries = response.json()
if not isinstance(entries, list) or not entries:
raise ValueError("catalog is not a non-empty list")
except (httpx.HTTPError, ValueError, json.JSONDecodeError) as exc:
# No internet is a normal state for a self-hosted box. Say so once and
# carry on with the built-in glyphs.
logger.info("Could not refresh the app icon catalog: %s", exc)
return False
os.makedirs(_root(), exist_ok=True)
tmp = catalog_path() + ".tmp"
with open(tmp, "w", encoding="utf-8") as fh:
json.dump(entries, fh)
os.replace(tmp, catalog_path())
load_catalog()
return True
async def catalog_loop() -> None:
"""Keep the catalog fresh for as long as the app runs."""
while True:
try:
await refresh_catalog()
except Exception as exc: # noqa: BLE001 - a background loop may not die
logger.warning("Icon catalog refresh failed: %s", exc)
await asyncio.sleep(CATALOG_TTL)
# --------------------------------------------------------------------------- #
# Matching a stack to a slug
# --------------------------------------------------------------------------- #
def _normalize(text: str) -> str:
return re.sub(r"[^a-z0-9]+", " ", text.lower()).strip()
def _lookup(catalog: Catalog, phrase: str) -> Optional[str]:
"""A slug for one normalized phrase, trying every spelling of it."""
if not phrase:
return None
hyphenated = phrase.replace(" ", "-")
squashed = phrase.replace(" ", "")
if (alias := ALIASES.get(hyphenated)) and alias in catalog.slugs:
return alias
if hyphenated in catalog.slugs:
return hyphenated
if phrase in catalog.by_name:
return catalog.by_name[phrase]
if squashed in catalog.compact:
return catalog.compact[squashed]
return None
def match_slug(name: str, images: Iterable[str] = ()) -> Optional[str]:
"""The catalog slug a stack's name (then its images) points at."""
catalog = load_catalog()
if not catalog:
return None
phrase = _normalize(name)
if (slug := _lookup(catalog, phrase)):
return slug
# The app's name inside a longer one ("my jellyfin stack", "medien plex").
# Longest run of words first, so "home assistant" beats "home".
tokens = phrase.split()
for size in range(len(tokens), 0, -1):
for start in range(len(tokens) - size + 1):
gram = tokens[start : start + size]
# A single short word is far more likely to be a coincidence than
# an app ("app", "web", "db" are all slugs somewhere).
if size == 1 and len(gram[0]) < 4:
continue
if (slug := _lookup(catalog, " ".join(gram))):
return slug
# Nothing in the name: ask what the stack actually runs.
for image in images:
if (slug := _lookup(catalog, _normalize(image))):
return slug
return None
def images_for(stack_id: str) -> list[str]:
"""Image names a stack's compose file pulls, most specific part first.
``lscr.io/linuxserver/jellyfin:latest`` contributes ``jellyfin``: the tag,
the registry and the vendor namespace say nothing about which app it is.
"""
directory = compose_service.stack_dir(stack_id)
compose_file = compose_service.find_compose_file(directory)
if not compose_file:
return []
try:
with open(compose_file, "r", encoding="utf-8", errors="replace") as fh:
data = yaml.safe_load(fh) or {}
except (OSError, yaml.YAMLError):
return []
out: list[str] = []
for spec in (data.get("services") or {}).values():
if not isinstance(spec, dict):
continue
image = spec.get("image")
if not isinstance(image, str) or not image:
continue
# Strip the tag/digest, then take the last path segment.
base = image.split("@")[0].rsplit(":", 1)[0]
candidate = base.rstrip("/").split("/")[-1]
if candidate and candidate not in _IMAGE_NOISE and candidate not in out:
out.append(candidate)
return out
#: Per-stack results, keyed by what they were computed from. Matching is pure
#: string work, but it reads the compose file, and the stacks list runs it for
#: every row on every poll.
_resolved: dict[str, tuple[tuple, Optional[str]]] = {}
def _signature(stack_id: str, name: str) -> tuple:
try:
mtime = os.path.getmtime(
compose_service.find_compose_file(compose_service.stack_dir(stack_id)) or ""
)
except OSError:
mtime = 0.0
return (name, mtime, _catalog_mtime)
def auto_slug(stack_id: str, name: str) -> Optional[str]:
"""The logo a stack gets with nothing configured, or None for no match.
Cached against the stack's name and its compose file's mtime, so a rename or
an edited compose re-matches and everything else is a dictionary hit.
"""
signature = _signature(stack_id, name)
cached = _resolved.get(stack_id)
if cached and cached[0] == signature:
return cached[1]
slug = match_slug(name, images_for(stack_id))
_resolved[stack_id] = (signature, slug)
return slug
def forget(stack_id: str) -> None:
"""Drop a stack's memoized match (it was deleted, or renamed by clone)."""
_resolved.pop(stack_id, None)
# --------------------------------------------------------------------------- #
# The logo files
# --------------------------------------------------------------------------- #
#: Slugs currently being downloaded, so N rows asking at once fetch once.
_inflight: dict[str, asyncio.Task] = {}
async def ensure_logo(slug: str) -> Optional[str]:
"""Path to a slug's cached logo, downloading it the first time."""
path = logo_path(slug)
if not path:
return None
if os.path.isfile(path):
return path
catalog = load_catalog()
if catalog and slug not in catalog.slugs:
return None
if (task := _inflight.get(slug)) is None:
task = asyncio.create_task(_download(slug, path))
_inflight[slug] = task
task.add_done_callback(lambda _t, s=slug: _inflight.pop(s, None))
try:
return await asyncio.shield(task)
except asyncio.CancelledError:
# The *caller* went away (client disconnected); the download itself is
# shielded and still finishes for whoever asks next.
raise
except Exception: # noqa: BLE001 - a missing logo is not an error
return None
async def _download(slug: str, path: str) -> Optional[str]:
try:
async with httpx.AsyncClient(follow_redirects=True, timeout=TIMEOUT) as client:
response = await client.get(LOGO_URL.format(slug=slug))
response.raise_for_status()
data = response.content
except httpx.HTTPError as exc:
logger.info("Could not fetch the logo for '%s': %s", slug, exc)
return None
if not data.startswith(b"\x89PNG\r\n\x1a\n") or len(data) > LOGO_MAX_BYTES:
logger.info("Ignoring the logo for '%s': not a plausible PNG", slug)
return None
os.makedirs(logo_dir(), exist_ok=True)
tmp = f"{path}.{os.getpid()}.tmp"
try:
with open(tmp, "wb") as fh:
fh.write(data)
os.replace(tmp, path)
except OSError as exc:
logger.warning("Could not cache the logo for '%s': %s", slug, exc)
return None
return path
def search(query: str, limit: int = 60) -> list[dict]:
"""Catalog entries matching a search term, best match first."""
catalog = load_catalog()
if not catalog:
return []
needle = _normalize(query)
results: list[tuple[int, str, dict]] = []
for entry in catalog.entries:
slug = (entry.get("Reference") or "").lower()
name = entry.get("Name") or slug
if not _SLUG_RE.match(slug):
continue
haystack = _normalize(name)
if not needle:
rank = 2
elif haystack == needle or slug == needle.replace(" ", "-"):
rank = 0
elif haystack.startswith(needle) or slug.startswith(needle.replace(" ", "-")):
rank = 1
elif needle in haystack or needle.replace(" ", "-") in slug:
rank = 2
else:
continue
results.append((rank, haystack, {"slug": slug, "name": name}))
results.sort(key=lambda row: (row[0], row[1]))
return [row[2] for row in results[:limit]]
+198
View File
@@ -0,0 +1,198 @@
"""App logos: matching a stack to a catalog slug, and the caching around it.
Nothing here touches the network. The catalog is a file on disk, so the tests
write one; the download paths are covered by pointing them at a stub client.
What matters is the matching — it decides the icon of every stack that has not
been given one by hand — and that a slug can never become a path.
"""
from __future__ import annotations
import json
import os
import pytest
CATALOG = [
{"Name": "Jellyfin", "Reference": "jellyfin"},
{"Name": "Plex", "Reference": "plex"},
{"Name": "AdGuard Home", "Reference": "adguard-home"},
{"Name": "Pi-hole", "Reference": "pi-hole"},
{"Name": "PostgreSQL", "Reference": "postgresql"},
{"Name": "Home Assistant", "Reference": "home-assistant"},
{"Name": "Homepage", "Reference": "homepage"},
{"Name": "Paperless-ngx", "Reference": "paperless-ngx"},
{"Name": "Vaultwarden", "Reference": "vaultwarden"},
{"Name": "Gitea", "Reference": "gitea"},
{"Name": "Web", "Reference": "web"},
{"Name": "Bad Slug", "Reference": "../../etc/passwd"},
]
@pytest.fixture
def svc(db, tmp_path, monkeypatch):
"""logo_service with a catalog on disk and its caches cleared."""
from services import logo_service
monkeypatch.setattr(logo_service.settings, "DATA_DIR", str(tmp_path))
os.makedirs(os.path.join(tmp_path, "stack-icons"), exist_ok=True)
with open(logo_service.catalog_path(), "w", encoding="utf-8") as fh:
json.dump(CATALOG, fh)
logo_service._catalog = None
logo_service._catalog_mtime = 0.0
logo_service._resolved.clear()
logo_service.load_catalog()
return logo_service
# --------------------------------------------------------------------------- #
# Matching
# --------------------------------------------------------------------------- #
@pytest.mark.parametrize(
"name,slug",
[
("jellyfin", "jellyfin"),
("Jellyfin", "jellyfin"),
# Display name with a space vs. a hyphenated slug, in both directions.
("AdGuard Home", "adguard-home"),
("adguard-home", "adguard-home"),
# People drop the punctuation the catalog keeps.
("pihole", "pi-hole"),
("Paperless NGX", "paperless-ngx"),
# The app's name inside a longer one.
("my-jellyfin-stack", "jellyfin"),
("Medien Plex Wohnzimmer", "plex"),
# An alias for what the Docker image is called.
("postgres", "postgresql"),
],
)
def test_a_stack_name_finds_its_app(svc, name, slug):
assert svc.match_slug(name) == slug
def test_the_longest_run_of_words_wins(svc):
""""home assistant" must not lose to "home" or "homepage"."""
assert svc.match_slug("Home Assistant") == "home-assistant"
def test_a_name_that_means_nothing_gets_no_logo(svc):
assert svc.match_slug("zzz-42") is None
assert svc.match_slug("") is None
def test_a_single_short_word_is_not_enough(svc):
""""web" is a catalog entry, so a stack called "web ui" must not claim it —
a wrong logo is worse than the generic glyph."""
assert svc.match_slug("web ui") is None
# Spelled out on its own it is a deliberate match.
assert svc.match_slug("web") == "web"
def test_the_images_answer_when_the_name_does_not(svc):
assert svc.match_slug("medienserver", ["jellyfin"]) == "jellyfin"
# The name still wins when it matches by itself.
assert svc.match_slug("gitea", ["jellyfin"]) == "gitea"
def test_a_malformed_catalog_entry_is_ignored(svc):
assert "../../etc/passwd" not in svc.load_catalog().slugs
def test_no_catalog_means_no_logo(svc, tmp_path):
os.remove(svc.catalog_path())
svc._catalog = None
svc._catalog_mtime = 0.0
assert svc.load_catalog() is None
assert svc.match_slug("jellyfin") is None
# --------------------------------------------------------------------------- #
# Image names out of a compose file
# --------------------------------------------------------------------------- #
def test_image_names_drop_registry_vendor_and_tag(svc, monkeypatch, tmp_path):
stack_dir = tmp_path / "stacks" / "medien"
stack_dir.mkdir(parents=True)
(stack_dir / "compose.yaml").write_text(
"services:\n"
" app:\n"
" image: lscr.io/linuxserver/jellyfin:latest\n"
" db:\n"
" image: postgres:16-alpine\n"
)
monkeypatch.setattr(
svc.compose_service, "stack_dir", lambda sid, override=None: str(stack_dir)
)
assert svc.images_for("medien") == ["jellyfin", "postgres"]
def test_a_stack_with_no_compose_file_has_no_images(svc, monkeypatch, tmp_path):
monkeypatch.setattr(
svc.compose_service, "stack_dir", lambda sid, override=None: str(tmp_path / "nope")
)
assert svc.images_for("nope") == []
# --------------------------------------------------------------------------- #
# Caching
# --------------------------------------------------------------------------- #
def test_the_match_is_recomputed_when_the_name_changes(svc, monkeypatch):
calls = []
real = svc.match_slug
monkeypatch.setattr(
svc, "match_slug", lambda name, images=(): (calls.append(name), real(name, images))[1]
)
assert svc.auto_slug("s1", "jellyfin") == "jellyfin"
assert svc.auto_slug("s1", "jellyfin") == "jellyfin"
assert len(calls) == 1, "a repeat lookup must come from the memo"
assert svc.auto_slug("s1", "gitea") == "gitea"
assert len(calls) == 2, "a rename must re-match"
def test_forgetting_a_stack_drops_its_match(svc):
svc.auto_slug("s1", "jellyfin")
svc.forget("s1")
assert "s1" not in svc._resolved
# --------------------------------------------------------------------------- #
# Slug → path
# --------------------------------------------------------------------------- #
@pytest.mark.parametrize(
"slug", ["../../../etc/passwd", "..", "foo/bar", "foo.png", "", "UPPER", "-lead"]
)
def test_a_slug_that_is_not_a_slug_has_no_path(svc, slug):
assert svc.logo_path(slug) is None
def test_a_real_slug_lands_in_the_logo_directory(svc):
path = svc.logo_path("jellyfin")
assert path == os.path.join(svc.logo_dir(), "jellyfin.png")
# --------------------------------------------------------------------------- #
# Search
# --------------------------------------------------------------------------- #
def test_search_ranks_exact_then_prefix_then_substring(svc):
slugs = [row["slug"] for row in svc.search("home")]
# "Home Assistant" starts with it; "AdGuard Home" only contains it.
assert slugs.index("home-assistant") < slugs.index("adguard-home")
def test_search_with_no_term_lists_the_catalog(svc):
assert len(svc.search("", limit=5)) == 5
def test_search_without_a_catalog_is_empty_not_an_error(svc):
os.remove(svc.catalog_path())
svc._catalog = None
svc._catalog_mtime = 0.0
assert svc.search("jellyfin") == []
@@ -61,6 +61,10 @@ USER_READABLE = {
"GET /api/stacks",
"GET /api/stacks/stats",
"GET /api/stacks/updates",
# The app-logo catalog and its images: public artwork from a public icon
# set, keyed by app name. Nothing here is derived from this install.
"GET /api/stacks/icons/search",
"GET /api/stacks/icons/logo/{slug}",
"GET /api/stacks/{stack_id}",
"GET /api/stacks/{stack_id}/auto-update",
# The stack's uploaded icon — an image the user already sees next to the
+1 -1
View File
@@ -1,3 +1,3 @@
"""Single source of truth for the StackPilot release version."""
APP_VERSION = "0.51.0"
APP_VERSION = "0.52.0"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "stackpilot-frontend",
"private": true,
"version": "0.51.0",
"version": "0.52.0",
"type": "module",
"scripts": {
"dev": "vite",
+17
View File
@@ -52,6 +52,23 @@ export const stacksApi = {
resetIcon: (id: string) =>
api.delete<StackSummary>(`/api/stacks/${id}/icon`).then((r) => r.data),
/** Search the app-logo catalog (Jellyfin, Postgres, Gitea, …). `ready` is
* false when the server has not been able to download the catalog. */
searchLogos: (q: string, limit = 24) =>
api
.get<{ ready: boolean; icons: { slug: string; name: string }[] }>(
`/api/stacks/icons/search?q=${encodeURIComponent(q)}&limit=${limit}`
)
.then((r) => r.data),
/** One catalog logo by slug. Served by our backend from its own cache, so
* the browser never talks to the icon CDN. */
logo: (slug: string) =>
api
.get<Blob>(`/api/stacks/icons/logo/${encodeURIComponent(slug)}`, {
responseType: "blob",
})
.then((r) => r.data),
logs: (id: string, tail = 200) =>
api.get<{ logs: string }>(`/api/stacks/${id}/logs?tail=${tail}`).then((r) => r.data),
convert: (command: string) =>
+131 -8
View File
@@ -1,12 +1,13 @@
import { useMemo, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useQueryClient } from "@tanstack/react-query";
import { Sparkles, Upload, X } from "lucide-react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { ImageOff, Sparkles, Upload, X } from "lucide-react";
import { toast } from "sonner";
import { Button, Input } from "@/components/ui";
import { StackIcon } from "@/components/ui/StackIcon";
import { cn } from "@/lib/utils";
import { ICON_GROUPS, suggestIconName } from "@/lib/stackIcons";
import type { IconStack } from "@/lib/stackIcons";
import { stacksApi } from "@/api/stacks";
import { apiErrorMessage } from "@/api/client";
import type { StackStatus } from "@/types";
@@ -33,7 +34,7 @@ export function IconPicker({
onUpload,
onClose,
}: {
stack: { id: string; name: string; icon?: string | null };
stack: IconStack;
status?: StackStatus;
previewUrl?: string | null;
busy?: boolean;
@@ -120,7 +121,7 @@ export function IconPicker({
/>
<div className="relative min-w-[140px] flex-1">
<Input
placeholder="Search icons…"
placeholder="Search app logos and symbols…"
value={q}
onChange={(e) => setQ(e.target.value)}
/>
@@ -128,12 +129,19 @@ export function IconPicker({
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-4">
<AppLogos
term={q.trim() || stack.name}
selected={stack.icon ?? ""}
busy={busy}
onSelect={onSelect}
/>
{groups.length === 0 && (
<p className="text-sm text-slate-500">No icon matches {q}.</p>
<p className="text-sm text-slate-500">No symbol matches {q}.</p>
)}
{groups.length > 0 && <h3 className="sp-label mb-2">Symbols</h3>}
{groups.map((group) => (
<section key={group.label} className="mb-4 last:mb-0">
<h3 className="sp-label mb-2">{group.label}</h3>
<h3 className="mb-2 text-[11px] text-slate-400">{group.label}</h3>
<div className="grid grid-cols-8 gap-1.5 sm:grid-cols-10">
{Object.entries(group.icons).map(([name, Glyph]) => {
const selected = stack.icon === `lucide:${name}`;
@@ -187,7 +195,7 @@ export function StackIconEditor({
size = "lg",
editable = true,
}: {
stack: { id: string; name: string; icon?: string | null };
stack: IconStack;
status: StackStatus;
size?: "sm" | "md" | "lg";
/** The read-only role sees the icon but cannot change it. */
@@ -235,3 +243,118 @@ export function StackIconEditor({
</>
);
}
/**
* Logos of known apps, searched in the catalog the backend caches.
*
* Opening the picker searches for the stack's own name, so the Jellyfin logo is
* the first thing a stack called "jellyfin" offers. Each thumbnail comes from
* our own backend rather than the icon CDN — see api/stacks.ts.
*/
function AppLogos({
term,
selected,
busy,
onSelect,
}: {
term: string;
selected: string;
busy: boolean;
onSelect: (icon: string) => void;
}) {
const { data, isLoading } = useQuery({
queryKey: ["icon-logos", term],
queryFn: () => stacksApi.searchLogos(term),
enabled: term.trim().length > 0,
staleTime: 5 * 60 * 1000,
});
if (!term.trim()) return null;
if (isLoading) {
return (
<section className="mb-4">
<h3 className="sp-label mb-2">App logos</h3>
<div className="h-12 sp-skeleton" />
</section>
);
}
if (!data) return null;
if (!data.ready) {
return (
<section className="mb-4">
<h3 className="sp-label mb-2">App logos</h3>
<p className="text-xs text-slate-500">
The logo catalog has not been downloaded yet it needs outbound
internet on the server, and is retried automatically.
</p>
</section>
);
}
if (data.icons.length === 0) {
return (
<section className="mb-4">
<h3 className="sp-label mb-2">App logos</h3>
<p className="text-xs text-slate-500">No app matches {term}.</p>
</section>
);
}
return (
<section className="mb-4">
<h3 className="sp-label mb-2">App logos</h3>
<div className="grid grid-cols-4 gap-1.5 sm:grid-cols-6">
{data.icons.map((icon) => (
<button
key={icon.slug}
title={icon.name}
disabled={busy}
onClick={() => onSelect(`logo:${icon.slug}`)}
className={cn(
"flex flex-col items-center gap-1 rounded-lg border p-2 transition-colors",
"disabled:opacity-40",
selected === `logo:${icon.slug}`
? "border-accent bg-accent/10"
: "border-transparent hover:bg-slate-100 dark:hover:bg-slate-700"
)}
>
<LogoThumb slug={icon.slug} />
<span className="w-full truncate text-[10px] text-slate-500">{icon.name}</span>
</button>
))}
</div>
</section>
);
}
/** One catalog logo, fetched through the API client (the endpoint needs the
* token) and cached for the session. */
function LogoThumb({ slug }: { slug: string }) {
const { data: blob } = useQuery({
queryKey: ["icon-logo", slug],
queryFn: () => stacksApi.logo(slug),
staleTime: Infinity,
gcTime: 60 * 60 * 1000,
retry: false,
});
const [url, setUrl] = useState<string | null>(null);
useEffect(() => {
if (!blob) {
setUrl(null);
return;
}
const objectUrl = URL.createObjectURL(blob);
setUrl(objectUrl);
return () => URL.revokeObjectURL(objectUrl);
}, [blob]);
return (
<span className="flex h-8 w-8 items-center justify-center overflow-hidden rounded-md bg-white">
{url ? (
<img src={url} alt="" className="h-full w-full object-contain p-0.5" />
) : (
<ImageOff className="h-3.5 w-3.5 text-slate-300" />
)}
</span>
);
}
+34 -10
View File
@@ -1,7 +1,13 @@
import { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { cn } from "@/lib/utils";
import { iconComponent, resolveStackIcon } from "@/lib/stackIcons";
import {
iconComponent,
imageIconKey,
resolveStackIcon,
suggestIconName,
} from "@/lib/stackIcons";
import type { IconStack } from "@/lib/stackIcons";
import { stacksApi } from "@/api/stacks";
import type { StackStatus } from "@/types";
@@ -12,6 +18,10 @@ import type { StackStatus } from "@/types";
* carried by the thing the eye lands on anyway. The status is still spelled out
* in the badge next to it and in the tooltip here, so the colour is never the
* only way to read it.
*
* The icon is either an image the server holds — the app's real logo, or an
* upload — or a glyph derived from the name. Images are fetched through the API
* client because that endpoint needs the bearer token.
*/
type Size = "sm" | "md" | "lg";
@@ -64,7 +74,7 @@ export function StackIcon({
previewUrl,
className,
}: {
stack: { id: string; name: string; icon?: string | null };
stack: IconStack;
status: StackStatus;
size?: Size;
/** Shows this image instead of the stored icon — used to preview a file that
@@ -75,12 +85,17 @@ export function StackIcon({
const resolved = resolveStackIcon(stack);
const dims = SIZES[size];
const tone = STATUS_STYLE[status] ?? STATUS_STYLE.unknown;
const stored = useCustomIconUrl(
const stored = useStackImageUrl(
stack.id,
resolved.kind === "custom" && !previewUrl ? stack.icon : null
resolved.kind === "image" && !previewUrl ? imageIconKey(stack) : null
);
const custom = previewUrl ?? stored;
const Glyph = iconComponent(resolved.kind === "builtin" ? resolved.name : "");
// The glyph doubles as the fallback for an image that cannot be fetched (a
// logo the server has not got yet), so derive it from the name either way
// rather than landing on the generic mark.
const Glyph = iconComponent(
resolved.kind === "builtin" ? resolved.name : suggestIconName(stack.name, stack.id)
);
return (
<span
@@ -109,7 +124,14 @@ export function StackIcon({
)}
>
{custom ? (
<img src={custom} alt="" className="h-full w-full object-cover" />
// Logos are drawn for a light ground and many are dark line art, so
// the tile stays light in both themes rather than swallowing them.
// `contain`, not `cover`: a logo must not be cropped.
<img
src={custom}
alt=""
className="h-full w-full bg-white object-contain p-0.5"
/>
) : (
<Glyph className={cn(dims.glyph, tone.glyph)} strokeWidth={2} />
)}
@@ -119,14 +141,16 @@ export function StackIcon({
}
/**
* Object URL for a stack's uploaded icon, or null while there is none.
* Object URL for a stack's image icon, or null while there is none.
*
* The icon endpoint needs the bearer token, so the bytes are fetched through
* the API client and handed to the browser as a blob. `icon` is part of the
* query key and changes on every upload (it carries a version), which is what
* retires the previous image instead of leaving a stale one on screen.
* query key: an upload carries a version and a logo carries its slug, so
* changing either retires the previous image instead of leaving a stale one on
* screen. `retry: false` matters here — a stack whose logo the server cannot
* fetch (no internet yet) must fall through to its glyph quietly.
*/
function useCustomIconUrl(stackId: string, icon: string | null | undefined): string | null {
function useStackImageUrl(stackId: string, icon: string | null | undefined): string | null {
const { data: blob } = useQuery({
queryKey: ["stack-icon", stackId, icon],
queryFn: () => stacksApi.icon(stackId),
+44 -1
View File
@@ -9,6 +9,7 @@ import { describe, expect, it } from "vitest";
import {
FALLBACK_ICON,
STACK_ICONS,
imageIconKey,
resolveStackIcon,
suggestIconName,
} from "./stackIcons";
@@ -87,7 +88,34 @@ describe("resolveStackIcon", () => {
it("reports an uploaded image", () => {
expect(
resolveStackIcon({ id: "x", name: "X", icon: "custom:png:123" }).kind
).toBe("custom");
).toBe("image");
});
it("reports an app logo, chosen or matched by the server", () => {
expect(
resolveStackIcon({ id: "x", name: "X", icon: "logo:jellyfin" }).kind
).toBe("image");
expect(
resolveStackIcon({ id: "plex", name: "Plex", auto_icon: "logo:plex" }).kind
).toBe("image");
});
it("lets an explicit choice outrank the matched logo", () => {
// Somebody who picked a glyph must not have the server's logo put back.
expect(
resolveStackIcon({
id: "plex",
name: "Plex",
icon: "lucide:database",
auto_icon: "logo:plex",
})
).toEqual({ kind: "builtin", name: "database", automatic: false });
});
it("falls back to the derived glyph when the server matched nothing", () => {
expect(
resolveStackIcon({ id: "plex", name: "Plex", icon: null, auto_icon: null })
).toEqual({ kind: "builtin", name: "clapperboard", automatic: true });
});
it("derives one when nothing is stored", () => {
@@ -107,3 +135,18 @@ describe("resolveStackIcon", () => {
expect(resolved).toEqual({ kind: "builtin", name: "clapperboard", automatic: true });
});
});
describe("imageIconKey", () => {
it("keys on the explicit choice, then on the matched logo", () => {
expect(imageIconKey({ id: "x", name: "X", icon: "custom:png:9" })).toBe("custom:png:9");
expect(imageIconKey({ id: "x", name: "X", auto_icon: "logo:plex" })).toBe("logo:plex");
});
it("is null when the icon is a glyph, so nothing is fetched", () => {
expect(imageIconKey({ id: "x", name: "X", icon: "lucide:database" })).toBeNull();
// An explicit glyph suppresses the matched logo rather than fetching it.
expect(
imageIconKey({ id: "x", name: "X", icon: "lucide:database", auto_icon: "logo:plex" })
).toBeNull();
});
});
+42 -15
View File
@@ -4,17 +4,26 @@
* Every stack shows an icon in place of the old status dot. Where it comes
* from, in order:
*
* 1. `stack.icon === "custom:…"` — an image the user uploaded. Rendered by
* `StackIcon`, which fetches it through the authenticated API client.
* 2. `stack.icon === "lucide:<name>"` — an icon the user picked from here.
* 3. `stack.icon` unset — `suggestIconName()` derives one from the stack's
* name. This is what every stack that predates the feature gets, which is
* why no migration backfills the column: the icon is simply computed.
* 1. `stack.icon === "custom:…"` — an image the user uploaded.
* 2. `stack.icon === "logo:<slug>"` — the real logo of a known app, picked by
* hand from the catalog.
* 3. `stack.icon === "lucide:<name>"` — a glyph the user picked from here.
* 4. `stack.auto_icon === "logo:<slug>"` — no explicit choice, but the server
* recognised the app from the stack's name or its compose images. This is
* the common case: a stack called "jellyfin" shows the Jellyfin logo.
* 5. nothing at all — `suggestIconName()` derives a glyph from the name. The
* backstop for a name no catalog knows ("Mediaserver Wohnzimmer"), for an
* install with no outbound internet, and for every stack in the seconds
* before the logo catalog finishes downloading.
*
* The catalog and the matching rules live in the frontend on purpose. This is
* the only place that can actually *render* an icon, so a second copy in the
* 1, 2 and 4 are images and all come from the same authenticated endpoint —
* `StackIcon` fetches the bytes and renders the blob, so the browser never
* talks to the icon CDN. Only 5 is drawn here.
*
* The glyph catalog and its keyword rules live in the frontend on purpose. This
* is the only place that can actually *render* one, so a second copy in the
* backend would be a list to keep in sync and nothing more — the server only
* validates the shape of the value (`services/icon_service.py`).
* validates the shape of the stored value (`services/icon_service.py`).
*/
import {
Activity,
@@ -470,22 +479,40 @@ export function suggestIconName(name: string, extra = ""): string {
}
export type ResolvedIcon =
| { kind: "custom"; name: null }
| { kind: "image"; name: null }
| { kind: "builtin"; name: string; automatic: boolean };
/** What to draw for a stack, given its stored choice (or the lack of one). */
export function resolveStackIcon(stack: {
export interface IconStack {
id: string;
name: string;
icon?: string | null;
}): ResolvedIcon {
auto_icon?: string | null;
}
/** Whether a stored icon value names an image the server can serve. */
export function isImageIcon(value: string | null | undefined): boolean {
return Boolean(value && (value.startsWith("custom:") || value.startsWith("logo:")));
}
/** The image the icon endpoint would return for this stack, as an opaque cache
* key — the explicit choice if there is one, otherwise the matched logo. */
export function imageIconKey(stack: IconStack): string | null {
if (isImageIcon(stack.icon)) return stack.icon!;
if (!stack.icon && isImageIcon(stack.auto_icon)) return stack.auto_icon!;
return null;
}
/** What to draw for a stack, given its stored choice (or the lack of one). */
export function resolveStackIcon(stack: IconStack): ResolvedIcon {
const stored = stack.icon ?? "";
if (stored.startsWith("custom:")) return { kind: "custom", name: null };
if (isImageIcon(stored)) return { kind: "image", name: null };
if (stored.startsWith("lucide:")) {
const name = stored.slice("lucide:".length);
// An icon that was dropped from the catalog must not blank the row out.
// A glyph that was dropped from the catalog must not blank the row out.
if (STACK_ICONS[name]) return { kind: "builtin", name, automatic: false };
}
// No explicit choice: the app logo the server recognised, else a glyph.
if (!stored && isImageIcon(stack.auto_icon)) return { kind: "image", name: null };
return {
kind: "builtin",
name: suggestIconName(stack.name, stack.id),
+6 -2
View File
@@ -10,9 +10,12 @@ export interface StackSummary {
id: string;
name: string;
description?: string | null;
/** "lucide:<name>", "custom:<ext>:<version>", or null for the icon derived
* from the stack's name. See lib/stackIcons.ts. */
/** The explicit choice: "lucide:<name>", "logo:<slug>",
* "custom:<ext>:<version>", or null for automatic. See lib/stackIcons.ts. */
icon?: string | null;
/** Only when `icon` is null: the app logo the server matched the name to
* ("logo:<slug>"), or null when it recognised nothing. */
auto_icon?: string | null;
status: StackStatus;
service_count: number;
running_count: number;
@@ -50,6 +53,7 @@ export interface StackDetail {
name: string;
description?: string | null;
icon?: string | null;
auto_icon?: string | null;
status: StackStatus;
yaml: string;
env: string;