Give every stack an icon, and put the status on it (0.51.0)
Stacks were a name and a coloured dot. The dot carried the status but nothing
carried identity, so a list of twenty stacks read as twenty identical rows.
This gives each one an icon in front of its name and moves the status onto that
icon as a halo in the status colour, which is the thing the eye lands on anyway.
The constraint that shaped the design: people already have stacks. Asking them
to pick an icon for each one before the feature does anything would mean it
never gets used, so the icon is *derived* from the stack's name and the column
stays empty until somebody overrides it. ~700 keywords in 79 groups cover the
self-hosted long tail (jellyfin -> clapperboard, vaultwarden -> key,
home-assistant -> house) plus generic English and German terms; the longest
match wins, so photoprism beats a bare photo, and short keywords like "tv" only
match as whole words. No backfill, no migration, and a rename moves the icon
with it.
That is also why the catalog and the matcher live in the frontend. It is the
only place that can render an icon, so a copy in the backend would be a list to
keep in sync and nothing else. The server validates the shape of the stored
value and stores uploads; it never needs to know what "lucide:database" looks
like. An icon name that later leaves the catalog falls back to the derived one
rather than blanking the row.
Overriding happens in two places, because there are two moments: the editor
(holding a chosen file until the stack exists, since uploading needs an id) and
a click on the icon on the detail page, which is how a stack that has existed
for a year gets one without a trip through the editor.
Uploads are classified by their bytes, not by the filename or Content-Type the
browser claims, and land in ${DATA_DIR}/stack-icons/ under the stack id. SVG is
allowed — <img> does not execute it — but the endpoint serves every icon as an
attachment so one can never be opened as a document in the API's own origin. A
client-supplied "custom:" value is refused: the server mints those, so a stack
cannot be pointed at a file it does not own. Files follow the stack: replaced on
re-upload (including across formats, or the old one orphans), copied on clone,
removed on delete.
The one piece of plumbing worth knowing about: the icon endpoint needs the
bearer token like everything else, and an <img src> would not carry it. So
StackIcon fetches the bytes through the API client and renders the blob, keyed
on the stored value — which carries an upload timestamp precisely so a re-upload
changes the key and retires the cached image.
Covered by 22 backend tests (the value rules, byte-sniffing, the file lifecycle,
the API round-trip, and that the read-only role cannot change an icon) and 29
frontend ones for the matcher. The schema change was verified against a
hand-built pre-0.51 database: the column is added on start and existing rows
come back NULL, i.e. automatic. Not click-tested in a browser — no Docker in
this environment — so the row height the taller icon produces is unverified.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,29 @@ 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.51.0 — nothing to do
|
||||
|
||||
Stacks have icons now, and your existing ones already have theirs. The icon is
|
||||
derived from the stack's name at render time — `jellyfin` gets a clapperboard,
|
||||
`postgres` a database, `home-assistant` a house — so nothing is backfilled and
|
||||
nothing needs configuring. A stack whose name matches no keyword gets a neutral
|
||||
mark, and renaming a stack moves its icon with it.
|
||||
|
||||
**The status dot is gone.** In the stacks list and on the detail page the status
|
||||
is carried by the icon instead: a soft glow in the status colour, pulsing while
|
||||
an operation runs. The status badge next to it still spells the state out in
|
||||
words, so nothing depends on seeing the colour.
|
||||
|
||||
To override an icon, click it on the stack detail page (or the one beside the
|
||||
name field in the editor): keep it automatic, pick from the built-in catalog, or
|
||||
upload your own PNG / JPEG / GIF / WebP / SVG up to 512 KiB. That is admin-only
|
||||
and audited as `stack.icon`.
|
||||
|
||||
Two things change on disk, both handled on first start: the `stack` table gains
|
||||
a nullable `icon` column (empty = automatic), and uploaded images are written to
|
||||
`${DATA_DIR}/stack-icons/`. If you already back up the data volume, the icons
|
||||
ride along with it.
|
||||
|
||||
## Upgrading to 0.50.0 — nothing to do
|
||||
|
||||
Two robustness fixes, no configuration changes.
|
||||
@@ -160,7 +183,11 @@ it is what your saved destination credentials are encrypted with.
|
||||
- **Stack lifecycle** — create, edit, clone, delete, and `up / down / start /
|
||||
stop / restart / pull / update` via `docker compose`.
|
||||
- **Live status** — running / partial / stopped / error / updating, computed from
|
||||
Docker container labels.
|
||||
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.
|
||||
- **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
|
||||
@@ -319,6 +346,33 @@ it is what your saved destination credentials are encrypted with.
|
||||
container or connect any container on the host (`POST /api/networks/{id}/connect`
|
||||
/ `/disconnect`).
|
||||
|
||||
### 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.
|
||||
- **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
|
||||
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
|
||||
the status dot is gone: the icon carries a soft glow in the status colour
|
||||
(green running, amber partial, red error, pulsing blue while an operation
|
||||
runs). The status badge and tooltip still spell it out in words, so colour is
|
||||
never the only carrier.
|
||||
- Uploading and resetting an icon is admin-only and audited (`stack.icon`); the
|
||||
read-only role sees icons but cannot change them.
|
||||
|
||||
### Phase 24 — Design System v2 (analytics-style UI)
|
||||
|
||||
- **New shell**: the sidebar is gone — a fixed 60px top bar carries a pill
|
||||
@@ -684,6 +738,15 @@ GET /api/dashboard/funnel[?refresh=true] (stack-health funnel, 30s TTL cache)
|
||||
GET /api/dashboard/summary (containers, uptime series, ops activity)
|
||||
```
|
||||
|
||||
### 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)
|
||||
```
|
||||
|
||||
## Security notes
|
||||
|
||||
- The Docker socket is only ever touched by the backend process; it is never
|
||||
|
||||
Reference in New Issue
Block a user