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