Use the apps' real logos as stack icons, fetched server-side (0.52.0)
0.51.0 gave every stack an icon, but a generic one: jellyfin got a clapperboard,
not the Jellyfin logo. Glyphs make a list readable; they do not make a stack
recognisable, which was the point. This resolves stacks against the selfh.st
icon catalog (~2900 self-hosted apps, the set Homarr and Homepage draw on), so
the row shows the thing people already recognise. All 83 bundled templates
resolve to their own logo.
The whole design question was *who* talks to the CDN. If the <img> points at
jsdelivr, then every client needs internet, every page load leaks the names of
somebody's stacks to a third party, and an air-gapped box gets nothing. So the
backend does it: the catalog on startup and weekly after, each logo once on
first use, both into ${DATA_DIR}/stack-icons/. Browsers keep reading icons from
the authenticated endpoint that already existed for uploads, and after the first
fetch the feature is fully offline. Logos are cached per *app*, not per stack —
verified: two stacks resolving to jellyfin produce one download.
Nothing here can fail loudly. Every entry point returns None rather than raising
when the network is absent, the catalog refresh is a task the lifespan does not
await, and an install with no outbound internet simply keeps 0.51.0's glyphs.
That fallback is also what covers a name the catalog does not know
("Mediaserver Wohnzimmer" is still a clapperboard), and the seconds after a
fresh install before the catalog lands. The glyph is derived even for stacks
that *do* have a logo, so an image that cannot be fetched degrades to something
meaningful instead of a box.
Matching gained a second source that turned out to matter more than expected:
the compose images. A stack called "medienserver" says nothing, but it pulls
lscr.io/linuxserver/jellyfin — strip the registry, the vendor and the tag and
the app is right there. Name first, then the longest run of words inside it,
then the images. It is deliberately cautious: a single word shorter than four
characters never claims a logo, because "web", "app" and "db" are all catalog
entries and a *wrong* logo is worse than a neutral glyph. A short alias table
covers what the catalog spells differently from Docker Hub (postgres →
postgresql, pihole → pi-hole, wg-easy → wireguard).
A slug arrives from the database and from query strings and then becomes a
filename, so it is pattern-checked before it is ever joined to a path, catalog
entries that are not slug-shaped are dropped on load, and a downloaded logo is
verified to start with the PNG magic bytes before being cached.
The picker searches the catalog too — pre-seeded with the stack's own name, so
opening it on "jellyfin" offers the Jellyfin logo first — which is how a wrong
match gets corrected, and how a stack can be given any app's logo on purpose.
Verified end to end against the live catalog and real downloads: list rows carry
the resolved logo, the icon endpoint serves real PNG bytes, an unmatched stack
404s (and falls through to its glyph), a hand-picked logo round-trips, reset
clears it, and a traversal slug 404s. 30 new backend tests and 12 new frontend
ones run without any network at all.
0.52.0 rather than amending 0.51.0: those images are already in the registry,
and rebuilding a published version tag with different content is exactly what
breaks the self-update checker.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "stackpilot-frontend",
|
||||
"private": true,
|
||||
"version": "0.51.0",
|
||||
"version": "0.52.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -52,6 +52,23 @@ export const stacksApi = {
|
||||
resetIcon: (id: string) =>
|
||||
api.delete<StackSummary>(`/api/stacks/${id}/icon`).then((r) => r.data),
|
||||
|
||||
/** Search the app-logo catalog (Jellyfin, Postgres, Gitea, …). `ready` is
|
||||
* false when the server has not been able to download the catalog. */
|
||||
searchLogos: (q: string, limit = 24) =>
|
||||
api
|
||||
.get<{ ready: boolean; icons: { slug: string; name: string }[] }>(
|
||||
`/api/stacks/icons/search?q=${encodeURIComponent(q)}&limit=${limit}`
|
||||
)
|
||||
.then((r) => r.data),
|
||||
/** One catalog logo by slug. Served by our backend from its own cache, so
|
||||
* the browser never talks to the icon CDN. */
|
||||
logo: (slug: string) =>
|
||||
api
|
||||
.get<Blob>(`/api/stacks/icons/logo/${encodeURIComponent(slug)}`, {
|
||||
responseType: "blob",
|
||||
})
|
||||
.then((r) => r.data),
|
||||
|
||||
logs: (id: string, tail = 200) =>
|
||||
api.get<{ logs: string }>(`/api/stacks/${id}/logs?tail=${tail}`).then((r) => r.data),
|
||||
convert: (command: string) =>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Sparkles, Upload, X } from "lucide-react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { ImageOff, Sparkles, Upload, X } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button, Input } from "@/components/ui";
|
||||
import { StackIcon } from "@/components/ui/StackIcon";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ICON_GROUPS, suggestIconName } from "@/lib/stackIcons";
|
||||
import type { IconStack } from "@/lib/stackIcons";
|
||||
import { stacksApi } from "@/api/stacks";
|
||||
import { apiErrorMessage } from "@/api/client";
|
||||
import type { StackStatus } from "@/types";
|
||||
@@ -33,7 +34,7 @@ export function IconPicker({
|
||||
onUpload,
|
||||
onClose,
|
||||
}: {
|
||||
stack: { id: string; name: string; icon?: string | null };
|
||||
stack: IconStack;
|
||||
status?: StackStatus;
|
||||
previewUrl?: string | null;
|
||||
busy?: boolean;
|
||||
@@ -120,7 +121,7 @@ export function IconPicker({
|
||||
/>
|
||||
<div className="relative min-w-[140px] flex-1">
|
||||
<Input
|
||||
placeholder="Search icons…"
|
||||
placeholder="Search app logos and symbols…"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
/>
|
||||
@@ -128,12 +129,19 @@ export function IconPicker({
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-4">
|
||||
<AppLogos
|
||||
term={q.trim() || stack.name}
|
||||
selected={stack.icon ?? ""}
|
||||
busy={busy}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
{groups.length === 0 && (
|
||||
<p className="text-sm text-slate-500">No icon matches “{q}”.</p>
|
||||
<p className="text-sm text-slate-500">No symbol matches “{q}”.</p>
|
||||
)}
|
||||
{groups.length > 0 && <h3 className="sp-label mb-2">Symbols</h3>}
|
||||
{groups.map((group) => (
|
||||
<section key={group.label} className="mb-4 last:mb-0">
|
||||
<h3 className="sp-label mb-2">{group.label}</h3>
|
||||
<h3 className="mb-2 text-[11px] text-slate-400">{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}`;
|
||||
@@ -187,7 +195,7 @@ export function StackIconEditor({
|
||||
size = "lg",
|
||||
editable = true,
|
||||
}: {
|
||||
stack: { id: string; name: string; icon?: string | null };
|
||||
stack: IconStack;
|
||||
status: StackStatus;
|
||||
size?: "sm" | "md" | "lg";
|
||||
/** The read-only role sees the icon but cannot change it. */
|
||||
@@ -235,3 +243,118 @@ export function StackIconEditor({
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logos of known apps, searched in the catalog the backend caches.
|
||||
*
|
||||
* Opening the picker searches for the stack's own name, so the Jellyfin logo is
|
||||
* the first thing a stack called "jellyfin" offers. Each thumbnail comes from
|
||||
* our own backend rather than the icon CDN — see api/stacks.ts.
|
||||
*/
|
||||
function AppLogos({
|
||||
term,
|
||||
selected,
|
||||
busy,
|
||||
onSelect,
|
||||
}: {
|
||||
term: string;
|
||||
selected: string;
|
||||
busy: boolean;
|
||||
onSelect: (icon: string) => void;
|
||||
}) {
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["icon-logos", term],
|
||||
queryFn: () => stacksApi.searchLogos(term),
|
||||
enabled: term.trim().length > 0,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
if (!term.trim()) return null;
|
||||
if (isLoading) {
|
||||
return (
|
||||
<section className="mb-4">
|
||||
<h3 className="sp-label mb-2">App logos</h3>
|
||||
<div className="h-12 sp-skeleton" />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
if (!data) return null;
|
||||
if (!data.ready) {
|
||||
return (
|
||||
<section className="mb-4">
|
||||
<h3 className="sp-label mb-2">App logos</h3>
|
||||
<p className="text-xs text-slate-500">
|
||||
The logo catalog has not been downloaded yet — it needs outbound
|
||||
internet on the server, and is retried automatically.
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
if (data.icons.length === 0) {
|
||||
return (
|
||||
<section className="mb-4">
|
||||
<h3 className="sp-label mb-2">App logos</h3>
|
||||
<p className="text-xs text-slate-500">No app matches “{term}”.</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="mb-4">
|
||||
<h3 className="sp-label mb-2">App logos</h3>
|
||||
<div className="grid grid-cols-4 gap-1.5 sm:grid-cols-6">
|
||||
{data.icons.map((icon) => (
|
||||
<button
|
||||
key={icon.slug}
|
||||
title={icon.name}
|
||||
disabled={busy}
|
||||
onClick={() => onSelect(`logo:${icon.slug}`)}
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-1 rounded-lg border p-2 transition-colors",
|
||||
"disabled:opacity-40",
|
||||
selected === `logo:${icon.slug}`
|
||||
? "border-accent bg-accent/10"
|
||||
: "border-transparent hover:bg-slate-100 dark:hover:bg-slate-700"
|
||||
)}
|
||||
>
|
||||
<LogoThumb slug={icon.slug} />
|
||||
<span className="w-full truncate text-[10px] text-slate-500">{icon.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** One catalog logo, fetched through the API client (the endpoint needs the
|
||||
* token) and cached for the session. */
|
||||
function LogoThumb({ slug }: { slug: string }) {
|
||||
const { data: blob } = useQuery({
|
||||
queryKey: ["icon-logo", slug],
|
||||
queryFn: () => stacksApi.logo(slug),
|
||||
staleTime: Infinity,
|
||||
gcTime: 60 * 60 * 1000,
|
||||
retry: false,
|
||||
});
|
||||
const [url, setUrl] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!blob) {
|
||||
setUrl(null);
|
||||
return;
|
||||
}
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
setUrl(objectUrl);
|
||||
return () => URL.revokeObjectURL(objectUrl);
|
||||
}, [blob]);
|
||||
|
||||
return (
|
||||
<span className="flex h-8 w-8 items-center justify-center overflow-hidden rounded-md bg-white">
|
||||
{url ? (
|
||||
<img src={url} alt="" className="h-full w-full object-contain p-0.5" />
|
||||
) : (
|
||||
<ImageOff className="h-3.5 w-3.5 text-slate-300" />
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { iconComponent, resolveStackIcon } from "@/lib/stackIcons";
|
||||
import {
|
||||
iconComponent,
|
||||
imageIconKey,
|
||||
resolveStackIcon,
|
||||
suggestIconName,
|
||||
} from "@/lib/stackIcons";
|
||||
import type { IconStack } from "@/lib/stackIcons";
|
||||
import { stacksApi } from "@/api/stacks";
|
||||
import type { StackStatus } from "@/types";
|
||||
|
||||
@@ -12,6 +18,10 @@ import type { StackStatus } from "@/types";
|
||||
* carried by the thing the eye lands on anyway. The status is still spelled out
|
||||
* in the badge next to it and in the tooltip here, so the colour is never the
|
||||
* only way to read it.
|
||||
*
|
||||
* The icon is either an image the server holds — the app's real logo, or an
|
||||
* upload — or a glyph derived from the name. Images are fetched through the API
|
||||
* client because that endpoint needs the bearer token.
|
||||
*/
|
||||
|
||||
type Size = "sm" | "md" | "lg";
|
||||
@@ -64,7 +74,7 @@ export function StackIcon({
|
||||
previewUrl,
|
||||
className,
|
||||
}: {
|
||||
stack: { id: string; name: string; icon?: string | null };
|
||||
stack: IconStack;
|
||||
status: StackStatus;
|
||||
size?: Size;
|
||||
/** Shows this image instead of the stored icon — used to preview a file that
|
||||
@@ -75,12 +85,17 @@ export function StackIcon({
|
||||
const resolved = resolveStackIcon(stack);
|
||||
const dims = SIZES[size];
|
||||
const tone = STATUS_STYLE[status] ?? STATUS_STYLE.unknown;
|
||||
const stored = useCustomIconUrl(
|
||||
const stored = useStackImageUrl(
|
||||
stack.id,
|
||||
resolved.kind === "custom" && !previewUrl ? stack.icon : null
|
||||
resolved.kind === "image" && !previewUrl ? imageIconKey(stack) : null
|
||||
);
|
||||
const custom = previewUrl ?? stored;
|
||||
const Glyph = iconComponent(resolved.kind === "builtin" ? resolved.name : "");
|
||||
// The glyph doubles as the fallback for an image that cannot be fetched (a
|
||||
// logo the server has not got yet), so derive it from the name either way
|
||||
// rather than landing on the generic mark.
|
||||
const Glyph = iconComponent(
|
||||
resolved.kind === "builtin" ? resolved.name : suggestIconName(stack.name, stack.id)
|
||||
);
|
||||
|
||||
return (
|
||||
<span
|
||||
@@ -109,7 +124,14 @@ export function StackIcon({
|
||||
)}
|
||||
>
|
||||
{custom ? (
|
||||
<img src={custom} alt="" className="h-full w-full object-cover" />
|
||||
// Logos are drawn for a light ground and many are dark line art, so
|
||||
// the tile stays light in both themes rather than swallowing them.
|
||||
// `contain`, not `cover`: a logo must not be cropped.
|
||||
<img
|
||||
src={custom}
|
||||
alt=""
|
||||
className="h-full w-full bg-white object-contain p-0.5"
|
||||
/>
|
||||
) : (
|
||||
<Glyph className={cn(dims.glyph, tone.glyph)} strokeWidth={2} />
|
||||
)}
|
||||
@@ -119,14 +141,16 @@ export function StackIcon({
|
||||
}
|
||||
|
||||
/**
|
||||
* Object URL for a stack's uploaded icon, or null while there is none.
|
||||
* Object URL for a stack's image icon, or null while there is none.
|
||||
*
|
||||
* The icon endpoint needs the bearer token, so the bytes are fetched through
|
||||
* the API client and handed to the browser as a blob. `icon` is part of the
|
||||
* query key and changes on every upload (it carries a version), which is what
|
||||
* retires the previous image instead of leaving a stale one on screen.
|
||||
* query key: an upload carries a version and a logo carries its slug, so
|
||||
* changing either retires the previous image instead of leaving a stale one on
|
||||
* screen. `retry: false` matters here — a stack whose logo the server cannot
|
||||
* fetch (no internet yet) must fall through to its glyph quietly.
|
||||
*/
|
||||
function useCustomIconUrl(stackId: string, icon: string | null | undefined): string | null {
|
||||
function useStackImageUrl(stackId: string, icon: string | null | undefined): string | null {
|
||||
const { data: blob } = useQuery({
|
||||
queryKey: ["stack-icon", stackId, icon],
|
||||
queryFn: () => stacksApi.icon(stackId),
|
||||
|
||||
@@ -9,6 +9,7 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
FALLBACK_ICON,
|
||||
STACK_ICONS,
|
||||
imageIconKey,
|
||||
resolveStackIcon,
|
||||
suggestIconName,
|
||||
} from "./stackIcons";
|
||||
@@ -87,7 +88,34 @@ describe("resolveStackIcon", () => {
|
||||
it("reports an uploaded image", () => {
|
||||
expect(
|
||||
resolveStackIcon({ id: "x", name: "X", icon: "custom:png:123" }).kind
|
||||
).toBe("custom");
|
||||
).toBe("image");
|
||||
});
|
||||
|
||||
it("reports an app logo, chosen or matched by the server", () => {
|
||||
expect(
|
||||
resolveStackIcon({ id: "x", name: "X", icon: "logo:jellyfin" }).kind
|
||||
).toBe("image");
|
||||
expect(
|
||||
resolveStackIcon({ id: "plex", name: "Plex", auto_icon: "logo:plex" }).kind
|
||||
).toBe("image");
|
||||
});
|
||||
|
||||
it("lets an explicit choice outrank the matched logo", () => {
|
||||
// Somebody who picked a glyph must not have the server's logo put back.
|
||||
expect(
|
||||
resolveStackIcon({
|
||||
id: "plex",
|
||||
name: "Plex",
|
||||
icon: "lucide:database",
|
||||
auto_icon: "logo:plex",
|
||||
})
|
||||
).toEqual({ kind: "builtin", name: "database", automatic: false });
|
||||
});
|
||||
|
||||
it("falls back to the derived glyph when the server matched nothing", () => {
|
||||
expect(
|
||||
resolveStackIcon({ id: "plex", name: "Plex", icon: null, auto_icon: null })
|
||||
).toEqual({ kind: "builtin", name: "clapperboard", automatic: true });
|
||||
});
|
||||
|
||||
it("derives one when nothing is stored", () => {
|
||||
@@ -107,3 +135,18 @@ describe("resolveStackIcon", () => {
|
||||
expect(resolved).toEqual({ kind: "builtin", name: "clapperboard", automatic: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("imageIconKey", () => {
|
||||
it("keys on the explicit choice, then on the matched logo", () => {
|
||||
expect(imageIconKey({ id: "x", name: "X", icon: "custom:png:9" })).toBe("custom:png:9");
|
||||
expect(imageIconKey({ id: "x", name: "X", auto_icon: "logo:plex" })).toBe("logo:plex");
|
||||
});
|
||||
|
||||
it("is null when the icon is a glyph, so nothing is fetched", () => {
|
||||
expect(imageIconKey({ id: "x", name: "X", icon: "lucide:database" })).toBeNull();
|
||||
// An explicit glyph suppresses the matched logo rather than fetching it.
|
||||
expect(
|
||||
imageIconKey({ id: "x", name: "X", icon: "lucide:database", auto_icon: "logo:plex" })
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,17 +4,26 @@
|
||||
* Every stack shows an icon in place of the old status dot. Where it comes
|
||||
* from, in order:
|
||||
*
|
||||
* 1. `stack.icon === "custom:…"` — an image the user uploaded. Rendered by
|
||||
* `StackIcon`, which fetches it through the authenticated API client.
|
||||
* 2. `stack.icon === "lucide:<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.
|
||||
* 1. `stack.icon === "custom:…"` — an image the user uploaded.
|
||||
* 2. `stack.icon === "logo:<slug>"` — the real logo of a known app, picked by
|
||||
* hand from the catalog.
|
||||
* 3. `stack.icon === "lucide:<name>"` — a glyph the user picked from here.
|
||||
* 4. `stack.auto_icon === "logo:<slug>"` — no explicit choice, but the server
|
||||
* recognised the app from the stack's name or its compose images. This is
|
||||
* the common case: a stack called "jellyfin" shows the Jellyfin logo.
|
||||
* 5. nothing at all — `suggestIconName()` derives a glyph from the name. The
|
||||
* backstop for a name no catalog knows ("Mediaserver Wohnzimmer"), for an
|
||||
* install with no outbound internet, and for every stack in the seconds
|
||||
* before the logo catalog finishes downloading.
|
||||
*
|
||||
* The catalog and the matching rules live in the frontend on purpose. This is
|
||||
* the only place that can actually *render* an icon, so a second copy in the
|
||||
* 1, 2 and 4 are images and all come from the same authenticated endpoint —
|
||||
* `StackIcon` fetches the bytes and renders the blob, so the browser never
|
||||
* talks to the icon CDN. Only 5 is drawn here.
|
||||
*
|
||||
* The glyph catalog and its keyword rules live in the frontend on purpose. This
|
||||
* is the only place that can actually *render* one, so a second copy in the
|
||||
* backend would be a list to keep in sync and nothing more — the server only
|
||||
* validates the shape of the value (`services/icon_service.py`).
|
||||
* validates the shape of the stored value (`services/icon_service.py`).
|
||||
*/
|
||||
import {
|
||||
Activity,
|
||||
@@ -470,22 +479,40 @@ export function suggestIconName(name: string, extra = ""): string {
|
||||
}
|
||||
|
||||
export type ResolvedIcon =
|
||||
| { kind: "custom"; name: null }
|
||||
| { kind: "image"; name: null }
|
||||
| { kind: "builtin"; name: string; automatic: boolean };
|
||||
|
||||
/** What to draw for a stack, given its stored choice (or the lack of one). */
|
||||
export function resolveStackIcon(stack: {
|
||||
export interface IconStack {
|
||||
id: string;
|
||||
name: string;
|
||||
icon?: string | null;
|
||||
}): ResolvedIcon {
|
||||
auto_icon?: string | null;
|
||||
}
|
||||
|
||||
/** Whether a stored icon value names an image the server can serve. */
|
||||
export function isImageIcon(value: string | null | undefined): boolean {
|
||||
return Boolean(value && (value.startsWith("custom:") || value.startsWith("logo:")));
|
||||
}
|
||||
|
||||
/** The image the icon endpoint would return for this stack, as an opaque cache
|
||||
* key — the explicit choice if there is one, otherwise the matched logo. */
|
||||
export function imageIconKey(stack: IconStack): string | null {
|
||||
if (isImageIcon(stack.icon)) return stack.icon!;
|
||||
if (!stack.icon && isImageIcon(stack.auto_icon)) return stack.auto_icon!;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** What to draw for a stack, given its stored choice (or the lack of one). */
|
||||
export function resolveStackIcon(stack: IconStack): ResolvedIcon {
|
||||
const stored = stack.icon ?? "";
|
||||
if (stored.startsWith("custom:")) return { kind: "custom", name: null };
|
||||
if (isImageIcon(stored)) return { kind: "image", name: null };
|
||||
if (stored.startsWith("lucide:")) {
|
||||
const name = stored.slice("lucide:".length);
|
||||
// An icon that was dropped from the catalog must not blank the row out.
|
||||
// A glyph that was dropped from the catalog must not blank the row out.
|
||||
if (STACK_ICONS[name]) return { kind: "builtin", name, automatic: false };
|
||||
}
|
||||
// No explicit choice: the app logo the server recognised, else a glyph.
|
||||
if (!stored && isImageIcon(stack.auto_icon)) return { kind: "image", name: null };
|
||||
return {
|
||||
kind: "builtin",
|
||||
name: suggestIconName(stack.name, stack.id),
|
||||
|
||||
@@ -10,9 +10,12 @@ 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. */
|
||||
/** The explicit choice: "lucide:<name>", "logo:<slug>",
|
||||
* "custom:<ext>:<version>", or null for automatic. See lib/stackIcons.ts. */
|
||||
icon?: string | null;
|
||||
/** Only when `icon` is null: the app logo the server matched the name to
|
||||
* ("logo:<slug>"), or null when it recognised nothing. */
|
||||
auto_icon?: string | null;
|
||||
status: StackStatus;
|
||||
service_count: number;
|
||||
running_count: number;
|
||||
@@ -50,6 +53,7 @@ export interface StackDetail {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
icon?: string | null;
|
||||
auto_icon?: string | null;
|
||||
status: StackStatus;
|
||||
yaml: string;
|
||||
env: string;
|
||||
|
||||
Reference in New Issue
Block a user