stack and container icons - pick an icon per stack or per container (survives recreation), plus automatic app logos on containers

This commit is contained in:
Jarek Krochmalski committed 2026-08-23 10:20:59 +02:00
1 parent 30fb770a9f
commit f6ffadbacb
40 files changed
+9520 -38

No files matched your search

+107
View File
@@ -0,0 +1,107 @@
<script lang="ts">
import type { Component } from 'svelte';
import { Box } from 'lucide-svelte';
import { appSettings } from '$lib/stores/settings';
import { selfhstMatcher, ensureSelfhstMatcher } from '$lib/stores/selfhst-refs';
import { isSelfhstIcon, selfhstRef, isCustomIcon, getStackIconComponent } from '$lib/utils/icons';
interface Props {
image: string;
/** Container name, used as a fallback match when the image does not resolve. */
name?: string;
/**
* A user-set icon override (lucide name / 'selfhst:<ref>' / 'custom:container').
* When present it WINS over automatic matching and renders even with the app-logo
* toggle off - an explicit choice always shows. `envId` builds the custom-icon URL.
*/
override?: string | null;
/** Environment id, only needed to fetch a 'custom:' override's bytes. */
envId?: number | null;
class?: string;
/** Extra classes for the generic-box fallback (e.g. a running/stopped state colour). */
fallbackClass?: string;
/** Lucide component to render instead of the generic Box when there is no logo. */
fallbackIcon?: Component;
/**
* When the app-logo toggle is OFF, render the generic box anyway (used by places
* that already showed a box, e.g. the logs/terminal pickers, so they keep their
* icon + state colour). Default false: with the toggle off, render nothing (the
* container/stack lists opt into icons only when the toggle is on).
*/
showFallbackWhenOff?: boolean;
/**
* Render nothing when there is no matched app logo (instead of the generic box).
* For dense inline spots (e.g. a volume's "used by" chips) where a box on every
* row would be noise - show a logo only when we actually have one.
*/
hideWhenNoMatch?: boolean;
}
let {
image,
name = '',
override = null,
envId = null,
class: className = 'w-4 h-4',
fallbackClass = 'text-muted-foreground',
fallbackIcon = undefined,
showFallbackWhenOff = false,
hideWhenNoMatch = false
}: Props = $props();
const FallbackIcon = $derived(fallbackIcon ?? Box);
// An explicit override wins over auto-matching (and the app-logo toggle).
const hasOverride = $derived(!!override);
const overrideSelfhst = $derived(selfhstRef(override));
const overrideCustom = $derived(isCustomIcon(override));
const overrideCustomUrl = $derived(
overrideCustom && name
? `/api/container-icons/${encodeURIComponent(name)}${envId != null ? `?env=${envId}` : ''}`
: ''
);
// A non-selfhst, non-custom override is a lucide name.
const OverrideLucide = $derived(
hasOverride && !isSelfhstIcon(override) && !overrideCustom ? getStackIconComponent(override || '') : null
);
const enabled = $derived($appSettings.useSelfhstIcons);
// Idempotent (guards on loaded/loading); the $effect fires on mount and again if the
// toggle flips on later, so no separate onMount is needed.
$effect(() => {
if (enabled) ensureSelfhstMatcher();
});
// Reactive match: recomputes when the matcher store loads or the image changes.
const ref = $derived(enabled ? $selfhstMatcher(image, name) : null);
// A failed <img> load (icon 404'd) falls back to the generic box.
let imgFailed = $state(false);
$effect(() => {
// reset the failure flag when the resolved ref changes
void ref;
imgFailed = false;
});
// Show the fallback glyph when the toggle is on (no logo matched) or a caller that
// always had an icon asked for it while off - but never on dense inline spots.
const showFallback = $derived(!hideWhenNoMatch && (enabled || showFallbackWhenOff));
</script>
{#if hasOverride && overrideSelfhst}
<img src="/api/icons/selfhst/{overrideSelfhst}" alt="" class="{className} object-contain shrink-0" />
{:else if hasOverride && overrideCustom && overrideCustomUrl}
<img src={overrideCustomUrl} alt="" class="{className} object-contain shrink-0" />
{:else if hasOverride && OverrideLucide}
<OverrideLucide class="{className} shrink-0" />
{:else if enabled && ref && !imgFailed}
<img
src="/api/icons/selfhst/{ref}"
alt=""
class="{className} object-contain shrink-0"
onerror={() => (imgFailed = true)}
/>
{:else if showFallback}
<!-- Generic icon so the layout stays consistent even with no match. -->
<FallbackIcon class="{className} shrink-0 {fallbackClass}" />
{/if}
+14 -2
View File
@@ -12,18 +12,26 @@
// inherits the title's colour/weight, only the env name is amber. Anything
// else — a rename pencil, a status badge — goes in the `extra` snippet,
// rendered inline after the env suffix.
import ContainerIcon from '$lib/components/ContainerIcon.svelte';
let {
icon,
title,
name = undefined,
envName = undefined,
extra = undefined
extra = undefined,
iconImage = undefined,
iconName = undefined
}: {
icon: Component;
title: string;
name?: string;
envName?: string;
extra?: Snippet;
// When this header describes a container, pass its image (+ name) to show the
// matched app logo in place of the lucide icon; falls back to `icon` with no match.
iconImage?: string;
iconName?: string;
} = $props();
const Icon = $derived(icon);
@@ -31,7 +39,11 @@
</script>
<Dialog.Title class="flex items-center gap-2 min-w-0">
<Icon class="w-5 h-5 shrink-0" />
{#if iconImage !== undefined}
<ContainerIcon image={iconImage} name={iconName ?? ''} class="w-5 h-5" fallbackIcon={icon} showFallbackWhenOff />
{:else}
<Icon class="w-5 h-5 shrink-0" />
{/if}
<span class="shrink-0">{title}</span>
{#if name}
<Tooltip.Root delayDuration={200} ignoreNonKeyboardFocus>
+34
View File
@@ -0,0 +1,34 @@
<script lang="ts">
import { getStackIconComponent, isSelfhstIcon, selfhstRef, isCustomIcon } from '$lib/utils/icons';
interface Props {
/** Icon value: a lucide name, 'selfhst:<ref>', 'custom:<file>', or null/empty. */
icon?: string | null;
/** Stack name, used to build the custom-icon URL. */
stackName?: string;
/** Environment id the stack belongs to (custom icons are keyed by name+env). */
envId?: number | null;
class?: string;
}
let { icon, stackName, envId = null, class: className = 'w-4 h-4' }: Props = $props();
const customUrl = $derived(
stackName
? `/api/stacks/${encodeURIComponent(stackName)}/icon${envId != null ? `?env=${envId}` : ''}`
: ''
);
const selfhst = $derived(selfhstRef(icon));
const custom = $derived(isCustomIcon(icon));
// Lucide is the fallback for any non-selfhst, non-custom value (incl. null).
const LucideIcon = $derived(!isSelfhstIcon(icon) && !custom ? getStackIconComponent(icon || '') : null);
</script>
{#if selfhst}
<img src="/api/icons/selfhst/{selfhst}" alt="" class="{className} object-contain shrink-0" />
{:else if custom && customUrl}
<img src={customUrl} alt="" class="{className} object-contain shrink-0" />
{:else if LucideIcon}
<LucideIcon class={className} />
{/if}
+583 -7
View File
@@ -35,6 +35,9 @@
{
"name": "config-sets"
},
{
"name": "container-icons"
},
{
"name": "containers"
},
@@ -68,6 +71,9 @@
{
"name": "host"
},
{
"name": "icons"
},
{
"name": "images"
},
@@ -3595,6 +3601,12 @@
"hostPath": {
"type": "string"
},
"cacert": {
"type": "string"
},
"tlsClientCert": {
"type": "string"
},
"policies": {
"type": "string"
}
@@ -3727,6 +3739,12 @@
"hostPath": {
"type": "string"
},
"cacert": {
"type": "string"
},
"tlsClientCert": {
"type": "string"
},
"policies": {
"type": "string"
}
@@ -4103,6 +4121,12 @@
"envVars": {
"type": "object",
"properties": {}
},
"cacert": {
"type": "string"
},
"tlsClientCert": {
"type": "string"
}
}
},
@@ -5977,6 +6001,244 @@
]
}
},
"/api/container-icons": {
"get": {
"operationId": "get_api_container-icons",
"tags": [
"container-icons"
],
"summary": "List all container icon overrides for an environment as a name -> icon map",
"parameters": [
{
"name": "env",
"in": "query",
"required": false,
"schema": {
"type": "integer"
},
"description": "Environment id"
}
],
"responses": {
"200": {
"description": "A JSON object mapping container name to its icon value (lucide name, `selfhst:<ref>`, or `custom:container`)"
},
"403": {
"description": "Permission denied (needs containers:view)"
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
],
"description": "Returns every container that has a user-set icon override in the given environment, so the containers list can render them in one request instead of one lookup per row."
}
},
"/api/container-icons/{name}": {
"get": {
"operationId": "get_api_container-icons_name",
"tags": [
"container-icons"
],
"summary": "Get a container's uploaded custom icon (raw image/webp bytes, not JSON)",
"parameters": [
{
"name": "name",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "Container name"
},
{
"name": "env",
"in": "query",
"required": false,
"schema": {
"type": "integer"
},
"description": "Environment id the container belongs to"
}
],
"responses": {
"200": {
"description": "Binary image/webp response body, Cache-Control public max-age=3600"
},
"403": {
"description": "Permission denied (needs containers:view)"
},
"404": {
"description": "No custom icon set for this container"
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
]
},
"post": {
"operationId": "post_api_container-icons_name",
"tags": [
"container-icons"
],
"summary": "Set a container's icon override - either a name reference or an uploaded image",
"parameters": [
{
"name": "name",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "Container name"
},
{
"name": "env",
"in": "query",
"required": false,
"schema": {
"type": "integer"
},
"description": "Environment id the container belongs to"
}
],
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"icon": {
"type": "string"
}
},
"required": [
"success",
"icon"
]
},
"example": {
"success": true,
"icon": "selfhst:plex"
}
}
}
},
"400": {
"description": "Neither icon nor image supplied, or image exceeds the size limit"
},
"403": {
"description": "Permission denied (needs containers:edit)"
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
],
"description": "Body carries EITHER `icon` (a lucide name or `selfhst:<ref>`) to set a referenced icon, OR `image` (a base64-encoded data URL, ~300KB limit) to upload a custom one (stored as `custom:container`). One of the two is required. The override is keyed by container name + environment, so it survives container recreation.",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"icon": {
"type": "string"
},
"image": {
"type": "string"
}
}
},
"example": {
"icon": "string",
"image": "string"
}
}
}
}
},
"delete": {
"operationId": "delete_api_container-icons_name",
"tags": [
"container-icons"
],
"summary": "Remove a container's icon override (fall back to automatic matching)",
"parameters": [
{
"name": "name",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "Container name"
},
{
"name": "env",
"in": "query",
"required": false,
"schema": {
"type": "integer"
},
"description": "Environment id the container belongs to"
}
],
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
}
},
"required": [
"success"
]
},
"example": {
"success": true
}
}
}
},
"403": {
"description": "Permission denied (needs containers:edit)"
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
]
}
},
"/api/containers": {
"get": {
"operationId": "get_api_containers",
@@ -15141,6 +15403,73 @@
]
}
},
"/api/icons/selfhst/{ref}": {
"get": {
"operationId": "get_api_icons_selfhst_ref",
"tags": [
"icons"
],
"summary": "Get a selfh.st app icon (raw SVG), fetched once from the CDN and cached on disk",
"parameters": [
{
"name": "ref",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "selfh.st icon Reference (lowercase letters, digits, hyphens), e.g. \"plex\""
}
],
"responses": {
"200": {
"description": "Raw image/svg+xml body, Cache-Control public max-age=604800"
},
"400": {
"description": "The ref is not a valid selfh.st reference"
},
"404": {
"description": "No such icon (invalid ref or upstream fetch failed)"
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
],
"description": "Proxies an icon from the selfh.st collection so the browser never contacts an external CDN directly. The first request for a given ref fetches the SVG from jsdelivr and caches it under DATA_DIR; later requests are served locally. Icons are CC BY 4.0 (selfh.st). Returns 404 if the ref is invalid or the icon can't be fetched, so the UI falls back to a generic icon."
}
},
"/api/icons/selfhst-manifest": {
"get": {
"operationId": "get_api_icons_selfhst-manifest",
"tags": [
"icons"
],
"summary": "Get the selfh.st icon manifest (index.json), cached on disk with a 7-day TTL",
"parameters": [],
"responses": {
"200": {
"description": "The raw selfh.st index.json array (application/json)"
},
"503": {
"description": "The manifest is unavailable (never cached and the upstream fetch failed)"
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
],
"description": "Returns the selfh.st collection manifest (~2880 entries with Name, Reference, format flags, Category, Tags) so the icon picker can search it. Fetched once from the CDN and cached under DATA_DIR. Returns 503 only if it has never been fetched and the fetch fails."
}
},
"/api/images": {
"get": {
"operationId": "get_api_images",
@@ -24446,7 +24775,7 @@
"tags": [
"stacks"
],
"summary": "Get a stack's env vars (non-secrets from file, secrets masked from DB) plus injected provider keys",
"summary": "Get a stack's env vars plus injected provider keys",
"parameters": [
{
"name": "name",
@@ -24478,7 +24807,23 @@
"variables": {
"type": "array",
"items": {
"type": "string"
"type": "object",
"properties": {
"key": {
"type": "string"
},
"value": {
"type": "string"
},
"isSecret": {
"type": "boolean"
}
},
"required": [
"key",
"value",
"isSecret"
]
}
},
"injectedSecretKeys": {
@@ -24497,7 +24842,11 @@
},
"example": {
"variables": [
"string"
{
"key": "string",
"value": "string",
"isSecret": true
}
],
"injectedSecretKeys": [
"string"
@@ -24521,14 +24870,15 @@
{
"bearerAuth": []
}
]
],
"description": "The source of `variables` depends on the stack type. For a GIT stack, ALL variables (secret and non-secret) come from the database via this endpoint - the DB is the canonical store the UI, `POST /env/validate`, and deploys read. For an INTERNAL/adopted stack, non-secrets come from the on-disk `.env` file (written via `PUT /env/raw`) and only secrets come from the DB. Secret values are masked as `***` and never returned in plaintext. Each variable is `{ key, value, isSecret }`."
},
"put": {
"operationId": "put_api_stacks_name_env",
"tags": [
"stacks"
],
"summary": "Save a stack's secret env vars to the DB (secrets never hit disk)",
"summary": "Save a stack's env vars to the DB",
"parameters": [
{
"name": "name",
@@ -24571,6 +24921,7 @@
"bearerAuth": []
}
],
"description": "Persists EVERY variable in the body to the database (secrets and non-secrets alike). For a GIT stack this is the canonical store - non-secret overrides and secrets both belong here, NOT in the `.env` file. For an INTERNAL/adopted stack the DB is used for secrets while non-secrets are expected in the `.env` file (written via `PUT /env/raw`); posting a non-secret here still saves it but the file remains the source the UI reads for that type. A masked secret value of `***` is left unchanged (the stored value is preserved). Each item is `{ key, value, isSecret? }`; a request whose `variables` are not such objects is rejected with 400.",
"requestBody": {
"required": true,
"content": {
@@ -24581,7 +24932,22 @@
"variables": {
"type": "array",
"items": {
"type": "string"
"type": "object",
"properties": {
"key": {
"type": "string"
},
"value": {
"type": "string"
},
"isSecret": {
"type": "boolean"
}
},
"required": [
"key",
"value"
]
}
}
},
@@ -24591,7 +24957,16 @@
},
"example": {
"variables": [
"string"
{
"key": "DB_HOST",
"value": "db",
"isSecret": false
},
{
"key": "DB_PASSWORD",
"value": "s3cret",
"isSecret": true
}
]
}
}
@@ -24902,6 +25277,207 @@
}
}
},
"/api/stacks/{name}/icon": {
"get": {
"operationId": "get_api_stacks_name_icon",
"tags": [
"stacks"
],
"summary": "Get a stack's uploaded custom icon (raw image/webp bytes, not JSON)",
"parameters": [
{
"name": "name",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "Stack name"
},
{
"name": "env",
"in": "query",
"required": false,
"schema": {
"type": "integer"
},
"description": "Environment id the stack belongs to"
}
],
"responses": {
"200": {
"description": "Binary image/webp response body, Cache-Control public max-age=3600"
},
"403": {
"description": "Permission denied (needs stacks:view)"
},
"404": {
"description": "No custom icon set for this stack"
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
]
},
"post": {
"operationId": "post_api_stacks_name_icon",
"tags": [
"stacks"
],
"summary": "Set a stack's icon - either a name reference or an uploaded image",
"parameters": [
{
"name": "name",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "Stack name"
},
{
"name": "env",
"in": "query",
"required": false,
"schema": {
"type": "integer"
},
"description": "Environment id the stack belongs to"
}
],
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"icon": {
"type": "string"
}
},
"required": [
"success",
"icon"
]
},
"example": {
"success": true,
"icon": "selfhst:plex"
}
}
}
},
"400": {
"description": "Neither icon nor image supplied, or image exceeds the size limit"
},
"403": {
"description": "Permission denied (needs stacks:edit)"
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
],
"description": "Body carries EITHER `icon` (a lucide name or `selfhst:<ref>`) to set a referenced icon, OR `image` (a base64-encoded data URL, ~300KB limit) to upload a custom one (stored as `custom:stack`). One of the two is required.",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"icon": {
"type": "string"
},
"image": {
"type": "string"
}
}
},
"example": {
"icon": "string",
"image": "string"
}
}
}
}
},
"delete": {
"operationId": "delete_api_stacks_name_icon",
"tags": [
"stacks"
],
"summary": "Remove a stack's custom icon",
"parameters": [
{
"name": "name",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "Stack name"
},
{
"name": "env",
"in": "query",
"required": false,
"schema": {
"type": "integer"
},
"description": "Environment id the stack belongs to"
}
],
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
}
},
"required": [
"success"
]
},
"example": {
"success": true
}
}
}
},
"403": {
"description": "Permission denied (needs stacks:edit)"
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
]
}
},
"/api/stacks/{name}/relocate": {
"post": {
"operationId": "post_api_stacks_name_relocate",
+43
View File
@@ -0,0 +1,43 @@
import { resolve } from 'path';
import { existsSync, mkdirSync, writeFileSync, unlinkSync, readFileSync } from 'fs';
import { createHash } from 'crypto';
// Re-export so the icon endpoints share ONE magic-byte check with stacks.
export { looksLikeImage } from './stack-icons';
/**
* Custom uploaded container icons, mirroring stack-icons.ts. A container has no
* stable numeric id (it is keyed by name+env, the name being stable across
* recreation), so the on-disk filename is a hash of name+env - collision-free and
* filesystem-safe regardless of the container name.
*/
function getIconsDir(): string {
const dataDir = process.env.DATA_DIR || './data';
const dir = resolve(dataDir, 'icons', 'containers');
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
return dir;
}
/** Stable, path-safe basename for a container's custom icon. */
export function containerIconKey(containerName: string, envId: number | null): string {
return createHash('sha256').update(`${containerName} ${envId ?? ''}`).digest('hex').slice(0, 32);
}
function iconPath(containerName: string, envId: number | null): string {
return resolve(getIconsDir(), `${containerIconKey(containerName, envId)}.webp`);
}
export function saveContainerIcon(containerName: string, envId: number | null, base64Data: string): void {
const base64 = base64Data.replace(/^data:image\/\w+;base64,/, '');
writeFileSync(iconPath(containerName, envId), Buffer.from(base64, 'base64'));
}
export function deleteContainerIcon(containerName: string, envId: number | null): void {
const path = iconPath(containerName, envId);
if (existsSync(path)) unlinkSync(path);
}
export function getContainerIconBuffer(containerName: string, envId: number | null): Buffer | null {
const path = iconPath(containerName, envId);
return existsSync(path) ? readFileSync(path) : null;
}
+74 -6
View File
@@ -43,6 +43,7 @@ import {
gitStacks,
secretProviders,
stackSources,
containerIconOverrides,
vulnerabilityScans,
auditLogs,
containerEvents,
@@ -2946,6 +2947,7 @@ export interface StackSourceData {
composePath: string | null;
envPath: string | null;
secretProviderId: number | null;
icon: string | null;
createdAt: string;
updatedAt: string;
}
@@ -3054,6 +3056,7 @@ export async function upsertStackSource(data: {
composePath?: string | null;
envPath?: string | null;
secretProviderId?: number | null;
icon?: string | null;
}): Promise<StackSourceData> {
const existing = await getStackSource(data.stackName, data.environmentId);
@@ -3077,7 +3080,9 @@ export async function upsertStackSource(data: {
envPath: data.envPath ?? null,
updatedAt: new Date().toISOString(),
// Preserve existing binding when caller (like git) omits it
...(data.secretProviderId !== undefined && { secretProviderId: data.secretProviderId })
...(data.secretProviderId !== undefined && { secretProviderId: data.secretProviderId }),
// Same preserve-on-omit for the icon, so a git sync doesn't wipe a user's choice
...(data.icon !== undefined && { icon: data.icon })
})
.where(eq(stackSources.id, existing.id));
return getStackSource(data.stackName, data.environmentId) as Promise<StackSourceData>;
@@ -3091,7 +3096,8 @@ export async function upsertStackSource(data: {
gitStackId: data.gitStackId || null,
composePath: data.composePath ?? null,
envPath: data.envPath ?? null,
secretProviderId: data.secretProviderId ?? null
secretProviderId: data.secretProviderId ?? null,
icon: data.icon ?? null
});
return getStackSource(data.stackName, data.environmentId) as Promise<StackSourceData>;
}
@@ -3100,7 +3106,7 @@ export async function upsertStackSource(data: {
export async function updateStackSource(
stackName: string,
environmentId: number | null,
updates: { composePath?: string | null; envPath?: string | null; secretProviderId?: number | null }
updates: { composePath?: string | null; envPath?: string | null; secretProviderId?: number | null; icon?: string | null }
): Promise<boolean> {
const existing = await getStackSource(stackName, environmentId);
if (!existing) return false;
@@ -3110,6 +3116,7 @@ export async function updateStackSource(
composePath: updates.composePath !== undefined ? updates.composePath : existing.composePath,
envPath: updates.envPath !== undefined ? updates.envPath : existing.envPath,
secretProviderId: updates.secretProviderId !== undefined ? updates.secretProviderId : existing.secretProviderId,
icon: updates.icon !== undefined ? updates.icon : existing.icon,
updatedAt: new Date().toISOString()
})
.where(eq(stackSources.id, existing.id));
@@ -3193,6 +3200,53 @@ export async function updateStackSourceName(
return true;
}
// =============================================================================
// CONTAINER ICON OVERRIDES
// =============================================================================
// A user-picked icon for a container, keyed by (name, env). Absent -> the UI's
// automatic image/name matching applies. The value is a lucide name,
// 'selfhst:<ref>', or 'custom:container' (bytes on disk via container-icons.ts).
function containerEnvClause(environmentId: number | null) {
return environmentId !== null
? eq(containerIconOverrides.environmentId, environmentId)
: isNull(containerIconOverrides.environmentId);
}
/** The icon override for one container, or null if none is set. */
export async function getContainerIconOverride(containerName: string, environmentId: number | null): Promise<string | null> {
const rows = await db.select().from(containerIconOverrides)
.where(and(eq(containerIconOverrides.containerName, containerName), containerEnvClause(environmentId)))
.limit(1);
return rows[0]?.icon ?? null;
}
/** All overrides for an environment as a name->icon map, for the list page (no N+1). */
export async function getContainerIconOverrides(environmentId: number | null): Promise<Record<string, string>> {
const rows = await db.select().from(containerIconOverrides).where(containerEnvClause(environmentId));
const map: Record<string, string> = {};
for (const row of rows) map[row.containerName] = row.icon;
return map;
}
/** Upsert the icon override for a container. */
export async function setContainerIconOverride(containerName: string, environmentId: number | null, icon: string): Promise<void> {
const existing = await getContainerIconOverride(containerName, environmentId);
if (existing !== null) {
await db.update(containerIconOverrides)
.set({ icon, updatedAt: new Date().toISOString() })
.where(and(eq(containerIconOverrides.containerName, containerName), containerEnvClause(environmentId)));
} else {
await db.insert(containerIconOverrides).values({ containerName, environmentId, icon });
}
}
/** Remove a container's icon override (fall back to automatic matching). */
export async function deleteContainerIconOverride(containerName: string, environmentId: number | null): Promise<void> {
await db.delete(containerIconOverrides)
.where(and(eq(containerIconOverrides.containerName, containerName), containerEnvClause(environmentId)));
}
// =============================================================================
// VULNERABILITY SCAN RESULTS
// =============================================================================
@@ -5569,6 +5623,8 @@ export async function createBackupDestination(data: {
flags?: string | null;
hostPath?: string | null;
policies?: string | null;
cacert?: string | null;
tlsClientCert?: string | null;
}): Promise<BackupDestination> {
const result = await db.insert(backupDestinations).values({
name: data.name,
@@ -5577,7 +5633,9 @@ export async function createBackupDestination(data: {
envVars: data.envVars ? encrypt(data.envVars) : null,
flags: data.flags ?? null,
hostPath: data.hostPath ?? null,
policies: data.policies ?? null
policies: data.policies ?? null,
cacert: data.cacert ? encrypt(data.cacert) : null,
tlsClientCert: data.tlsClientCert ? encrypt(data.tlsClientCert) : null
}).returning();
return result[0];
}
@@ -5590,6 +5648,8 @@ export async function updateBackupDestination(id: number, data: {
flags?: string | null;
hostPath?: string | null;
policies?: string | null;
cacert?: string | null;
tlsClientCert?: string | null;
lastTestAt?: string | null;
lastTestStatus?: string | null;
lastTestError?: string | null;
@@ -5604,6 +5664,9 @@ export async function updateBackupDestination(id: number, data: {
// Same guard for envVars: an empty string would null out stored cloud creds.
// (An explicit '{}' JSON string is truthy and still clears them intentionally.)
if (data.envVars) updateData.envVars = encrypt(data.envVars);
// Certs (optional): undefined = keep, '' = clear (user removed it), value = encrypt.
if (data.cacert !== undefined) updateData.cacert = data.cacert ? encrypt(data.cacert) : null;
if (data.tlsClientCert !== undefined) updateData.tlsClientCert = data.tlsClientCert ? encrypt(data.tlsClientCert) : null;
if (data.flags !== undefined) updateData.flags = data.flags;
if (data.hostPath !== undefined) updateData.hostPath = data.hostPath;
if (data.policies !== undefined) updateData.policies = data.policies;
@@ -5631,7 +5694,7 @@ export async function updateBackupDestinationTestStatus(id: number, status: 'suc
/**
* Decrypt sensitive fields from a backup destination for runtime use.
*/
export function decryptBackupDestination(dest: BackupDestination): BackupDestination & { decryptedPassword: string; decryptedEnvVars: Record<string, string> } {
export function decryptBackupDestination(dest: BackupDestination): BackupDestination & { decryptedPassword: string; decryptedEnvVars: Record<string, string>; decryptedCacert: string | null; decryptedTlsClientCert: string | null } {
// (audit low #55) Fail closed: if the stored password is a genuine ciphertext
// blob that can't be decrypted (wrong/rotated key), decryptStrict throws rather
// than forwarding the literal `enc:v1:...` string as RESTIC_PASSWORD.
@@ -5647,7 +5710,12 @@ export function decryptBackupDestination(dest: BackupDestination): BackupDestina
}
}
}
return { ...dest, decryptedPassword, decryptedEnvVars };
// PEM certs for a self-signed TLS backend (#1451). Same fail-closed rule as the
// password: a genuine ciphertext that can't be decrypted throws rather than passing
// the literal enc:v1 string to restic.
const decryptedCacert = dest.cacert ? (decryptStrict(dest.cacert) || null) : null;
const decryptedTlsClientCert = dest.tlsClientCert ? (decryptStrict(dest.tlsClientCert) || null) : null;
return { ...dest, decryptedPassword, decryptedEnvVars, decryptedCacert, decryptedTlsClientCert };
}
// =============================================================================
+1
View File
@@ -925,6 +925,7 @@ export const gitRepositories = schemaProxy.gitRepositories;
export const gitStacks = schemaProxy.gitStacks;
export const secretProviders = schemaProxy.secretProviders;
export const stackSources = schemaProxy.stackSources;
export const containerIconOverrides = schemaProxy.containerIconOverrides;
export const vulnerabilityScans = schemaProxy.vulnerabilityScans;
export const auditLogs = schemaProxy.auditLogs;
export const containerEvents = schemaProxy.containerEvents;
+21
View File
@@ -358,12 +358,31 @@ export const stackSources = sqliteTable('stack_sources', {
// Names (no values) of secret keys injected from the bound provider on the last
// deploy, so container inspect can mask them without a live provider call.
injectedSecretKeys: text('injected_secret_keys'),
// Per-stack icon: a lucide name ('server'), 'selfhst:<ref>', or 'custom:<file>'.
// Null -> UI falls back to a generic icon.
icon: text('icon'),
createdAt: text('created_at').default(sql`CURRENT_TIMESTAMP`),
updatedAt: text('updated_at').default(sql`CURRENT_TIMESTAMP`)
}, (table) => ({
stackSourceEnvUnique: unique().on(table.stackName, table.environmentId)
}));
// Per-container icon override. Containers are ephemeral Docker objects (no DB row of
// their own), so the override is keyed by (containerName, environmentId) - the name is
// stable across recreation (auto-update, compose up), unlike the container id. Absent
// row -> the UI's automatic image/name icon matching applies.
export const containerIconOverrides = sqliteTable('container_icon_overrides', {
id: integer('id').primaryKey({ autoIncrement: true }),
containerName: text('container_name').notNull(),
environmentId: integer('environment_id').references(() => environments.id, { onDelete: 'cascade' }),
// A lucide name ('server'), 'selfhst:<ref>', or 'custom:<file>'. Same shape as stack icons.
icon: text('icon').notNull(),
createdAt: text('created_at').default(sql`CURRENT_TIMESTAMP`),
updatedAt: text('updated_at').default(sql`CURRENT_TIMESTAMP`)
}, (table) => ({
containerIconEnvUnique: unique().on(table.containerName, table.environmentId)
}));
export const stackEnvironmentVariables = sqliteTable('stack_environment_variables', {
id: integer('id').primaryKey({ autoIncrement: true }),
stackName: text('stack_name').notNull(),
@@ -506,6 +525,8 @@ export const backupDestinations = sqliteTable('backup_destinations', {
envVars: text('env_vars'), // JSON: { key: value } — values encrypted
flags: text('flags'), // extra restic CLI flags
hostPath: text('host_path'), // bind-mount source for local repos
cacert: text('cacert'), // AES-256-GCM encrypted PEM: self-signed CA for a TLS backend (RESTIC_CACERT)
tlsClientCert: text('tls_client_cert'), // AES-256-GCM encrypted PEM: client cert+key for mTLS (RESTIC_TLS_CLIENT_CERT)
policies: text('policies'), // JSON: { pruneSchedule, checkSchedule, autoUnlock, maxUnused }
lastTestAt: text('last_test_at'),
lastTestStatus: text('last_test_status'), // 'success' | 'failed'
+20
View File
@@ -361,12 +361,30 @@ export const stackSources = pgTable('stack_sources', {
// Names (no values) of secret keys injected from the bound provider on the last
// deploy, so container inspect can mask them without a live provider call.
injectedSecretKeys: text('injected_secret_keys'),
// Per-stack icon: a lucide name ('server'), 'selfhst:<ref>', or 'custom:<file>'.
// Null -> UI falls back to a generic icon.
icon: text('icon'),
createdAt: timestamp('created_at', { mode: 'string' }).defaultNow(),
updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow()
}, (table) => ({
stackSourceEnvUnique: unique().on(table.stackName, table.environmentId)
}));
// Per-container icon override, keyed by (containerName, environmentId). Containers have
// no DB row of their own and are recreated with a new id but a stable name, so the name
// is the durable key. Absent row -> automatic image/name icon matching applies.
export const containerIconOverrides = pgTable('container_icon_overrides', {
id: serial('id').primaryKey(),
containerName: text('container_name').notNull(),
environmentId: integer('environment_id').references(() => environments.id, { onDelete: 'cascade' }),
// A lucide name ('server'), 'selfhst:<ref>', or 'custom:<file>'. Same shape as stack icons.
icon: text('icon').notNull(),
createdAt: timestamp('created_at', { mode: 'string' }).defaultNow(),
updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow()
}, (table) => ({
containerIconEnvUnique: unique().on(table.containerName, table.environmentId)
}));
export const stackEnvironmentVariables = pgTable('stack_environment_variables', {
id: serial('id').primaryKey(),
stackName: text('stack_name').notNull(),
@@ -509,6 +527,8 @@ export const backupDestinations = pgTable('backup_destinations', {
envVars: text('env_vars'),
flags: text('flags'),
hostPath: text('host_path'),
cacert: text('cacert'),
tlsClientCert: text('tls_client_cert'),
policies: text('policies'),
lastTestAt: timestamp('last_test_at', { mode: 'string' }),
lastTestStatus: text('last_test_status'),
+183
View File
@@ -0,0 +1,183 @@
/**
* selfh.st icon proxy + on-disk cache.
*
* The browser NEVER hits an external CDN: it asks our /api/icons/selfhst/<ref>
* endpoint, we fetch the SVG from jsdelivr ONCE, cache it under DATA_DIR, and
* serve it locally forever after. This keeps what a user self-hosts private (no
* per-view CDN request that would reveal their app list) and works offline once
* cached. Icons are CC-BY-4.0 (attribution shown in the picker + manual).
*/
import { resolve, join } from 'path';
import { existsSync, mkdirSync, writeFileSync, readFileSync, statSync, renameSync, unlinkSync } from 'fs';
// Only these hosts/paths are ever fetched - the ref is the sole user-controlled
// input and is strictly validated below, so there is no SSRF surface.
const CDN_BASE = 'https://cdn.jsdelivr.net/gh/selfhst/icons';
const MANIFEST_URL = `${CDN_BASE}/index.json`;
const FETCH_TIMEOUT_MS = 8000;
const MAX_ICON_BYTES = 512 * 1024; // an SVG logo is a few KB; cap defensively
const MANIFEST_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
// A negative-cache tombstone (0-byte file) expires so a ref added upstream later, or a
// transiently-failed fetch, is retried instead of stuck on the fallback glyph forever.
const NEG_CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 1 day
/** Write bytes atomically (temp + rename) so a crash mid-write never leaves a partial file. */
function atomicWrite(path: string, data: Buffer): void {
const tmp = `${path}.${process.pid}.tmp`;
writeFileSync(tmp, data);
renameSync(tmp, path);
}
/** A selfh.st Reference: lowercase letters, digits, hyphens only. Anti-traversal. */
export function isValidSelfhstRef(ref: string): boolean {
return /^[a-z0-9-]{1,64}$/.test(ref);
}
function cacheDir(): string {
const dataDir = process.env.DATA_DIR || './data';
const dir = resolve(dataDir, 'icon-cache', 'selfhst');
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
return dir;
}
/** Absolute cache path for a ref's svg. Throws on an invalid ref (never builds a path from junk). */
export function selfhstCachePath(ref: string): string {
if (!isValidSelfhstRef(ref)) throw new Error('Invalid selfh.st icon reference');
return join(cacheDir(), `${ref}.svg`);
}
async function fetchWithTimeout(url: string): Promise<Response> {
const ac = new AbortController();
const t = setTimeout(() => ac.abort(), FETCH_TIMEOUT_MS);
try {
return await fetch(url, { signal: ac.signal, headers: { Accept: 'image/svg+xml,*/*' } });
} finally {
clearTimeout(t);
}
}
/**
* Return the cached SVG bytes for a ref, fetching+caching from the CDN on a miss.
* Returns null when the ref is invalid, not found upstream, or the fetch fails -
* the caller then serves a 404 and the UI falls back to a generic icon. Never throws.
*/
export async function getSelfhstIcon(ref: string): Promise<Buffer | null> {
if (!isValidSelfhstRef(ref)) return null;
const path = selfhstCachePath(ref);
if (existsSync(path)) {
try {
const cached = readFileSync(path);
// A zero-byte file is a negative-cache tombstone; honour it until it expires,
// then fall through to refetch (a ref added upstream later, or a transient
// failure, must not be stuck on the fallback forever).
if (cached.length === 0) {
if (Date.now() - statSync(path).mtimeMs < NEG_CACHE_TTL_MS) return null;
} else if (cached[0] === 0x3c) {
return cached; // still looks like an SVG - serve it
}
// zero-byte-but-stale, or a nonzero-but-corrupt file (e.g. a truncated write):
// drop it and refetch.
try {
unlinkSync(path);
} catch {
/* best-effort */
}
} catch {
// fall through to refetch
}
}
const tombstone = () => {
try {
atomicWrite(path, Buffer.alloc(0));
} catch {
/* best-effort */
}
};
try {
const res = await fetchWithTimeout(`${CDN_BASE}/svg/${ref}.svg`);
if (!res.ok) {
// Remember the miss (with a TTL) so a matched-but-missing ref isn't re-fetched
// every render - covers 404 and transient 5xx/timeouts alike.
tombstone();
return null;
}
const buf = Buffer.from(await res.arrayBuffer());
if (buf.length === 0 || buf.length > MAX_ICON_BYTES) return null;
// Sanity check: SVG starts with '<' (xml/svg), never binary junk.
if (buf[0] !== 0x3c) return null;
// Strip active content before caching. The icons come from a third-party
// community repo; an SVG is an active-content format, so a poisoned/scripted
// logo could carry <script>/on*/<foreignObject>. We render only via <img>
// (script-inert) AND serve with a locked-down CSP, but sanitizing at rest is
// defense in depth so a cached file can never be a stored-XSS payload.
const clean = Buffer.from(sanitizeSvg(buf.toString('utf-8')), 'utf-8');
atomicWrite(path, clean);
return clean;
} catch {
// Network error / timeout: tombstone with a TTL so we back off instead of
// re-running an 8s-timeout fetch on every remount of the row.
tombstone();
return null;
}
}
/**
* Remove active content from an SVG string: <script> elements, on* event-handler
* attributes, <foreignObject> (can embed HTML), and javascript: URLs. Conservative
* regex strip - the SVGs are simple logos, not documents.
*/
export function sanitizeSvg(svg: string): string {
return svg
.replace(/<script[\s\S]*?<\/script\s*>/gi, '')
.replace(/<foreignObject[\s\S]*?<\/foreignObject\s*>/gi, '')
.replace(/\son\w+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, '')
.replace(/(href|xlink:href)\s*=\s*("|')?\s*javascript:[^"'>]*("|')?/gi, '');
}
// --- manifest (index.json, ~2880 entries) cached for the search picker ---
function manifestPath(): string {
return join(cacheDir(), 'index.json');
}
function manifestFresh(): boolean {
try {
const st = statSync(manifestPath());
return Date.now() - st.mtimeMs < MANIFEST_TTL_MS;
} catch {
return false;
}
}
/**
* Return the selfh.st manifest JSON (as a string), cached on disk with a 7-day TTL.
* Returns null if it has never been fetched and the fetch fails.
*/
export async function getSelfhstManifest(): Promise<string | null> {
const path = manifestPath();
if (manifestFresh()) {
try {
return readFileSync(path, 'utf-8');
} catch {
// fall through to refetch
}
}
try {
const res = await fetchWithTimeout(MANIFEST_URL);
if (!res.ok) throw new Error(`manifest ${res.status}`);
const text = await res.text();
JSON.parse(text); // validate it parses before caching
atomicWrite(path, Buffer.from(text, 'utf-8'));
return text;
} catch {
// Serve a stale cached copy if we have one; otherwise null.
if (existsSync(path)) {
try {
return readFileSync(path, 'utf-8');
} catch {
return null;
}
}
return null;
}
}
Binary file not shown.
+34
View File
@@ -0,0 +1,34 @@
import { writable } from 'svelte/store';
import { createSelfhstMatcher } from '$lib/utils/selfhst-match';
/**
* A single shared matcher ((image, name?) -> selfh.st reference) built from the manifest.
* Resolves by image first, then by container name. The manifest is fetched at most once
* per session, only after something first needs it (i.e. the selfh.st-icons toggle is on).
* Until it loads, the matcher returns null for everything, so containers keep the generic
* icon.
*/
const matcher = writable<(image: string, name?: string) => string | null>(() => null);
let loading = false;
let loaded = false;
export const selfhstMatcher = matcher;
/** Ensure the manifest is loaded and the matcher is ready. Idempotent. */
export async function ensureSelfhstMatcher(): Promise<void> {
if (loaded || loading) return;
loading = true;
try {
const res = await fetch('/api/icons/selfhst-manifest');
if (!res.ok) return;
const entries = (await res.json()) as { Reference?: string; SVG?: string }[];
const refs = new Set<string>();
for (const e of entries) if (e.SVG === 'Yes' && e.Reference) refs.add(e.Reference.toLowerCase());
matcher.set(createSelfhstMatcher(refs));
loaded = true;
} catch {
// leave the null matcher in place; containers keep generic icons
} finally {
loading = false;
}
}
+12
View File
@@ -50,6 +50,8 @@ export interface AppSettings {
defaultBackupImage: string;
honorProxyLabels: boolean;
showImageChangelogLinks: boolean;
// Fetch app logos from selfh.st for container icons (off by default; opt-in).
useSelfhstIcons: boolean;
showWhatsNew: boolean; // show the "What's New" modal after an upgrade (#1235)
protectScannerImages: boolean;
// Scanner Advanced settings (#1219). Empty values = use auto-detection.
@@ -91,6 +93,7 @@ const DEFAULT_SETTINGS: AppSettings = {
labelFilterMode: 'any',
honorProxyLabels: true,
showImageChangelogLinks: true,
useSelfhstIcons: false,
showWhatsNew: true,
protectScannerImages: true,
defaultScannerNetworkMode: '',
@@ -182,6 +185,7 @@ function createSettingsStore() {
defaultBackupImage: settings.defaultBackupImage ?? DEFAULT_SETTINGS.defaultBackupImage,
honorProxyLabels: settings.honorProxyLabels ?? DEFAULT_SETTINGS.honorProxyLabels,
showImageChangelogLinks: settings.showImageChangelogLinks ?? DEFAULT_SETTINGS.showImageChangelogLinks,
useSelfhstIcons: settings.useSelfhstIcons ?? DEFAULT_SETTINGS.useSelfhstIcons,
showWhatsNew: settings.showWhatsNew ?? DEFAULT_SETTINGS.showWhatsNew,
protectScannerImages: settings.protectScannerImages ?? DEFAULT_SETTINGS.protectScannerImages,
defaultScannerNetworkMode: settings.defaultScannerNetworkMode ?? DEFAULT_SETTINGS.defaultScannerNetworkMode,
@@ -241,6 +245,7 @@ function createSettingsStore() {
defaultBackupImage: updatedSettings.defaultBackupImage ?? DEFAULT_SETTINGS.defaultBackupImage,
honorProxyLabels: updatedSettings.honorProxyLabels ?? DEFAULT_SETTINGS.honorProxyLabels,
showImageChangelogLinks: updatedSettings.showImageChangelogLinks ?? DEFAULT_SETTINGS.showImageChangelogLinks,
useSelfhstIcons: updatedSettings.useSelfhstIcons ?? DEFAULT_SETTINGS.useSelfhstIcons,
showWhatsNew: updatedSettings.showWhatsNew ?? DEFAULT_SETTINGS.showWhatsNew,
protectScannerImages: updatedSettings.protectScannerImages ?? DEFAULT_SETTINGS.protectScannerImages,
defaultScannerNetworkMode: updatedSettings.defaultScannerNetworkMode ?? DEFAULT_SETTINGS.defaultScannerNetworkMode,
@@ -292,6 +297,13 @@ function createSettingsStore() {
return newSettings;
});
},
setUseSelfhstIcons: (value: boolean) => {
update((current) => {
const newSettings = { ...current, useSelfhstIcons: value };
saveSettings({ useSelfhstIcons: value });
return newSettings;
});
},
setShowGitCommitHash: (value: boolean) => {
update((current) => {
const newSettings = { ...current, showGitCommitHash: value };
+120
View File
@@ -47,4 +47,124 @@ export function isCustomIcon(icon: string | null | undefined): boolean {
return !!icon && icon.startsWith('custom:');
}
export function isSelfhstIcon(icon: string | null | undefined): boolean {
return !!icon && icon.startsWith('selfhst:');
}
/** The selfh.st reference from a 'selfhst:<ref>' value, or null. */
export function selfhstRef(icon: string | null | undefined): string | null {
return isSelfhstIcon(icon) ? icon!.slice('selfhst:'.length) : null;
}
export { iconMap };
// --- Stack icon set (SEPARATE from env; app/service-oriented) --------------------
// A curated, app-focused set so a stack picker feels different from the env one.
// Every name is verified to exist in lucide-svelte 0.562.0.
import {
DatabaseZap, FileStack, MonitorPlay, TvMinimalPlay, Music4, Disc3, Gamepad2, Joystick,
Dices, Puzzle, Ghost, Trophy, Swords, ChartColumn, Thermometer,
SignalHigh, Waypoints, Cctv, Rss, Webhook, Plug, Antenna, Radar, Bug, FlaskConical,
Blocks, Component, Workflow, Boxes, Bot, BrainCircuit, Lightbulb, MessagesSquare,
Send, Bell, Users, User, AtSign, Hash, FileText, NotebookPen, BookOpen, Library,
Newspaper, ListTodo, Kanban, Calendar, Clock, KeyRound, Fingerprint, ScanLine, ShieldAlert,
Files, CloudDownload, HardDriveDownload, Download, Upload, Save, CreditCard, Wallet, Receipt,
Image as ImageIcon, Images, Film, Clapperboard, Mic, Podcast, FolderGit2, DollarSign,
ShoppingCart, Feather, Palette,
// --- extra app-oriented set (verified in lucide 0.562.0) ---
Airplay, AlarmClock, Album, Aperture, AppWindow, Atom, Award, Backpack, Banknote, Battery,
Beaker, Bean, Bird, Bitcoin, Bluetooth, Bomb, Bone, BookMarked, Briefcase, Brush, Cake,
Calculator, CalendarClock, CandlestickChart, Cast, Cat, ChefHat, Cherry, Cigarette, Clipboard,
Cloudy, Clover, Coffee, Coins, Combine, Contact, Cookie, Croissant, Diamond, Dna, Dog, DoorOpen,
Drum, Dumbbell, Earth, Egg, Factory as FactoryIcon, FerrisWheel, FileArchive, Flower, Focus,
Folder, Forklift, Frame, Fuel, Gift, GitFork, Grape, Grid3x3, HardHat, Hexagon, History,
Hourglass, IceCream, Inbox, Infinity, Keyboard, Lamp, Landmark as LandmarkIcon, Laptop as LaptopIcon,
Leaf, Link, Magnet, Mailbox, Medal, Megaphone, Microscope, Milk, Monitor as MonitorIcon, Mountain,
Mouse, Orbit, Paperclip, Parentheses, PartyPopper, Pencil, PiggyBank, Pin, Pizza,
Popcorn, Power, Printer, Recycle, Ruler, Satellite as SatelliteIcon, Scale, School as SchoolIcon,
Scissors, ScreenShare, Settings as SettingsIcon, Shovel, Shrub, Siren, Skull, Smartphone as SmartphoneIcon,
Speaker, Sprout, Sword, Syringe, Table, Tablet as TabletIcon, Tag, Telescope, Tent, Thermometer as ThermometerIcon2,
ThumbsUp, Ticket, ToyBrick, Trash, TreePine, Truck as TruckIcon, Tv as TvIcon, Umbrella, University,
Usb, Verified, Wand, Watch, Waves, Wind, Wine
} from 'lucide-svelte';
const stackIconMap: Record<string, ComponentType> = {
// data / db / storage
'database': Database, 'database-zap': DatabaseZap, 'server': Server, 'hard-drive': HardDrive,
'boxes': Boxes, 'container': Container, 'box': Box, 'layers': FileStack, 'archive': Archive,
'files': Files, 'folder-open': FolderOpen, 'package': Package, 'save': Save,
'cloud-download': CloudDownload, 'hard-drive-download': HardDriveDownload, 'download': Download, 'upload': Upload,
// media
'film': Film, 'clapperboard': Clapperboard, 'monitor-play': MonitorPlay, 'tv-play': TvMinimalPlay,
'music': Music4, 'disc': Disc3, 'headphones': Headphones, 'mic': Mic, 'podcast': Podcast,
'image': ImageIcon, 'images': Images, 'camera': Camera, 'video': Video, 'radio': Radio,
// gaming
'gamepad': Gamepad2, 'joystick': Joystick, 'dices': Dices, 'puzzle': Puzzle, 'ghost': Ghost,
'trophy': Trophy, 'swords': Swords,
// monitoring / metrics
'activity': Activity, 'bar-chart': ChartColumn, 'line-chart': ChartLine, 'pie-chart': ChartPie,
'gauge': Gauge, 'trending-up': TrendingUp, 'cpu': Cpu, 'thermometer': Thermometer,
'signal': SignalHigh, 'waypoints': Waypoints,
// network / proxy
'network': Network, 'globe': Globe, 'router': Router, 'wifi': Wifi, 'cable': Cable,
'cctv': Cctv, 'rss': Rss, 'webhook': Webhook, 'plug': Plug, 'antenna': Antenna, 'radar': Radar,
// dev / code
'code': Code, 'terminal': Terminal, 'git-branch': GitBranch, 'folder-git': FolderGit2,
'braces': Braces, 'binary': Binary, 'bug': Bug, 'flask': FlaskConical, 'blocks': Blocks,
'component': Component, 'workflow': Workflow,
// automation / home
'bot': Bot, 'brain': BrainCircuit, 'home': House, 'lightbulb': Lightbulb, 'zap': Zap,
'cog': Cog, 'wrench': Wrench,
// comms / social
'mail': Mail, 'message-square': MessageSquare, 'messages': MessagesSquare, 'send': Send,
'bell': Bell, 'users': Users, 'user': User, 'phone': Phone, 'at-sign': AtSign, 'hash': Hash,
// productivity / docs
'file-text': FileText, 'notebook': NotebookPen, 'book-open': BookOpen, 'library': Library,
'newspaper': Newspaper, 'list-todo': ListTodo, 'kanban': Kanban, 'calendar': Calendar,
'clock': Clock, 'bookmark': Bookmark,
// security
'shield': Shield, 'shield-check': ShieldCheck, 'shield-alert': ShieldAlert, 'lock': Lock,
'key': Key, 'key-round': KeyRound, 'fingerprint': Fingerprint, 'scan': ScanLine,
// finance / commerce
'dollar': DollarSign, 'shopping-cart': ShoppingCart, 'credit-card': CreditCard,
'wallet': Wallet, 'receipt': Receipt, 'store': Store,
// identity / misc
'rocket': Rocket, 'flame': Flame, 'star': Star, 'sparkles': Sparkles, 'heart': Heart,
'anchor': Anchor, 'ship': Ship, 'cloud': Cloud, 'feather': Feather, 'palette': Palette,
'gem': Gem, 'crown': Crown, 'target': Target, 'flag': Flag, 'compass': Compass,
// extra set
'airplay': Airplay, 'alarm-clock': AlarmClock, 'album': Album, 'aperture': Aperture,
'app-window': AppWindow, 'atom': Atom, 'award': Award, 'backpack': Backpack, 'banknote': Banknote,
'battery': Battery, 'beaker': Beaker, 'bean': Bean, 'bird': Bird, 'bitcoin': Bitcoin,
'bluetooth': Bluetooth, 'bomb': Bomb, 'bone': Bone, 'book-marked': BookMarked, 'briefcase': Briefcase,
'brush': Brush, 'cake': Cake, 'calculator': Calculator, 'calendar-clock': CalendarClock,
'candlestick': CandlestickChart, 'cast': Cast, 'cat': Cat, 'chef-hat': ChefHat, 'cherry': Cherry,
'cigarette': Cigarette, 'clipboard': Clipboard, 'cloudy': Cloudy, 'clover': Clover, 'coffee': Coffee,
'coins': Coins, 'combine': Combine, 'contact': Contact, 'cookie': Cookie, 'croissant': Croissant,
'diamond': Diamond, 'dna': Dna, 'dog': Dog, 'door-open': DoorOpen, 'drum': Drum, 'dumbbell': Dumbbell,
'earth': Earth, 'egg': Egg, 'factory': FactoryIcon, 'ferris-wheel': FerrisWheel, 'file-archive': FileArchive,
'flower': Flower, 'focus': Focus, 'folder': Folder, 'forklift': Forklift, 'frame': Frame, 'fuel': Fuel,
'gift': Gift, 'git-fork': GitFork, 'grape': Grape, 'grid': Grid3x3, 'hard-hat': HardHat, 'hexagon': Hexagon,
'history': History, 'hourglass': Hourglass, 'ice-cream': IceCream, 'inbox': Inbox, 'infinity': Infinity,
'keyboard': Keyboard, 'lamp': Lamp, 'landmark': LandmarkIcon, 'laptop': LaptopIcon, 'leaf': Leaf,
'link': Link, 'magnet': Magnet, 'mailbox': Mailbox, 'medal': Medal, 'megaphone': Megaphone,
'microscope': Microscope, 'milk': Milk, 'monitor': MonitorIcon, 'mountain': Mountain, 'mouse': Mouse,
'orbit': Orbit, 'paperclip': Paperclip, 'parentheses': Parentheses,
'party-popper': PartyPopper, 'pencil': Pencil, 'piggy-bank': PiggyBank, 'pin': Pin, 'pizza': Pizza,
'popcorn': Popcorn, 'power': Power, 'printer': Printer, 'recycle': Recycle, 'ruler': Ruler,
'satellite': SatelliteIcon, 'scale': Scale, 'school': SchoolIcon, 'scissors': Scissors, 'screen-share': ScreenShare,
'settings': SettingsIcon, 'shovel': Shovel, 'shrub': Shrub, 'siren': Siren, 'skull': Skull,
'smartphone': SmartphoneIcon, 'speaker': Speaker, 'sprout': Sprout, 'sword': Sword, 'syringe': Syringe,
'table': Table, 'tablet': TabletIcon, 'tag': Tag, 'telescope': Telescope, 'tent': Tent,
'thermometer2': ThermometerIcon2, 'thumbs-up': ThumbsUp, 'ticket': Ticket, 'toy-brick': ToyBrick,
'trash': Trash, 'tree': TreePine, 'truck': TruckIcon, 'tv': TvIcon, 'umbrella': Umbrella,
'university': University, 'usb': Usb, 'verified': Verified, 'wand': Wand, 'watch': Watch,
'waves': Waves, 'wind': Wind, 'wine': Wine
};
/** Component for a stack lucide-name icon, defaulting to Boxes (a stack-y default). */
export function getStackIconComponent(iconName: string): ComponentType {
return stackIconMap[iconName] || Boxes;
}
export { stackIconMap };
+103
View File
@@ -0,0 +1,103 @@
// Pure image-name -> selfh.st reference matcher. Conservative by design: a
// confident hit returns the reference, anything uncertain returns null so the UI
// keeps its generic icon (never a wrong logo). Memoized per image string so a
// list of 100 containers costs one lookup per unique image, not per render.
/**
* Aliases ONLY for cases where the Docker image basename differs from the selfh.st
* Reference. An image whose basename already IS a known reference (redis, grafana,
* sonarr, ...) needs no entry - resolveBase() falls back to an exact match. Keep this
* small and high-confidence. There is deliberately no fuzzy/substring fallback (it
* mis-badges, e.g. `redis-exporter` -> `redis`).
*/
const IMAGE_ALIASES: Record<string, string> = {
'pms-docker': 'plex',
'plexinc': 'plex',
'homeassistant': 'home-assistant',
'hass': 'home-assistant',
'pihole': 'pi-hole',
'adguardhome': 'adguard-home',
'portainer-ce': 'portainer',
'portainer-ee': 'portainer',
'qbittorrentvpn': 'qbittorrent',
'postgres': 'postgresql',
'mongo': 'mongodb'
};
/**
* Reduce a full image reference to its lowercase basename, dropping registry
* host, namespace, tag and digest. `lscr.io/linuxserver/sonarr:latest` -> `sonarr`.
*/
export function imageBasename(image: string): string {
let s = (image || '').trim().toLowerCase();
if (!s) return '';
// strip digest, then tag
s = s.split('@')[0];
const lastColon = s.lastIndexOf(':');
const lastSlash = s.lastIndexOf('/');
if (lastColon > lastSlash) s = s.slice(0, lastColon);
// basename after the last slash
s = s.slice(lastSlash + 1);
return s;
}
/**
* Resolve an already-normalized base name to a known selfh.st Reference: alias table,
* then exact match. No fuzzy substring guessing (that mis-badges - e.g. `redis-exporter`
* should not become `redis`). Returns null when there is no confident match.
*/
function resolveBase(base: string, knownRefs: Set<string>): string | null {
if (!base) return null;
const alias = IMAGE_ALIASES[base];
if (alias && knownRefs.has(alias)) return alias;
if (knownRefs.has(base)) return base;
return null;
}
/** Match a Docker image reference to a selfh.st Reference (by its basename). */
export function matchSelfhstRef(image: string, knownRefs: Set<string>): string | null {
return resolveBase(imageBasename(image), knownRefs);
}
/**
* Normalize a container name to a candidate reference: drop a leading slash and the
* compose replica suffix (`-1` / `_1`), lowercase. `/immich_server_1` -> `immich_server`,
* `/traefik-1` -> `traefik`.
*/
export function containerNameBase(name: string): string {
let s = (name || '').trim().toLowerCase();
if (!s) return '';
if (s.startsWith('/')) s = s.slice(1);
// compose appends a numeric replica index: web-1, db_1
s = s.replace(/[-_]\d+$/, '');
return s;
}
/**
* Fallback match by CONTAINER NAME when the image did not resolve (e.g. an image pinned
* by digest with no readable tag). Same confidence bar as the image path: alias table,
* then exact name == reference. No fuzzy guessing - a generic name like `web` or `app`
* that isn't a known reference stays unmatched.
*/
export function matchSelfhstByName(name: string, knownRefs: Set<string>): string | null {
return resolveBase(containerNameBase(name), knownRefs);
}
/**
* Build a memoized matcher bound to a manifest's reference set. Resolves by image first
* (authoritative), then falls back to the container name when the image did not match.
* Memoized per (image, name) pair so a list of 100 containers costs one lookup per unique
* pair, not per render.
*/
export function createSelfhstMatcher(
knownRefs: Set<string>
): (image: string, name?: string) => string | null {
const cache = new Map<string, string | null>();
return (image: string, name = '') => {
const key = `${image} ${name}`;
if (cache.has(key)) return cache.get(key)!;
const ref = matchSelfhstRef(image, knownRefs) ?? matchSelfhstByName(name, knownRefs);
cache.set(key, ref);
return ref;
};
}