diff --git a/README.md b/README.md index fde07b4..9ae94f3 100644 --- a/README.md +++ b/README.md @@ -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:"} or "" for automatic) +``` + ## Security notes - The Docker socket is only ever touched by the backend process; it is never diff --git a/backend/models/stack.py b/backend/models/stack.py index 3e9849b..148c1a0 100644 --- a/backend/models/stack.py +++ b/backend/models/stack.py @@ -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:" for a + # built-in icon, "custom::" 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:", 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 diff --git a/backend/routers/stacks.py b/backend/routers/stacks.py index 899e66e..661785e 100644 --- a/backend/routers/stacks.py +++ b/backend/routers/stacks.py @@ -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 + ```` 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 # --------------------------------------------------------------------------- # diff --git a/backend/services/icon_service.py b/backend/services/icon_service.py new file mode 100644 index 0000000..5c4d18d --- /dev/null +++ b/backend/services/icon_service.py @@ -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:`` + 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::`` + An uploaded image at ``${DATA_DIR}/stack-icons/.``. + ```` 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}$") + +#: ```` 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:'; 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" 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())}" diff --git a/backend/tests/test_route_authorization.py b/backend/tests/test_route_authorization.py index 433da6c..cece628 100644 --- a/backend/tests/test_route_authorization.py +++ b/backend/tests/test_route_authorization.py @@ -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", diff --git a/backend/tests/test_stack_icons.py b/backend/tests/test_stack_icons.py new file mode 100644 index 0000000..04f0182 --- /dev/null +++ b/backend/tests/test_stack_icons.py @@ -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'' + + +@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", + "", + ], +) +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 diff --git a/backend/version.py b/backend/version.py index 0ae6362..e436766 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.50.0" +APP_VERSION = "0.51.0" diff --git a/frontend/package.json b/frontend/package.json index 364d34e..e09e969 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "stackpilot-frontend", "private": true, - "version": "0.50.0", + "version": "0.51.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/api/stacks.ts b/frontend/src/api/stacks.ts index 90a8c09..43d4629 100644 --- a/frontend/src/api/stacks.ts +++ b/frontend/src/api/stacks.ts @@ -8,10 +8,24 @@ export const stacksApi = { api.get>("/api/stacks/updates").then((r) => r.data), get: (id: string) => api.get(`/api/stacks/${id}`).then((r) => r.data), - create: (body: { name: string; description?: string; yaml?: string; env?: string }) => - api.post("/api/stacks", body).then((r) => r.data), - update: (id: string, body: { name?: string; description?: string; yaml?: string; env?: string }) => - api.put(`/api/stacks/${id}`, body).then((r) => r.data), + create: (body: { + name: string; + description?: string; + icon?: string; + yaml?: string; + env?: string; + }) => api.post("/api/stacks", body).then((r) => r.data), + update: ( + id: string, + body: { + name?: string; + description?: string; + /** "lucide:", or "" to go back to the name-derived icon. */ + icon?: string; + yaml?: string; + env?: string; + } + ) => api.put(`/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 pointed at this URL would be unauthenticated. */ + icon: (id: string) => + api.get(`/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(`/api/stacks/${id}/icon`, form) + .then((r) => r.data); + }, + resetIcon: (id: string) => + api.delete(`/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) => diff --git a/frontend/src/components/stacks/IconPicker.tsx b/frontend/src/components/stacks/IconPicker.tsx new file mode 100644 index 0000000..fcbcc1c --- /dev/null +++ b/frontend/src/components/stacks/IconPicker.tsx @@ -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(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( +
!busy && onClose()} + > +
e.stopPropagation()} + > +
+ +
+

Stack icon

+

{stack.name || "New stack"}

+
+ +
+ +
+ + + { + pickFile(e.target.files?.[0]); + // Reset, or picking the same file twice fires no change event. + e.target.value = ""; + }} + /> +
+ setQ(e.target.value)} + /> +
+
+ +
+ {groups.length === 0 && ( +

No icon matches “{q}”.

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

{group.label}

+
+ {Object.entries(group.icons).map(([name, Glyph]) => { + const selected = stack.icon === `lucide:${name}`; + return ( + + ); + })} +
+
+ ))} +
+ +

+ PNG, JPEG, GIF, WebP or SVG, up to 512 KiB. Square images look best — + anything else is cropped to fit. +

+
+
, + 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) => { + 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 ; + + return ( + <> + + {open && ( + apply(stacksApi.update(stack.id, { icon }))} + onUpload={(file) => apply(stacksApi.uploadIcon(stack.id, file))} + onClose={() => setOpen(false)} + /> + )} + + ); +} diff --git a/frontend/src/components/stacks/StacksTable.tsx b/frontend/src/components/stacks/StacksTable.tsx index cd03b8b..0872dab 100644 --- a/frontend/src/components/stacks/StacksTable.tsx +++ b/frontend/src/components/stacks/StacksTable.tsx @@ -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({
- + {/* The icon carries the status (a halo in the status colour), which + is why there is no separate dot here any more. */} + {stack.name} {stack.status} diff --git a/frontend/src/components/ui/StackIcon.tsx b/frontend/src/components/ui/StackIcon.tsx new file mode 100644 index 0000000..69ccbf7 --- /dev/null +++ b/frontend/src/components/ui/StackIcon.tsx @@ -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 = { + 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 = { + 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 ( + + {/* The status "shimmer": a blurred copy of the status colour bleeding out + from behind the tile. Pulses while an operation is running. */} + + + {custom ? ( + + ) : ( + + )} + + + ); +} + +/** + * 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(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; +} diff --git a/frontend/src/lib/stackIcons.test.ts b/frontend/src/lib/stackIcons.test.ts new file mode 100644 index 0000000..f1c64fb --- /dev/null +++ b/frontend/src/lib/stackIcons.test.ts @@ -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 }); + }); +}); diff --git a/frontend/src/lib/stackIcons.ts b/frontend/src/lib/stackIcons.ts new file mode 100644 index 0000000..fe6dd6b --- /dev/null +++ b/frontend/src/lib/stackIcons.ts @@ -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:"` — 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:`). Grouped for the picker; the flat map is derived below. */ +export const ICON_GROUPS: { label: string; icons: Record }[] = [ + { + 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 = 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]; +} diff --git a/frontend/src/pages/StackDetail.tsx b/frontend/src/pages/StackDetail.tsx index 985a16e..3699dfc 100644 --- a/frontend/src/pages/StackDetail.tsx +++ b/frontend/src/pages/StackDetail.tsx @@ -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 (
-
-
- -

{data.name}

- {data.status} +
+ {/* Clicking the icon opens the picker — the way an existing stack + gets one without a trip through the editor. */} + +
+
+

{data.name}

+ {data.status} +
+ {data.description && ( +

{data.description}

+ )}
- {data.description && ( -

{data.description}

- )}
{isAdmin && (
diff --git a/frontend/src/pages/StackEditor.tsx b/frontend/src/pages/StackEditor.tsx index 4086dc4..ced325e 100644 --- a/frontend/src/pages/StackEditor.tsx +++ b/frontend/src/pages/StackEditor.tsx @@ -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(null); + const [iconFile, setIconFile] = useState(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(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.
+
+ {iconOpen && ( + { + setIcon(value || null); + setIconFile(null); + setIconOpen(false); + }} + onUpload={(file) => { + setIconFile(file); + setIconOpen(false); + }} + onClose={() => setIconOpen(false)} + /> + )} + {convertOpen && ( ", "custom::", 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;