diff --git a/README.md b/README.md index 9ae94f3..a8f2397 100644 --- a/README.md +++ b/README.md @@ -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:"} 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:" | "lucide:" | ""}) +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 diff --git a/backend/main.py b/backend/main.py index e28dfb7..7cb587d 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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) diff --git a/backend/routers/stacks.py b/backend/routers/stacks.py index 661785e..dba2655 100644 --- a/backend/routers/stacks.py +++ b/backend/routers/stacks.py @@ -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 ```` 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}"'}, ) diff --git a/backend/services/icon_service.py b/backend/services/icon_service.py index 5c4d18d..da7af64 100644 --- a/backend/services/icon_service.py +++ b/backend/services/icon_service.py @@ -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:`` + 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:`` 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:'; upload custom " - "images through POST /api/stacks/{id}/icon" + "Icon must be empty (automatic), 'lucide:' or 'logo:'; " + "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 # --------------------------------------------------------------------------- # diff --git a/backend/services/logo_service.py b/backend/services/logo_service.py new file mode 100644 index 0000000..b3b065e --- /dev/null +++ b/backend/services/logo_service.py @@ -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/.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]] diff --git a/backend/tests/test_logo_service.py b/backend/tests/test_logo_service.py new file mode 100644 index 0000000..d140e53 --- /dev/null +++ b/backend/tests/test_logo_service.py @@ -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") == [] diff --git a/backend/tests/test_route_authorization.py b/backend/tests/test_route_authorization.py index cece628..59e96a0 100644 --- a/backend/tests/test_route_authorization.py +++ b/backend/tests/test_route_authorization.py @@ -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 diff --git a/backend/version.py b/backend/version.py index e436766..96ee23f 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,3 +1,3 @@ """Single source of truth for the StackPilot release version.""" -APP_VERSION = "0.51.0" +APP_VERSION = "0.52.0" diff --git a/frontend/package.json b/frontend/package.json index e09e969..60a2dc8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "stackpilot-frontend", "private": true, - "version": "0.51.0", + "version": "0.52.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/api/stacks.ts b/frontend/src/api/stacks.ts index 43d4629..67f3287 100644 --- a/frontend/src/api/stacks.ts +++ b/frontend/src/api/stacks.ts @@ -52,6 +52,23 @@ export const stacksApi = { resetIcon: (id: string) => api.delete(`/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(`/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) => diff --git a/frontend/src/components/stacks/IconPicker.tsx b/frontend/src/components/stacks/IconPicker.tsx index fcbcc1c..b058e6a 100644 --- a/frontend/src/components/stacks/IconPicker.tsx +++ b/frontend/src/components/stacks/IconPicker.tsx @@ -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({ />
setQ(e.target.value)} /> @@ -128,12 +129,19 @@ export function IconPicker({
+ {groups.length === 0 && ( -

No icon matches “{q}”.

+

No symbol matches “{q}”.

)} + {groups.length > 0 &&

Symbols

} {groups.map((group) => (
-

{group.label}

+

{group.label}

{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 ( +
+

App logos

+
+
+ ); + } + if (!data) return null; + if (!data.ready) { + return ( +
+

App logos

+

+ The logo catalog has not been downloaded yet — it needs outbound + internet on the server, and is retried automatically. +

+
+ ); + } + if (data.icons.length === 0) { + return ( +
+

App logos

+

No app matches “{term}”.

+
+ ); + } + + return ( +
+

App logos

+
+ {data.icons.map((icon) => ( + + ))} +
+
+ ); +} + +/** 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(null); + + useEffect(() => { + if (!blob) { + setUrl(null); + return; + } + const objectUrl = URL.createObjectURL(blob); + setUrl(objectUrl); + return () => URL.revokeObjectURL(objectUrl); + }, [blob]); + + return ( + + {url ? ( + + ) : ( + + )} + + ); +} diff --git a/frontend/src/components/ui/StackIcon.tsx b/frontend/src/components/ui/StackIcon.tsx index 69ccbf7..5fab567 100644 --- a/frontend/src/components/ui/StackIcon.tsx +++ b/frontend/src/components/ui/StackIcon.tsx @@ -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 ( {custom ? ( - + // 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. + ) : ( )} @@ -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), diff --git a/frontend/src/lib/stackIcons.test.ts b/frontend/src/lib/stackIcons.test.ts index f1c64fb..98e902b 100644 --- a/frontend/src/lib/stackIcons.test.ts +++ b/frontend/src/lib/stackIcons.test.ts @@ -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(); + }); +}); diff --git a/frontend/src/lib/stackIcons.ts b/frontend/src/lib/stackIcons.ts index fe6dd6b..51b013f 100644 --- a/frontend/src/lib/stackIcons.ts +++ b/frontend/src/lib/stackIcons.ts @@ -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:"` — 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:"` — the real logo of a known app, picked by + * hand from the catalog. + * 3. `stack.icon === "lucide:"` — a glyph the user picked from here. + * 4. `stack.auto_icon === "logo:"` — 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), diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 9c3f99a..fba5933 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -10,9 +10,12 @@ export interface StackSummary { id: string; name: string; description?: string | null; - /** "lucide:", "custom::", or null for the icon derived - * from the stack's name. See lib/stackIcons.ts. */ + /** The explicit choice: "lucide:", "logo:", + * "custom::", 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:"), 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;