Give every stack an icon, and put the status on it (0.51.0)
CI / check (push) Successful in 12m14s
CI / build-and-push (push) Successful in 3m37s

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:
menzelj
2026-09-17 10:03:10 +02:00
co-authored by Claude Opus 5
parent a25741f579
commit 7682460b4f
17 changed files with 1739 additions and 25 deletions
+64 -1
View File
@@ -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
+7
View File
@@ -15,6 +15,10 @@ class Stack(SQLModel, table=True):
id: str = Field(primary_key=True)
name: str
description: Optional[str] = None
# None = automatic (the UI derives one from the name), "lucide:<name>" for a
# built-in icon, "custom:<ext>:<version>" for an uploaded image.
# See services/icon_service.py.
icon: Optional[str] = None
stacks_dir_override: Optional[str] = None
created_at: datetime = Field(default_factory=_now)
updated_at: datetime = Field(default_factory=_now)
@@ -26,6 +30,7 @@ class Stack(SQLModel, table=True):
class StackCreate(SQLModel):
name: str
description: Optional[str] = None
icon: Optional[str] = None # "lucide:<name>", or None/"" for automatic
yaml: Optional[str] = None # initial compose content
env: Optional[str] = None
@@ -33,6 +38,8 @@ class StackCreate(SQLModel):
class StackUpdate(SQLModel):
name: Optional[str] = None
description: Optional[str] = None
# Omitted leaves the icon alone; "" resets it to automatic.
icon: Optional[str] = None
yaml: Optional[str] = None
env: Optional[str] = None
+107 -4
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
import os
from dataclasses import asdict
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile
from fastapi.responses import FileResponse
from sqlmodel import Session, select
@@ -31,6 +31,7 @@ from services import (
audit_service,
auto_update_service,
compose_service,
icon_service,
notify_service,
stack_lock_service,
stats_service,
@@ -99,6 +100,7 @@ def _stack_summary(
"id": stack.id,
"name": stack.name,
"description": stack.description,
"icon": stack.icon,
"status": status,
"service_count": total,
"running_count": running,
@@ -137,10 +139,16 @@ def create_stack(
stack_id = compose_service.slugify(body.name)
if session.get(Stack, stack_id) or os.path.isdir(compose_service.stack_dir(stack_id)):
raise HTTPException(status_code=409, detail=f"Stack '{stack_id}' already exists")
try:
icon = icon_service.normalize_choice(body.icon or "")
except icon_service.IconError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
compose_service.write_compose(stack_id, body.yaml or "services:\n")
if body.env:
compose_service.write_env(stack_id, body.env)
stack = Stack(id=stack_id, name=body.name, description=body.description)
stack = Stack(
id=stack_id, name=body.name, description=body.description, icon=icon
)
session.add(stack)
session.commit()
session.refresh(stack)
@@ -185,6 +193,7 @@ def get_stack(
"id": stack.id,
"name": stack.name,
"description": stack.description,
"icon": stack.icon,
"status": status,
"yaml": compose_service.read_compose(stack_id),
# The .env is where credentials live by convention, so it is withheld
@@ -214,6 +223,16 @@ def update_stack(
stack.name = body.name
if body.description is not None:
stack.description = body.description
if body.icon is not None:
try:
icon = icon_service.normalize_choice(body.icon)
except icon_service.IconError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
# Switching to a built-in icon (or back to automatic) makes any
# uploaded image dead weight, so it goes with the choice.
if icon_service.custom_ext(stack.icon) and icon != stack.icon:
icon_service.remove(stack_id)
stack.icon = icon
stack.updated_at = compose_service.now()
session.add(stack)
session.commit()
@@ -240,6 +259,7 @@ async def delete_stack(
pass
if delete_files:
compose_service.delete_stack_files(stack_id)
icon_service.remove(stack_id)
session.delete(stack)
session.commit()
audit_service.record(
@@ -257,7 +277,7 @@ def clone_stack(
session: Session = Depends(get_session),
user: User = Depends(require_admin),
) -> dict:
_get_stack_or_404(session, stack_id)
source = _get_stack_or_404(session, stack_id)
new_id = compose_service.slugify(body.name)
if session.get(Stack, new_id):
raise HTTPException(status_code=409, detail=f"Stack '{new_id}' already exists")
@@ -265,7 +285,11 @@ def clone_stack(
compose_service.clone_stack_files(stack_id, new_id)
except compose_service.StackFileError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
stack = Stack(id=new_id, name=body.name)
stack = Stack(
id=new_id,
name=body.name,
icon=icon_service.copy(stack_id, new_id, source.icon),
)
session.add(stack)
session.commit()
session.refresh(stack)
@@ -276,6 +300,85 @@ def clone_stack(
return _stack_summary(stack)
# --------------------------------------------------------------------------- #
# icon
# --------------------------------------------------------------------------- #
@router.get("/{stack_id}/icon")
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.
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).
"""
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 ""
return FileResponse(
path,
media_type=icon_service.content_type(ext),
# 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}"'},
)
@router.post("/{stack_id}/icon")
async def upload_stack_icon(
stack_id: str,
request: Request,
file: UploadFile = File(...),
session: Session = Depends(get_session),
user: User = Depends(require_admin),
) -> dict:
"""Replace a stack's icon with an uploaded image."""
stack = _get_stack_or_404(session, stack_id)
data = await file.read(icon_service.MAX_ICON_BYTES + 1)
try:
stack.icon = icon_service.store_upload(stack_id, data)
except icon_service.IconError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
stack.updated_at = compose_service.now()
session.add(stack)
session.commit()
session.refresh(stack)
audit_service.record(
session, user=user.username, action="stack.icon", target=stack_id,
detail=f"uploaded {file.filename or 'image'}", ip=_client_ip(request),
)
return _stack_summary(stack)
@router.delete("/{stack_id}/icon")
def reset_stack_icon(
stack_id: str,
request: Request,
session: Session = Depends(get_session),
user: User = Depends(require_admin),
) -> dict:
"""Drop any explicit choice and go back to the name-derived icon."""
stack = _get_stack_or_404(session, stack_id)
icon_service.remove(stack_id)
stack.icon = None
stack.updated_at = compose_service.now()
session.add(stack)
session.commit()
session.refresh(stack)
audit_service.record(
session, user=user.username, action="stack.icon", target=stack_id,
detail="reset to automatic", ip=_client_ip(request),
)
return _stack_summary(stack)
# --------------------------------------------------------------------------- #
# lifecycle
# --------------------------------------------------------------------------- #
+209
View File
@@ -0,0 +1,209 @@
"""Stack icons — validation of the stored choice and custom-image storage.
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.
``lucide:<name>``
A built-in icon the user picked explicitly. The catalog of names lives in
the frontend because that is the only place that can actually *render* one;
keeping a second copy here would only add a list to drift out of sync. An
unknown name is therefore not an error — the UI falls back to the automatic
icon for it.
``custom:<ext>:<version>``
An uploaded image at ``${DATA_DIR}/stack-icons/<stack_id>.<ext>``.
``<version>`` is the upload's unix timestamp. It carries no meaning beyond
changing the column value on every re-upload, which is what makes the
frontend's cache key change and the new image appear instead of the one the
browser already has.
"""
from __future__ import annotations
import logging
import os
import re
import shutil
import time
from typing import Optional
from config import settings
logger = logging.getLogger("stackpilot.icons")
#: Uploads are meant to be small app logos. The cap is deliberately generous
#: for a logo and still far too small to make the data directory grow.
MAX_ICON_BYTES = 512 * 1024
#: Extension per accepted image type. The extension is derived from the bytes
#: (see :func:`_sniff`), never from the upload's filename or Content-Type — a
#: client is free to lie about both.
_EXTENSIONS = {"png", "jpg", "gif", "webp", "svg"}
_BUILTIN_RE = re.compile(r"^lucide:[a-z0-9-]{1,48}$")
_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}$")
#: ``<img src>`` never executes script, but a custom icon is also reachable
#: directly under /api/..., where an SVG *would* run in the browser's own
#: context. Serving it as a download-only attachment keeps that door shut.
_CONTENT_TYPES = {
"png": "image/png",
"jpg": "image/jpeg",
"gif": "image/gif",
"webp": "image/webp",
"svg": "image/svg+xml",
}
class IconError(Exception):
"""An icon value or upload the server refuses."""
# --------------------------------------------------------------------------- #
# Paths
# --------------------------------------------------------------------------- #
def icon_dir() -> str:
return os.path.join(settings.DATA_DIR, "stack-icons")
def _safe_id(stack_id: str) -> str:
"""Reject anything that could escape the icon directory.
Stack ids are slugs, so this never fires in practice — it is here because
the id arrives from the URL and is about to be pasted into a filesystem
path.
"""
if not _SAFE_ID_RE.match(stack_id) or stack_id in (".", ".."):
raise IconError(f"Invalid stack id '{stack_id}'")
return stack_id
def custom_path(stack_id: str, ext: str) -> str:
return os.path.join(icon_dir(), f"{_safe_id(stack_id)}.{ext}")
def custom_ext(value: Optional[str]) -> Optional[str]:
"""The file extension of a ``custom:`` icon value, or None for the rest."""
match = _CUSTOM_RE.match(value or "")
return match.group(1) if match else None
def content_type(ext: str) -> str:
return _CONTENT_TYPES.get(ext, "application/octet-stream")
def file_for(stack_id: str, value: Optional[str]) -> Optional[str]:
"""Existing file backing a ``custom:`` icon value, or None."""
ext = custom_ext(value)
if not ext:
return None
path = custom_path(stack_id, ext)
return path if os.path.isfile(path) else None
# --------------------------------------------------------------------------- #
# Stored value
# --------------------------------------------------------------------------- #
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.
"""
value = (value or "").strip()
if not value:
return None
if _BUILTIN_RE.match(value):
return value
raise IconError(
"Icon must be empty (automatic) or 'lucide:<name>'; upload custom "
"images through POST /api/stacks/{id}/icon"
)
# --------------------------------------------------------------------------- #
# Uploads
# --------------------------------------------------------------------------- #
def _sniff(data: bytes) -> str:
"""Extension for the image these bytes actually are.
Trusting the declared Content-Type would mean storing (and later serving)
whatever a client cares to send under an image's name.
"""
if data.startswith(b"\x89PNG\r\n\x1a\n"):
return "png"
if data.startswith(b"\xff\xd8\xff"):
return "jpg"
if data.startswith(b"GIF87a") or data.startswith(b"GIF89a"):
return "gif"
if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
return "webp"
head = data[:512].lstrip()
if head.startswith(b"<?xml") or head.startswith(b"<svg") or b"<svg" in head:
return "svg"
raise IconError("Unsupported image type — use PNG, JPEG, GIF, WebP or SVG")
def store_upload(stack_id: str, data: bytes) -> str:
"""Write an uploaded icon and return the value for ``Stack.icon``."""
if not data:
raise IconError("The uploaded file is empty")
if len(data) > MAX_ICON_BYTES:
raise IconError(
f"Icon is too large ({len(data) // 1024} KiB); "
f"the limit is {MAX_ICON_BYTES // 1024} KiB"
)
ext = _sniff(data)
os.makedirs(icon_dir(), exist_ok=True)
# A re-upload in a different format would otherwise leave the old file
# behind as an orphan nothing ever cleans up.
remove(stack_id)
path = custom_path(stack_id, ext)
with open(path, "wb") as fh:
fh.write(data)
return f"custom:{ext}:{int(time.time())}"
def remove(stack_id: str) -> None:
"""Delete every custom icon file belonging to a stack. Best effort."""
for ext in _EXTENSIONS:
try:
os.remove(custom_path(stack_id, ext))
except FileNotFoundError:
continue
except OSError as exc: # noqa: PERF203 - one bad file must not block the rest
logger.warning("Could not remove icon %s.%s: %s", stack_id, ext, exc)
def copy(src_id: str, dst_id: str, value: Optional[str]) -> Optional[str]:
"""Copy a stack's custom icon to another stack (used when cloning).
Returns the icon value for the new stack: the copied ``custom:`` value, the
unchanged built-in choice, or None when there is nothing to carry over.
"""
ext = custom_ext(value)
if not ext:
return value
source = file_for(src_id, value)
if not source:
return None
os.makedirs(icon_dir(), exist_ok=True)
try:
shutil.copyfile(source, custom_path(dst_id, ext))
except OSError as exc:
logger.warning("Could not copy icon %s -> %s: %s", src_id, dst_id, exc)
return None
return f"custom:{ext}:{int(time.time())}"
@@ -63,6 +63,9 @@ USER_READABLE = {
"GET /api/stacks/updates",
"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
# stack's name in the list. Uploading and resetting it stay admin-only.
"GET /api/stacks/{stack_id}/icon",
"GET /api/stacks/{stack_id}/logs",
"GET /api/stacks/{stack_id}/services/{service}/logs",
"POST /api/stacks/convert",
+219
View File
@@ -0,0 +1,219 @@
"""Stack icons: what the server accepts, stores and hands back.
The interesting part is not "does a column round-trip" — it is the three rules
that keep the feature from becoming a liability:
* an icon value can only ever be the automatic one or a built-in name; a
``custom:`` value is minted by the server, so a stack can never be pointed at
another stack's uploaded file,
* an upload is classified by its bytes, not by what the client claims it is, and
* files follow the stack: replaced, cloned and deleted along with it, so the
data directory does not fill up with icons of stacks that are long gone.
"""
from __future__ import annotations
import os
import pytest
PNG = (
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01"
b"\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDAT\x78\x9cc\x00"
b"\x01\x00\x00\x05\x00\x01\x0d\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82"
)
SVG = b'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"></svg>'
@pytest.fixture
def svc(db):
from services import icon_service
return icon_service
@pytest.fixture
def stack(db):
"""A bare stack row to hang icons off, cleaned up afterwards."""
from sqlmodel import Session
from database import engine
from models.stack import Stack
from services import icon_service
stack_id = "icon-test-stack"
with Session(engine) as session:
if (existing := session.get(Stack, stack_id)) is not None:
session.delete(existing)
session.commit()
session.add(Stack(id=stack_id, name="Icon Test Stack"))
session.commit()
yield stack_id
icon_service.remove(stack_id)
with Session(engine) as session:
if (row := session.get(Stack, stack_id)) is not None:
session.delete(row)
session.commit()
# --------------------------------------------------------------------------- #
# The stored value
# --------------------------------------------------------------------------- #
def test_a_builtin_choice_is_stored_as_given(svc):
assert svc.normalize_choice("lucide:database") == "lucide:database"
def test_an_empty_choice_means_automatic(svc):
assert svc.normalize_choice("") is None
assert svc.normalize_choice(" ") is None
@pytest.mark.parametrize(
"value",
[
# A client-supplied custom value would name a file: that is how one
# stack would end up serving another's icon.
"custom:png:1",
"custom:../other:1",
"lucide:../../etc/passwd",
"lucide:Database", # the catalog is lower-kebab; anything else is a typo
"https://example.com/logo.png",
"<script>alert(1)</script>",
],
)
def test_anything_else_is_refused(svc, value):
with pytest.raises(svc.IconError):
svc.normalize_choice(value)
# --------------------------------------------------------------------------- #
# Uploads
# --------------------------------------------------------------------------- #
def test_an_upload_is_typed_by_its_bytes(svc, stack):
assert svc.store_upload(stack, PNG).startswith("custom:png:")
assert svc.store_upload(stack, SVG).startswith("custom:svg:")
def test_a_file_that_is_not_an_image_is_refused(svc, stack):
with pytest.raises(svc.IconError):
svc.store_upload(stack, b"#!/bin/sh\nrm -rf /\n")
def test_an_oversized_image_is_refused(svc, stack):
payload = PNG + b"\x00" * svc.MAX_ICON_BYTES
with pytest.raises(svc.IconError):
svc.store_upload(stack, payload)
def test_reuploading_in_another_format_leaves_no_orphan(svc, stack):
svc.store_upload(stack, PNG)
assert os.path.isfile(svc.custom_path(stack, "png"))
value = svc.store_upload(stack, SVG)
assert not os.path.exists(svc.custom_path(stack, "png"))
assert svc.file_for(stack, value) == svc.custom_path(stack, "svg")
def test_the_value_changes_on_every_upload(svc, stack, monkeypatch):
"""Otherwise a browser keeps showing the image it already cached."""
monkeypatch.setattr("services.icon_service.time.time", lambda: 1000)
first = svc.store_upload(stack, PNG)
monkeypatch.setattr("services.icon_service.time.time", lambda: 2000)
assert svc.store_upload(stack, PNG) != first
def test_removing_clears_the_file(svc, stack):
value = svc.store_upload(stack, PNG)
svc.remove(stack)
assert svc.file_for(stack, value) is None
def test_a_clone_gets_its_own_copy(svc, stack):
value = svc.store_upload(stack, PNG)
copied = svc.copy(stack, "icon-test-clone", value)
try:
assert copied is not None and copied.startswith("custom:png:")
assert os.path.isfile(svc.custom_path("icon-test-clone", "png"))
# Deleting the source must not take the clone's icon with it.
svc.remove(stack)
assert svc.file_for("icon-test-clone", copied)
finally:
svc.remove("icon-test-clone")
def test_cloning_a_builtin_choice_carries_the_name_over(svc):
assert svc.copy("a", "b", "lucide:database") == "lucide:database"
assert svc.copy("a", "b", None) is None
# --------------------------------------------------------------------------- #
# Through the API
# --------------------------------------------------------------------------- #
def test_the_list_row_carries_the_icon(as_admin, stack, svc):
from sqlmodel import Session
from database import engine
from models.stack import Stack
with Session(engine) as session:
row = session.get(Stack, stack)
row.icon = "lucide:database"
session.add(row)
session.commit()
body = as_admin.get(f"/api/stacks/{stack}").json()
assert body["icon"] == "lucide:database"
def test_upload_download_and_reset_round_trip(as_admin, stack):
uploaded = as_admin.post(
f"/api/stacks/{stack}/icon",
files={"file": ("logo.png", PNG, "image/png")},
)
assert uploaded.status_code == 200, uploaded.text
assert uploaded.json()["icon"].startswith("custom:png:")
served = as_admin.get(f"/api/stacks/{stack}/icon")
assert served.status_code == 200
assert served.content == PNG
assert served.headers["content-type"] == "image/png"
# An SVG icon must never be openable as a document in the API's own origin.
assert served.headers["content-disposition"].startswith("attachment")
assert as_admin.delete(f"/api/stacks/{stack}/icon").json()["icon"] is None
assert as_admin.get(f"/api/stacks/{stack}/icon").status_code == 404
def test_a_lying_content_type_does_not_get_through(as_admin, stack):
response = as_admin.post(
f"/api/stacks/{stack}/icon",
files={"file": ("logo.png", b"not an image at all", "image/png")},
)
assert response.status_code == 400
def test_switching_to_a_builtin_icon_drops_the_uploaded_file(as_admin, stack, svc):
as_admin.post(
f"/api/stacks/{stack}/icon", files={"file": ("logo.png", PNG, "image/png")}
)
as_admin.put(f"/api/stacks/{stack}", json={"icon": "lucide:database"})
assert not os.path.exists(svc.custom_path(stack, "png"))
def test_an_invalid_icon_on_update_is_a_400(as_admin, stack):
response = as_admin.put(f"/api/stacks/{stack}", json={"icon": "custom:png:1"})
assert response.status_code == 400
def test_the_read_only_role_cannot_change_an_icon(as_user, stack):
assert (
as_user.post(
f"/api/stacks/{stack}/icon", files={"file": ("l.png", PNG, "image/png")}
).status_code
== 403
)
assert as_user.delete(f"/api/stacks/{stack}/icon").status_code == 403
+1 -1
View File
@@ -1,3 +1,3 @@
"""Single source of truth for the StackPilot release version."""
APP_VERSION = "0.50.0"
APP_VERSION = "0.51.0"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "stackpilot-frontend",
"private": true,
"version": "0.50.0",
"version": "0.51.0",
"type": "module",
"scripts": {
"dev": "vite",
+32 -4
View File
@@ -8,10 +8,24 @@ export const stacksApi = {
api.get<Record<string, StackUpdateInfo>>("/api/stacks/updates").then((r) => r.data),
get: (id: string) =>
api.get<StackDetail>(`/api/stacks/${id}`).then((r) => r.data),
create: (body: { name: string; description?: string; yaml?: string; env?: string }) =>
api.post<StackSummary>("/api/stacks", body).then((r) => r.data),
update: (id: string, body: { name?: string; description?: string; yaml?: string; env?: string }) =>
api.put<StackSummary>(`/api/stacks/${id}`, body).then((r) => r.data),
create: (body: {
name: string;
description?: string;
icon?: string;
yaml?: string;
env?: string;
}) => api.post<StackSummary>("/api/stacks", body).then((r) => r.data),
update: (
id: string,
body: {
name?: string;
description?: string;
/** "lucide:<name>", or "" to go back to the name-derived icon. */
icon?: string;
yaml?: string;
env?: string;
}
) => api.put<StackSummary>(`/api/stacks/${id}`, body).then((r) => r.data),
remove: (id: string, deleteFiles = true) =>
api.delete(`/api/stacks/${id}?delete_files=${deleteFiles}`).then((r) => r.data),
clone: (id: string, name: string) =>
@@ -24,6 +38,20 @@ export const stacksApi = {
update_images: (id: string) => api.post(`/api/stacks/${id}/update`).then((r) => r.data),
down: (id: string) => api.post(`/api/stacks/${id}/down`).then((r) => r.data),
/** The uploaded icon, fetched through the client so it carries the token —
* an <img src> pointed at this URL would be unauthenticated. */
icon: (id: string) =>
api.get<Blob>(`/api/stacks/${id}/icon`, { responseType: "blob" }).then((r) => r.data),
uploadIcon: (id: string, file: File) => {
const form = new FormData();
form.append("file", file);
return api
.post<StackSummary>(`/api/stacks/${id}/icon`, form)
.then((r) => r.data);
},
resetIcon: (id: string) =>
api.delete<StackSummary>(`/api/stacks/${id}/icon`).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) =>
@@ -0,0 +1,237 @@
import { useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useQueryClient } from "@tanstack/react-query";
import { 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 { stacksApi } from "@/api/stacks";
import { apiErrorMessage } from "@/api/client";
import type { StackStatus } from "@/types";
/** Matches services/icon_service.py — the server rejects anything larger. */
const MAX_ICON_BYTES = 512 * 1024;
const ACCEPTED = "image/png,image/jpeg,image/gif,image/webp,image/svg+xml";
/**
* Pick a stack's icon: keep the automatic one, choose a built-in, or upload an
* image.
*
* The dialog itself is stateless — it reports the choice and lets the caller
* decide what to do with it, because the two callers differ: the editor holds
* the choice until the stack is saved (a stack being created has no id to
* upload to yet), while the detail page applies it immediately.
*/
export function IconPicker({
stack,
status = "stopped",
previewUrl,
busy = false,
onSelect,
onUpload,
onClose,
}: {
stack: { id: string; name: string; icon?: string | null };
status?: StackStatus;
previewUrl?: string | null;
busy?: boolean;
/** "" means "back to the icon derived from the name". */
onSelect: (icon: string) => void;
onUpload: (file: File) => void;
onClose: () => void;
}) {
const [q, setQ] = useState("");
const fileInput = useRef<HTMLInputElement>(null);
const automatic = useMemo(
() => suggestIconName(stack.name, stack.id),
[stack.name, stack.id]
);
const isAutomatic = !stack.icon;
const groups = useMemo(() => {
const needle = q.trim().toLowerCase();
if (!needle) return ICON_GROUPS;
return ICON_GROUPS.map((group) => ({
label: group.label,
icons: Object.fromEntries(
Object.entries(group.icons).filter(([name]) => name.includes(needle))
),
})).filter((group) => Object.keys(group.icons).length > 0);
}, [q]);
const pickFile = (file: File | undefined) => {
if (!file) return;
if (file.size > MAX_ICON_BYTES) {
toast.error(`That image is ${Math.round(file.size / 1024)} KiB; the limit is 512 KiB.`);
return;
}
onUpload(file);
};
return createPortal(
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onClick={() => !busy && onClose()}
>
<div
className="flex max-h-full w-full max-w-lg flex-col overflow-hidden rounded-xl border border-slate-200 bg-card shadow-xl dark:border-slate-700 dark:bg-card-dark"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-3 border-b border-slate-200 p-4 dark:border-slate-700">
<StackIcon stack={stack} status={status} size="lg" previewUrl={previewUrl} />
<div className="min-w-0 flex-1">
<h2 className="sp-heading text-lg">Stack icon</h2>
<p className="truncate text-sm text-slate-500">{stack.name || "New stack"}</p>
</div>
<button
onClick={onClose}
disabled={busy}
title="Close"
className="rounded-lg p-1.5 text-slate-500 hover:bg-slate-100 disabled:opacity-40 dark:hover:bg-slate-700"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="flex flex-wrap items-center gap-2 border-b border-slate-200 p-4 dark:border-slate-700">
<Button
variant={isAutomatic ? "primary" : "outline"}
onClick={() => onSelect("")}
disabled={busy}
title={`Derived from the stack name (currently “${automatic}”)`}
>
<Sparkles className="h-4 w-4" /> Automatic
</Button>
<Button variant="outline" onClick={() => fileInput.current?.click()} disabled={busy}>
<Upload className="h-4 w-4" /> Upload image
</Button>
<input
ref={fileInput}
type="file"
accept={ACCEPTED}
className="hidden"
onChange={(e) => {
pickFile(e.target.files?.[0]);
// Reset, or picking the same file twice fires no change event.
e.target.value = "";
}}
/>
<div className="relative min-w-[140px] flex-1">
<Input
placeholder="Search icons…"
value={q}
onChange={(e) => setQ(e.target.value)}
/>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-4">
{groups.length === 0 && (
<p className="text-sm text-slate-500">No icon matches {q}.</p>
)}
{groups.map((group) => (
<section key={group.label} className="mb-4 last:mb-0">
<h3 className="sp-label mb-2">{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}`;
return (
<button
key={name}
title={name}
disabled={busy}
onClick={() => onSelect(`lucide:${name}`)}
className={cn(
"flex aspect-square items-center justify-center rounded-lg border transition-colors",
"disabled:opacity-40",
selected
? "border-accent bg-accent/10 text-accent dark:text-accent-dark"
: "border-transparent text-slate-500 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-700",
// The automatic pick is called out so it is obvious what
// "Automatic" would give you.
!selected &&
isAutomatic &&
name === automatic &&
"border-slate-300 dark:border-slate-600"
)}
>
<Glyph className="h-[18px] w-[18px]" />
</button>
);
})}
</div>
</section>
))}
</div>
<p className="border-t border-slate-200 px-4 py-2.5 text-xs text-slate-500 dark:border-slate-700">
PNG, JPEG, GIF, WebP or SVG, up to 512 KiB. Square images look best
anything else is cropped to fit.
</p>
</div>
</div>,
document.body
);
}
/**
* The stack's icon, clickable: opens the picker and applies the choice right
* away. Used on the stack detail page, so an existing stack can be given an
* icon without going through the editor.
*/
export function StackIconEditor({
stack,
status,
size = "lg",
editable = true,
}: {
stack: { id: string; name: string; icon?: string | null };
status: StackStatus;
size?: "sm" | "md" | "lg";
/** The read-only role sees the icon but cannot change it. */
editable?: boolean;
}) {
const qc = useQueryClient();
const [open, setOpen] = useState(false);
const [busy, setBusy] = useState(false);
const apply = async (call: Promise<unknown>) => {
setBusy(true);
try {
await call;
qc.invalidateQueries({ queryKey: ["stack", stack.id] });
qc.invalidateQueries({ queryKey: ["stacks"] });
setOpen(false);
} catch (err) {
toast.error(apiErrorMessage(err));
} finally {
setBusy(false);
}
};
if (!editable) return <StackIcon stack={stack} status={status} size={size} />;
return (
<>
<button
onClick={() => setOpen(true)}
title="Change icon"
className="rounded-[15px] outline-none ring-offset-2 transition-opacity hover:opacity-80 focus-visible:ring-2 focus-visible:ring-accent dark:ring-offset-slate-900"
>
<StackIcon stack={stack} status={status} size={size} />
</button>
{open && (
<IconPicker
stack={stack}
status={status}
busy={busy}
onSelect={(icon) => apply(stacksApi.update(stack.id, { icon }))}
onUpload={(file) => apply(stacksApi.uploadIcon(stack.id, file))}
onClose={() => setOpen(false)}
/>
)}
</>
);
}
@@ -3,8 +3,9 @@ import { Link } from "react-router-dom";
import { useQueryClient } from "@tanstack/react-query";
import { Play, Square, RotateCw, ArrowUpCircle, Pencil, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { Card, Spinner, StatusDot, Badge } from "@/components/ui";
import { Card, Spinner, Badge } from "@/components/ui";
import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
import { StackIcon } from "@/components/ui/StackIcon";
import { formatBytes } from "@/lib/utils";
import { stacksApi } from "@/api/stacks";
import { apiErrorMessage } from "@/api/client";
@@ -161,9 +162,11 @@ function StackRow({
<div className="flex items-center gap-2">
<Link
to={`/stacks/${stack.id}`}
className="flex shrink-0 items-center gap-2"
className="flex shrink-0 items-center gap-2.5"
>
<StatusDot status={stack.status} />
{/* The icon carries the status (a halo in the status colour), which
is why there is no separate dot here any more. */}
<StackIcon stack={stack} status={stack.status} />
<span className="font-medium">{stack.name}</span>
<Badge status={stack.status}>{stack.status}</Badge>
<span className="text-xs text-slate-400">
+153
View File
@@ -0,0 +1,153 @@
import { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { cn } from "@/lib/utils";
import { iconComponent, resolveStackIcon } from "@/lib/stackIcons";
import { stacksApi } from "@/api/stacks";
import type { StackStatus } from "@/types";
/**
* A stack's icon, wearing its status as a coloured glow.
*
* This replaces the status dot in the stacks list: the same information, but
* 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.
*/
type Size = "sm" | "md" | "lg";
const SIZES: Record<Size, { box: string; glyph: string; radius: string }> = {
sm: { box: "h-7 w-7", glyph: "h-3.5 w-3.5", radius: "rounded-[9px]" },
md: { box: "h-9 w-9", glyph: "h-[18px] w-[18px]", radius: "rounded-[11px]" },
lg: { box: "h-12 w-12", glyph: "h-6 w-6", radius: "rounded-[15px]" },
};
/** Ring + halo per status. Kept as whole class strings because Tailwind only
* sees classes it can find literally in the source. */
const STATUS_STYLE: Record<StackStatus, { ring: string; halo: string; glyph: string }> = {
running: {
ring: "ring-green-500/60 dark:ring-green-400/60",
halo: "bg-green-500/40 dark:bg-green-400/40",
glyph: "text-green-600 dark:text-green-400",
},
partial: {
ring: "ring-yellow-500/60 dark:ring-yellow-400/60",
halo: "bg-yellow-500/40 dark:bg-yellow-400/40",
glyph: "text-yellow-600 dark:text-yellow-400",
},
stopped: {
ring: "ring-slate-400/50 dark:ring-slate-500/50",
halo: "bg-slate-400/20 dark:bg-slate-500/20",
glyph: "text-slate-500 dark:text-slate-400",
},
error: {
ring: "ring-red-500/60 dark:ring-red-400/60",
halo: "bg-red-500/45 dark:bg-red-400/45",
glyph: "text-red-600 dark:text-red-400",
},
updating: {
ring: "ring-sky-500/60 dark:ring-sky-400/60",
halo: "bg-sky-500/45 dark:bg-sky-400/45",
glyph: "text-sky-600 dark:text-sky-400",
},
unknown: {
ring: "ring-slate-300/60 dark:ring-slate-600/60",
halo: "bg-slate-300/20 dark:bg-slate-600/20",
glyph: "text-slate-400 dark:text-slate-500",
},
};
export function StackIcon({
stack,
status,
size = "md",
previewUrl,
className,
}: {
stack: { id: string; name: string; icon?: string | null };
status: StackStatus;
size?: Size;
/** Shows this image instead of the stored icon — used to preview a file that
* has been chosen but not uploaded yet (a stack being created). */
previewUrl?: string | null;
className?: string;
}) {
const resolved = resolveStackIcon(stack);
const dims = SIZES[size];
const tone = STATUS_STYLE[status] ?? STATUS_STYLE.unknown;
const stored = useCustomIconUrl(
stack.id,
resolved.kind === "custom" && !previewUrl ? stack.icon : null
);
const custom = previewUrl ?? stored;
const Glyph = iconComponent(resolved.kind === "builtin" ? resolved.name : "");
return (
<span
className={cn("relative inline-flex shrink-0", dims.box, className)}
role="img"
aria-label={`${stack.name}${status}`}
title={status}
>
{/* The status "shimmer": a blurred copy of the status colour bleeding out
from behind the tile. Pulses while an operation is running. */}
<span
aria-hidden
className={cn(
"pointer-events-none absolute -inset-[3px] blur-[5px]",
dims.radius,
tone.halo,
status === "updating" && "animate-pulse"
)}
/>
<span
className={cn(
"relative inline-flex h-full w-full items-center justify-center overflow-hidden",
"bg-sp-surface-2 ring-2",
dims.radius,
tone.ring
)}
>
{custom ? (
<img src={custom} alt="" className="h-full w-full object-cover" />
) : (
<Glyph className={cn(dims.glyph, tone.glyph)} strokeWidth={2} />
)}
</span>
</span>
);
}
/**
* Object URL for a stack's uploaded 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.
*/
function useCustomIconUrl(stackId: string, icon: string | null | undefined): string | null {
const { data: blob } = useQuery({
queryKey: ["stack-icon", stackId, icon],
queryFn: () => stacksApi.icon(stackId),
enabled: Boolean(icon),
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);
// Every mount makes its own URL; without this each row would leak one for
// the lifetime of the tab.
return () => URL.revokeObjectURL(objectUrl);
}, [blob]);
return url;
}
+109
View File
@@ -0,0 +1,109 @@
/**
* The name → icon matcher.
*
* This is the part of the feature that has to be right without anyone touching
* it: every stack that existed before icons landed gets its icon from here, and
* a bad guess is visible on the very first screen of the app.
*/
import { describe, expect, it } from "vitest";
import {
FALLBACK_ICON,
STACK_ICONS,
resolveStackIcon,
suggestIconName,
} from "./stackIcons";
describe("suggestIconName", () => {
it.each([
["jellyfin", "clapperboard"],
["Plex Media Server", "clapperboard"],
["radarr", "film"],
["sonarr", "tv"],
["qBittorrent", "download"],
["Postgres", "database"],
["mariadb", "database"],
["Traefik", "network"],
["pihole", "shield"],
["Vaultwarden", "key-round"],
["gitea", "git-branch"],
["Home Assistant", "home"],
["Uptime Kuma", "activity"],
["nextcloud", "cloud"],
["paperless-ngx", "file-text"],
["Immich", "image"],
["n8n", "workflow"],
["Minecraft Server", "gamepad-2"],
["ollama + open-webui", "brain"],
])("maps %s to %s", (name, icon) => {
expect(suggestIconName(name)).toBe(icon);
});
it("finds the app inside a longer name", () => {
expect(suggestIconName("my-jellyfin-stack")).toBe("clapperboard");
expect(suggestIconName("Mediaserver Wohnzimmer")).toBe("clapperboard");
});
it("prefers the more specific keyword", () => {
// "home-assistant" and "home" both match; the longer one wins.
expect(suggestIconName("home-assistant")).toBe("home");
expect(suggestIconName("photoprism")).toBe("image");
// The named app beats the generic word it sits next to.
expect(suggestIconName("radarr media")).toBe("film");
});
it("only matches short keywords as whole words", () => {
// "tv" must not fire on "tvorba" or a random substring...
expect(suggestIconName("Motvind")).toBe(FALLBACK_ICON);
// ...but does on its own.
expect(suggestIconName("tv box")).toBe("tv");
});
it("falls back for a name that means nothing", () => {
expect(suggestIconName("zzz-42")).toBe(FALLBACK_ICON);
expect(suggestIconName("")).toBe(FALLBACK_ICON);
});
it("falls back to the id when the name says nothing", () => {
expect(suggestIconName("Stack 1", "jellyfin")).toBe("clapperboard");
// The name still wins when it matches on its own.
expect(suggestIconName("postgres", "jellyfin")).toBe("database");
});
it("only ever returns an icon that exists", () => {
const names = ["jellyfin", "nothing-at-all", "grafana", "wireguard", "mealie"];
for (const name of names) {
expect(STACK_ICONS[suggestIconName(name)]).toBeDefined();
}
});
});
describe("resolveStackIcon", () => {
it("uses an explicit built-in choice", () => {
expect(
resolveStackIcon({ id: "jellyfin", name: "Jellyfin", icon: "lucide:database" })
).toEqual({ kind: "builtin", name: "database", automatic: false });
});
it("reports an uploaded image", () => {
expect(
resolveStackIcon({ id: "x", name: "X", icon: "custom:png:123" }).kind
).toBe("custom");
});
it("derives one when nothing is stored", () => {
expect(resolveStackIcon({ id: "plex", name: "Plex", icon: null })).toEqual({
kind: "builtin",
name: "clapperboard",
automatic: true,
});
});
it("does not blank the row out for an icon that left the catalog", () => {
const resolved = resolveStackIcon({
id: "plex",
name: "Plex",
icon: "lucide:no-such-icon",
});
expect(resolved).toEqual({ kind: "builtin", name: "clapperboard", automatic: true });
});
});
+499
View File
@@ -0,0 +1,499 @@
/**
* Stack icons — the built-in catalog and the name → icon matcher.
*
* 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.
*
* 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
* 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`).
*/
import {
Activity,
AlarmClock,
Archive,
Atom,
Banknote,
BarChart3,
Bell,
Blocks,
BookOpen,
Bot,
Box,
Boxes,
Brain,
Briefcase,
Brush,
Bug,
Cable,
Calendar,
Camera,
Car,
Clapperboard,
ClipboardList,
Cloud,
Code,
Coins,
Compass,
Container,
Cpu,
CreditCard,
Database,
Dna,
Download,
Dumbbell,
EarthLock,
Feather,
FileText,
Film,
Flame,
Folder,
Gamepad2,
Gauge,
Gift,
GitBranch,
Globe,
HardDrive,
Headphones,
HeartPulse,
Highlighter,
Home,
Image,
Inbox,
KeyRound,
Layers,
LayoutDashboard,
Leaf,
Library,
Lightbulb,
LineChart,
Link,
ListChecks,
Lock,
Mail,
Map,
MessageCircle,
Mic,
Monitor,
Music,
Network,
Newspaper,
NotebookPen,
Package,
Palette,
PenTool,
Phone,
PieChart,
Plane,
Play,
Plug,
Printer,
Radar,
Radio,
RefreshCw,
Rocket,
Router,
Rss,
SatelliteDish,
Scan,
ScrollText,
Search,
Server,
Settings,
Share2,
Shield,
ShieldCheck,
ShoppingCart,
Signal,
Siren,
Smartphone,
Speaker,
Sparkles,
Star,
Sun,
Tag,
Terminal,
Thermometer,
Ticket,
Timer,
Tv,
Upload,
Users,
Utensils,
Video,
Wallet,
Waves,
Webhook,
Wifi,
Wind,
Workflow,
Wrench,
Zap,
type LucideIcon,
} from "lucide-react";
/** Every icon a stack can be given, keyed by the name stored in the database
* (`lucide:<key>`). Grouped for the picker; the flat map is derived below. */
export const ICON_GROUPS: { label: string; icons: Record<string, LucideIcon> }[] = [
{
label: "General",
icons: {
boxes: Boxes,
box: Box,
package: Package,
container: Container,
layers: Layers,
blocks: Blocks,
rocket: Rocket,
sparkles: Sparkles,
star: Star,
zap: Zap,
flame: Flame,
tag: Tag,
gift: Gift,
ticket: Ticket,
briefcase: Briefcase,
feather: Feather,
},
},
{
label: "Infrastructure",
icons: {
server: Server,
cpu: Cpu,
"hard-drive": HardDrive,
database: Database,
cloud: Cloud,
network: Network,
router: Router,
cable: Cable,
globe: Globe,
plug: Plug,
monitor: Monitor,
printer: Printer,
smartphone: Smartphone,
},
},
{
label: "Media",
icons: {
clapperboard: Clapperboard,
film: Film,
tv: Tv,
video: Video,
camera: Camera,
image: Image,
music: Music,
headphones: Headphones,
speaker: Speaker,
mic: Mic,
play: Play,
"gamepad-2": Gamepad2,
library: Library,
"book-open": BookOpen,
},
},
{
label: "Network & security",
icons: {
shield: Shield,
"shield-check": ShieldCheck,
"key-round": KeyRound,
lock: Lock,
"earth-lock": EarthLock,
siren: Siren,
wifi: Wifi,
signal: Signal,
radio: Radio,
"satellite-dish": SatelliteDish,
radar: Radar,
scan: Scan,
},
},
{
label: "Development",
icons: {
code: Code,
terminal: Terminal,
"git-branch": GitBranch,
bug: Bug,
workflow: Workflow,
webhook: Webhook,
wrench: Wrench,
settings: Settings,
bot: Bot,
brain: Brain,
atom: Atom,
dna: Dna,
},
},
{
label: "Monitoring",
icons: {
activity: Activity,
gauge: Gauge,
"bar-chart-3": BarChart3,
"pie-chart": PieChart,
"line-chart": LineChart,
"layout-dashboard": LayoutDashboard,
"heart-pulse": HeartPulse,
thermometer: Thermometer,
timer: Timer,
"alarm-clock": AlarmClock,
},
},
{
label: "Files & documents",
icons: {
folder: Folder,
"file-text": FileText,
"scroll-text": ScrollText,
"clipboard-list": ClipboardList,
"notebook-pen": NotebookPen,
archive: Archive,
download: Download,
upload: Upload,
"refresh-cw": RefreshCw,
link: Link,
search: Search,
inbox: Inbox,
},
},
{
label: "Communication",
icons: {
mail: Mail,
"message-circle": MessageCircle,
bell: Bell,
phone: Phone,
users: Users,
rss: Rss,
newspaper: Newspaper,
"share-2": Share2,
},
},
{
label: "Home & life",
icons: {
home: Home,
lightbulb: Lightbulb,
leaf: Leaf,
sun: Sun,
wind: Wind,
waves: Waves,
utensils: Utensils,
"shopping-cart": ShoppingCart,
wallet: Wallet,
banknote: Banknote,
coins: Coins,
"credit-card": CreditCard,
calendar: Calendar,
"list-checks": ListChecks,
map: Map,
compass: Compass,
plane: Plane,
car: Car,
dumbbell: Dumbbell,
palette: Palette,
brush: Brush,
"pen-tool": PenTool,
highlighter: Highlighter,
},
},
];
/** Flat lookup of every catalog icon. */
export const STACK_ICONS: Record<string, LucideIcon> = Object.fromEntries(
ICON_GROUPS.flatMap((group) => Object.entries(group.icons))
);
/** The icon a stack gets when its name matches nothing at all. */
export const FALLBACK_ICON = "boxes";
/**
* Keywords that map a stack's name onto a catalog icon.
*
* The long tail is deliberate: the names people give stacks are the names of
* the apps inside them, and "jellyfin" should not need a manual pick to stop
* looking like a generic box. Order only breaks ties — the *longest* matching
* keyword wins, so "photoprism" beats a bare "photo" and "home-assistant"
* beats "home".
*/
const RULES: [icon: string, keywords: string[]][] = [
// Media
["clapperboard", ["plex", "jellyfin", "emby", "kodi", "streamio", "media", "stream", "cinema", "kino", "medien"]],
["film", ["radarr", "movie", "movies", "filme", "film", "tdarr", "handbrake"]],
["tv", ["sonarr", "series", "serien", "show", "shows", "iptv", "threadfin", "xteve", "tvheadend", "tv", "fernsehen"]],
["ticket", ["jellyseerr", "overseerr", "ombi", "petio", "request"]],
["music", ["lidarr", "navidrome", "airsonic", "funkwhale", "music", "musik", "spotify", "beets", "gonic", "koel"]],
["headphones", ["audiobookshelf", "audiobook", "podcast", "podgrab", "readarr", "booklore", "hoerbuch"]],
["image", ["immich", "photoprism", "piwigo", "lychee", "photo", "photos", "fotos", "gallery", "galerie", "chevereto"]],
["library", ["komga", "kavita", "calibre", "comic", "manga", "ebook", "bibliothek", "library", "booklog"]],
["video", ["jitsi", "meet", "bigbluebutton", "owncast", "peertube", "tube", "youtube", "metube", "tubearchivist"]],
["camera", ["frigate", "shinobi", "motioneye", "zoneminder", "nvr", "cctv", "surveillance", "kamera", "camera", "viseron"]],
["gamepad-2", ["minecraft", "valheim", "palworld", "factorio", "satisfactory", "terraria", "pterodactyl", "steam", "game", "games", "gaming", "romm", "emulator"]],
// Downloads & indexers
["download", ["qbittorrent", "transmission", "deluge", "rtorrent", "sabnzbd", "nzbget", "torrent", "download", "downloads", "jdownloader", "aria2", "pyload", "slskd", "usenet"]],
["search", ["prowlarr", "jackett", "searxng", "searx", "whoogle", "meilisearch", "elasticsearch", "opensearch", "typesense", "search", "suche"]],
["scroll-text", ["bazarr", "subtitle", "subtitles", "untertitel", "dozzle", "graylog", "loki", "logs", "syslog", "logging"]],
// Data stores
["database", ["postgres", "postgresql", "pgadmin", "mysql", "mariadb", "mongo", "mongodb", "sqlite", "influx", "influxdb", "timescale", "clickhouse", "couchdb", "database", "datenbank", "supabase"]],
["zap", ["redis", "valkey", "memcached", "dragonfly", "cache", "keydb"]],
["share-2", ["rabbitmq", "kafka", "nats", "queue", "broker", "pulsar"]],
["hard-drive", ["minio", "garage", "seaweedfs", "ceph", "storage", "speicher", "nas", "truenas"]],
// Network & proxies
["network", ["traefik", "nginx", "caddy", "haproxy", "envoy", "proxy", "gateway", "ingress", "swag", "zoraxy", "pangolin"]],
["shield", ["pihole", "adguard", "blocky", "unbound", "technitium", "dnsmasq", "adblock"]],
["earth-lock", ["wireguard", "tailscale", "headscale", "netbird", "openvpn", "zerotier", "gluetun", "vpn", "wg"]],
["shield-check", ["crowdsec", "fail2ban", "firewall", "opnsense", "pfsense", "waf", "security", "sicherheit", "modsecurity"]],
["cloud", ["nextcloud", "owncloud", "seafile", "cloudflare", "cloudflared", "tunnel", "cloud", "pydio"]],
["router", ["unifi", "omada", "openwrt", "router", "netbox", "phpipam", "librenms"]],
["gauge", ["speedtest", "bandwidth", "iperf", "librespeed", "benchmark"]],
// Monitoring & ops
["activity", ["uptime", "kuma", "statping", "healthcheck", "healthchecks", "netdata", "glances", "beszel", "scrutiny", "zabbix", "checkmk", "gatus", "status", "monitoring", "monitor"]],
["bar-chart-3", ["prometheus", "metrics", "telegraf", "collectd", "victoriametrics", "statistik"]],
["layout-dashboard", ["grafana", "dashboard", "dashy", "homarr", "heimdall", "homepage", "homer", "organizr", "flame", "glance", "startpage"]],
["pie-chart", ["matomo", "umami", "plausible", "analytics", "goaccess", "posthog"]],
["container", ["portainer", "dockge", "yacht", "docker", "compose", "swarm", "kubernetes", "k3s", "watchtower", "diun", "lazydocker"]],
["package", ["registry", "harbor", "nexus", "artifactory", "verdaccio", "gitea-registry", "packages"]],
// Dev
["git-branch", ["gitea", "forgejo", "gitlab", "github", "gogs", "onedev", "git", "repo", "repository"]],
["rocket", ["jenkins", "drone", "woodpecker", "buildkite", "runner", "deploy", "deployment", "argocd", "flux", "production"]],
["code", ["code-server", "codeserver", "vscode", "vscodium", "theia", "coder", "jupyter", "gitpod", "ide", "devcontainer"]],
["wrench", ["it-tools", "ittools", "tools", "utility", "utilities", "werkzeug", "toolbox"]],
["bug", ["sentry", "bugsink", "glitchtip", "debug", "testing"]],
["workflow", ["n8n", "node-red", "nodered", "huginn", "windmill", "activepieces", "temporal", "airflow", "automation", "automatisierung", "flow", "workflow"]],
["webhook", ["webhook", "webhooks", "smee", "ngrok", "relay"]],
["brain", ["ollama", "open-webui", "openwebui", "localai", "llm", "whisper", "comfyui", "automatic1111", "stable-diffusion", "librechat", "anythingllm", "langflow", "chatgpt"]],
["bot", ["bot", "bots", "discord", "telegram", "mirotalk", "matterbridge"]],
["terminal", ["shell", "ssh", "sshwifty", "wetty", "terminal", "console", "guacamole"]],
// Files & documents
["folder", ["filebrowser", "filestash", "files", "dateien", "folder", "explorer", "projectsend", "pingvin"]],
["refresh-cw", ["syncthing", "resilio", "rclone", "sync", "syncing", "unison"]],
["archive", ["duplicati", "restic", "borg", "borgmatic", "kopia", "duplicacy", "backrest", "backup", "backups", "sicherung", "archive", "archiv"]],
["file-text", ["paperless", "docspell", "mayan", "stirling", "gotenberg", "ocr", "pdf", "dokumente", "documents", "invoiceninja", "papermerge"]],
["book-open", ["wiki", "bookstack", "outline", "docmost", "mediawiki", "dokuwiki", "docusaurus", "mkdocs", "documentation", "handbuch"]],
["notebook-pen", ["trilium", "memos", "joplin", "obsidian", "silverbullet", "standardnotes", "notes", "notizen", "notion", "affine", "anytype"]],
["bookmark", ["linkwarden", "wallabag", "shaarli", "linkding", "shiori", "hoarder", "karakeep", "bookmark", "bookmarks", "lesezeichen"]],
["rss", ["freshrss", "miniflux", "rss", "feed", "feeds", "newsblur", "commafeed", "reader"]],
["newspaper", ["news", "nachrichten", "ghost", "wordpress", "blog", "hugo", "publii", "writefreely"]],
// Communication
["mail", ["mailu", "mailcow", "mailserver", "roundcube", "postfix", "dovecot", "stalwart", "snappymail", "mailpit", "maildev", "smtp", "imap", "mail", "email"]],
["message-circle", ["matrix", "synapse", "conduit", "element", "rocketchat", "mattermost", "zulip", "revolt", "chat", "irc", "thelounge", "signal-cli"]],
["bell", ["ntfy", "gotify", "apprise", "pushover", "notify", "notification", "benachrichtigung", "alert", "alertmanager"]],
["users", ["authentik", "authelia", "keycloak", "zitadel", "kanidm", "lldap", "ldap", "oauth", "oidc", "sso", "auth", "authentication", "login", "identity"]],
["key-round", ["vaultwarden", "bitwarden", "vault", "passbolt", "keepass", "password", "passwort", "secrets", "infisical", "psono"]],
// Home & IoT
["home", ["home-assistant", "homeassistant", "hass", "openhab", "domoticz", "iobroker", "smarthome", "hausautomation", "home"]],
["lightbulb", ["deconz", "hue", "zigbee2mqtt", "zigbee", "zwave", "zwavejs", "esphome", "tasmota", "wled", "licht", "lights"]],
["radio", ["mosquitto", "mqtt", "emqx", "rtl", "sdr", "meshtastic", "aprs"]],
["thermometer", ["thermostat", "temperatur", "temperature", "sensors", "sensor", "weather", "wetter"]],
["leaf", ["evcc", "solar", "photovoltaik", "openems", "energy", "energie", "garden", "garten"]],
["printer", ["octoprint", "klipper", "mainsail", "fluidd", "prusa", "printer", "drucker", "cups", "3d"]],
["car", ["teslamate", "tesla", "abrp", "auto", "vehicle", "fahrzeug"]],
// Life & admin
["utensils", ["mealie", "tandoor", "grocy", "recipe", "recipes", "rezepte", "kochbuch", "kitchen", "food", "essen"]],
["wallet", ["firefly", "actual", "budget", "ghostfolio", "maybe", "finance", "finanzen", "money", "geld", "banking", "wallabe"]],
["shopping-cart", ["shop", "shopware", "woocommerce", "medusa", "store", "ecommerce", "shopping", "einkauf", "grocery"]],
["list-checks", ["vikunja", "planka", "focalboard", "wekan", "kanboard", "taiga", "openproject", "redmine", "todo", "tasks", "task", "kanban", "aufgaben", "projekt", "project", "tickets", "jira"]],
["calendar", ["radicale", "baikal", "davical", "calendar", "kalender", "caldav", "carddav", "cal", "booking", "cal-com", "easyappointments"]],
["users", ["crm", "invoiceplane", "espocrm", "kunden", "contacts", "kontakte"]],
["dumbbell", ["fitness", "workout", "wger", "sport", "training", "gym"]],
["heart-pulse", ["health", "gesundheit", "medical", "librephotos-health", "openemr"]],
["palette", ["excalidraw", "drawio", "penpot", "figma", "design", "tldraw", "canvas", "whiteboard"]],
["map", ["openstreetmap", "osm", "nominatim", "traccar", "owntracks", "gpx", "wanderer", "maps", "karte", "navigation"]],
["plane", ["flight", "flug", "travel", "reise", "adsb", "tar1090", "flightradar", "urlaub", "holiday"]],
// Generic shapes, last resort before the fallback
["globe", ["website", "webseite", "site", "www", "web", "homepage-site", "portal", "landing"]],
["server", ["api", "backend", "service", "microservice", "app", "daemon"]],
["monitor", ["desktop", "vnc", "rdp", "kasm", "webtop", "remote"]],
["settings", ["config", "admin", "verwaltung", "management", "panel", "control"]],
["timer", ["cron", "scheduler", "job", "jobs", "batch", "zeitplan"]],
["users", ["forum", "discourse", "flarum", "lemmy", "mastodon", "community", "social", "friendica", "pixelfed"]],
];
/** Normalize a name to the space-separated lowercase form the rules match on. */
function normalize(text: string): string {
return text
.toLowerCase()
.replace(/[^a-z0-9]+/g, " ")
.trim();
}
function matches(haystack: string, keyword: string): boolean {
if (keyword.length >= 4) {
// Long enough to be unambiguous inside a run-together name
// ("mediastack", "my-jellyfin-server").
return haystack.includes(keyword);
}
return new RegExp(`(?:^| )${keyword}(?: |$)`).test(haystack);
}
/**
* The icon a stack's name suggests.
*
* `extra` is matched with lower priority than the name itself — the stack id is
* passed there, so a renamed stack follows its new name rather than its slug.
*/
export function suggestIconName(name: string, extra = ""): string {
for (const haystack of [normalize(name), normalize(extra)]) {
if (!haystack) continue;
let best: { icon: string; score: number } | null = null;
for (const [icon, keywords] of RULES) {
for (const keyword of keywords) {
if (matches(haystack, keyword) && (!best || keyword.length > best.score)) {
best = { icon, score: keyword.length };
}
}
}
if (best) return best.icon;
}
return FALLBACK_ICON;
}
export type ResolvedIcon =
| { kind: "custom"; 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: {
id: string;
name: string;
icon?: string | null;
}): ResolvedIcon {
const stored = stack.icon ?? "";
if (stored.startsWith("custom:")) return { kind: "custom", 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.
if (STACK_ICONS[name]) return { kind: "builtin", name, automatic: false };
}
return {
kind: "builtin",
name: suggestIconName(stack.name, stack.id),
automatic: true,
};
}
/** The component for a catalog name, falling back to the generic icon. */
export function iconComponent(name: string): LucideIcon {
return STACK_ICONS[name] ?? STACK_ICONS[FALLBACK_ICON];
}
+14 -9
View File
@@ -13,13 +13,14 @@ import {
LayoutTemplate,
} from "lucide-react";
import { toast } from "sonner";
import { Badge, Button, Card, Input, Spinner, StatusDot } from "@/components/ui";
import { Badge, Button, Card, Input, Spinner } from "@/components/ui";
import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
import { LogViewer } from "@/components/stacks/LogViewer";
import { ContainerCard } from "@/components/stacks/ContainerCard";
import { ActionStatusList } from "@/components/stacks/ActionStatusBanner";
import { AutoUpdatePanel } from "@/components/stacks/AutoUpdatePanel";
import { SecretsPanel } from "@/components/stacks/SecretsPanel";
import { StackIconEditor } from "@/components/stacks/IconPicker";
import { BackupButton } from "@/components/stacks/BackupRestore";
import { stacksApi } from "@/api/stacks";
import { templatesApi } from "@/api/templates";
@@ -51,15 +52,19 @@ export function StackDetail() {
return (
<div className="flex h-full flex-col space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="flex items-center gap-2">
<StatusDot status={data.status} />
<h1 className="sp-heading text-xl">{data.name}</h1>
<Badge status={data.status}>{data.status}</Badge>
<div className="flex items-center gap-3">
{/* Clicking the icon opens the picker — the way an existing stack
gets one without a trip through the editor. */}
<StackIconEditor stack={data} status={data.status} editable={isAdmin} />
<div>
<div className="flex items-center gap-2">
<h1 className="sp-heading text-xl">{data.name}</h1>
<Badge status={data.status}>{data.status}</Badge>
</div>
{data.description && (
<p className="mt-1 text-sm text-slate-500">{data.description}</p>
)}
</div>
{data.description && (
<p className="mt-1 text-sm text-slate-500">{data.description}</p>
)}
</div>
{isAdmin && (
<div className="flex flex-wrap gap-2">
+74 -2
View File
@@ -5,6 +5,8 @@ import Editor, { DiffEditor } from "@monaco-editor/react";
import { Rocket, Save, Wand2, FileCode, CheckCircle2, GitCompare, X } from "lucide-react";
import { Button, Card, Input } from "@/components/ui";
import { EditorHelperPanel } from "@/components/stacks/EditorHelperPanel";
import { IconPicker } from "@/components/stacks/IconPicker";
import { StackIcon } from "@/components/ui/StackIcon";
import { EnvEditor } from "@/components/env/EnvEditor";
import { PortConflictDialog } from "@/components/stacks/PortConflictDialog";
import { DeployConsole } from "@/components/stacks/DeployConsole";
@@ -32,6 +34,11 @@ export function StackEditor() {
const [name, setName] = useState("");
const [description, setDescription] = useState("");
// null = automatic (derived from the name). A freshly picked file is held
// here until the stack exists, because uploading needs an id.
const [icon, setIcon] = useState<string | null>(null);
const [iconFile, setIconFile] = useState<File | null>(null);
const [iconOpen, setIconOpen] = useState(false);
const [yaml, setYaml] = useState(STARTER);
const [env, setEnv] = useState("");
const [tab, setTab] = useState<"compose" | "env">("compose");
@@ -56,6 +63,7 @@ export function StackEditor() {
if (existing.data) {
setName(existing.data.name);
setDescription(existing.data.description ?? "");
setIcon(existing.data.icon ?? null);
setYaml(existing.data.yaml || STARTER);
setEnv(existing.data.env || "");
}
@@ -69,11 +77,27 @@ export function StackEditor() {
setSaving(true);
try {
let stackId = id;
// The icon field only ever carries a built-in choice or "" (automatic).
// A pending upload is sent separately once the stack has an id, and an
// upload that is already stored is left untouched — the server mints
// those values and refuses them coming back in.
const iconField =
iconFile || icon?.startsWith("custom:") ? undefined : icon ?? "";
if (isNew) {
const created = await stacksApi.create({ name, description, yaml, env });
const created = await stacksApi.create({
name,
description,
icon: iconField,
yaml,
env,
});
stackId = created.id;
} else {
await stacksApi.update(id!, { name, description, yaml, env });
await stacksApi.update(id!, { name, description, icon: iconField, yaml, env });
}
if (iconFile && stackId) {
await stacksApi.uploadIcon(stackId, iconFile);
setIconFile(null);
}
qc.invalidateQueries({ queryKey: ["stacks"] });
qc.invalidateQueries({ queryKey: ["stack", stackId] });
@@ -111,6 +135,28 @@ export function StackEditor() {
const originalYaml = existing.data?.yaml ?? "";
const dirty = !isNew && originalYaml !== yaml;
// Object URL for a file that has been chosen but not uploaded yet. Created in
// an effect rather than during render so React's cleanup is what revokes it —
// a URL made while rendering leaks on every re-render that discards it.
const [iconPreview, setIconPreview] = useState<string | null>(null);
useEffect(() => {
if (!iconFile) {
setIconPreview(null);
return;
}
const url = URL.createObjectURL(iconFile);
setIconPreview(url);
return () => URL.revokeObjectURL(url);
}, [iconFile]);
// What the icon would look like right now, name included: picking an icon
// before the stack exists has to preview against the name being typed.
const iconStack = {
id: id ?? "",
name,
icon: iconFile ? "custom:pending" : icon,
};
const iconStatus = existing.data?.status ?? "stopped";
const validate = async () => {
setValidating(true);
setValidation(null);
@@ -144,6 +190,14 @@ export function StackEditor() {
// bottom padding (pb-10), so the editor fills whatever screen the user has.
<div className="flex h-[calc(100vh-116px)] min-h-[420px] flex-col space-y-3">
<div className="flex flex-wrap items-center gap-3">
<button
type="button"
onClick={() => setIconOpen(true)}
title="Choose an icon for this stack"
className="rounded-[11px] outline-none ring-offset-2 transition-opacity hover:opacity-80 focus-visible:ring-2 focus-visible:ring-accent dark:ring-offset-slate-900"
>
<StackIcon stack={iconStack} status={iconStatus} previewUrl={iconPreview} />
</button>
<Input
className="max-w-xs"
placeholder="Stack name"
@@ -162,6 +216,24 @@ export function StackEditor() {
</Button>
</div>
{iconOpen && (
<IconPicker
stack={iconStack}
status={iconStatus}
previewUrl={iconPreview}
onSelect={(value) => {
setIcon(value || null);
setIconFile(null);
setIconOpen(false);
}}
onUpload={(file) => {
setIconFile(file);
setIconOpen(false);
}}
onClose={() => setIconOpen(false)}
/>
)}
{convertOpen && (
<Card className="flex items-center gap-2">
<Input
+4
View File
@@ -10,6 +10,9 @@ 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. */
icon?: string | null;
status: StackStatus;
service_count: number;
running_count: number;
@@ -46,6 +49,7 @@ export interface StackDetail {
id: string;
name: string;
description?: string | null;
icon?: string | null;
status: StackStatus;
yaml: string;
env: string;