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:
40 files changed
+9520
-38
No files matched your search
@@ -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}
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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.
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user