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 @@
|
||||
ALTER TABLE "stack_sources" ADD COLUMN "icon" text;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1 @@
|
||||
ALTER TABLE `stack_sources` ADD `icon` text;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { getContainerIconOverrides } from '$lib/server/db';
|
||||
|
||||
function parseEnv(raw: string | null): number | null {
|
||||
if (!raw) return null;
|
||||
const n = parseInt(raw, 10);
|
||||
return Number.isNaN(n) ? null : n;
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* summary: List all container icon overrides for an environment as a name -> icon map
|
||||
* 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.
|
||||
* query: env:integer Environment id
|
||||
* resp-200: A JSON object mapping container name to its icon value (lucide name, `selfhst:<ref>`, or `custom:container`)
|
||||
* resp-200-example: {"plex":"selfhst:plex","db":"custom:container"}
|
||||
* resp-403: Permission denied (needs containers:view)
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
const envId = parseEnv(url.searchParams.get('env'));
|
||||
if (auth.authEnabled && !(await auth.can('containers', 'view', envId ?? undefined))) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
return json(await getContainerIconOverrides(envId));
|
||||
};
|
||||
@@ -0,0 +1,101 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { getContainerIconOverride, setContainerIconOverride, deleteContainerIconOverride } from '$lib/server/db';
|
||||
import { saveContainerIcon, deleteContainerIcon, getContainerIconBuffer, looksLikeImage } from '$lib/server/container-icons';
|
||||
|
||||
function parseEnv(raw: string | null): number | null {
|
||||
if (!raw) return null;
|
||||
const n = parseInt(raw, 10);
|
||||
return Number.isNaN(n) ? null : n;
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* summary: Get a container's uploaded custom icon (raw image/webp bytes, not JSON)
|
||||
* path: name:string! Container name
|
||||
* query: env:integer Environment id the container belongs to
|
||||
* resp-200: Binary image/webp response body, Cache-Control public max-age=3600
|
||||
* resp-403: Permission denied (needs containers:view)
|
||||
* resp-404: No custom icon set for this container
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
const envId = parseEnv(url.searchParams.get('env'));
|
||||
if (auth.authEnabled && !(await auth.can('containers', 'view', envId ?? undefined))) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
const buffer = getContainerIconBuffer(params.name, envId);
|
||||
if (!buffer) return json({ error: 'No custom icon' }, { status: 404 });
|
||||
return new Response(new Uint8Array(buffer), {
|
||||
headers: {
|
||||
'Content-Type': 'image/webp',
|
||||
// no-cache (revalidate every load) so a re-uploaded icon at the same URL isn't
|
||||
// served stale - the override URL has no version token to bust a long cache.
|
||||
'Cache-Control': 'no-cache, must-revalidate',
|
||||
'X-Content-Type-Options': 'nosniff'
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* summary: Set a container's icon override - either a name reference or an uploaded image
|
||||
* 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.
|
||||
* path: name:string! Container name
|
||||
* query: env:integer Environment id the container belongs to
|
||||
* body: {icon:string, image:string}
|
||||
* resp-200: {success:boolean!, icon:string!}
|
||||
* resp-200-example: {"success":true,"icon":"selfhst:plex"}
|
||||
* resp-400: Neither icon nor image supplied, or image exceeds the size limit
|
||||
* resp-403: Permission denied (needs containers:edit)
|
||||
*/
|
||||
export const POST: RequestHandler = async ({ params, url, request, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
const envId = parseEnv(url.searchParams.get('env'));
|
||||
if (auth.authEnabled && !(await auth.can('containers', 'edit', envId ?? undefined))) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
const data = await request.json();
|
||||
|
||||
let iconValue: string;
|
||||
if (typeof data.image === 'string' && data.image) {
|
||||
if (data.image.length > 400_000) {
|
||||
return json({ error: 'Image too large' }, { status: 400 });
|
||||
}
|
||||
const raw = Buffer.from(data.image.replace(/^data:image\/\w+;base64,/, ''), 'base64');
|
||||
if (!looksLikeImage(raw)) {
|
||||
return json({ error: 'Uploaded file is not a recognised image' }, { status: 400 });
|
||||
}
|
||||
saveContainerIcon(params.name, envId, data.image);
|
||||
iconValue = 'custom:container';
|
||||
} else if (typeof data.icon === 'string' && data.icon) {
|
||||
// A referenced icon (lucide name or selfhst:<ref>) - drop any old upload.
|
||||
deleteContainerIcon(params.name, envId);
|
||||
iconValue = data.icon;
|
||||
} else {
|
||||
return json({ error: 'Missing icon or image' }, { status: 400 });
|
||||
}
|
||||
|
||||
await setContainerIconOverride(params.name, envId, iconValue);
|
||||
return json({ success: true, icon: iconValue });
|
||||
};
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* summary: Remove a container's icon override (fall back to automatic matching)
|
||||
* path: name:string! Container name
|
||||
* query: env:integer Environment id the container belongs to
|
||||
* resp-200: {success:boolean!}
|
||||
* resp-403: Permission denied (needs containers:edit)
|
||||
*/
|
||||
export const DELETE: RequestHandler = async ({ params, url, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
const envId = parseEnv(url.searchParams.get('env'));
|
||||
if (auth.authEnabled && !(await auth.can('containers', 'edit', envId ?? undefined))) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
deleteContainerIcon(params.name, envId);
|
||||
await deleteContainerIconOverride(params.name, envId);
|
||||
return json({ success: true });
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getSelfhstManifest } from '$lib/server/selfhst-icons';
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* summary: Get the selfh.st icon manifest (index.json), cached on disk with a 7-day TTL
|
||||
* 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.
|
||||
* resp-200: The raw selfh.st index.json array (application/json)
|
||||
* resp-503: The manifest is unavailable (never cached and the upstream fetch failed)
|
||||
*/
|
||||
export const GET: RequestHandler = async () => {
|
||||
const manifest = await getSelfhstManifest();
|
||||
if (!manifest) {
|
||||
return json({ error: 'Icon manifest unavailable' }, { status: 503 });
|
||||
}
|
||||
return new Response(manifest, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'public, max-age=3600'
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getSelfhstIcon, isValidSelfhstRef } from '$lib/server/selfhst-icons';
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* summary: Get a selfh.st app icon (raw SVG), fetched once from the CDN and cached on disk
|
||||
* 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.
|
||||
* path: ref:string! selfh.st icon Reference (lowercase letters, digits, hyphens), e.g. "plex"
|
||||
* resp-200: Raw image/svg+xml body, Cache-Control public max-age=604800
|
||||
* resp-400: The ref is not a valid selfh.st reference
|
||||
* resp-404: No such icon (invalid ref or upstream fetch failed)
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ params }) => {
|
||||
const ref = params.ref;
|
||||
if (!isValidSelfhstRef(ref)) {
|
||||
return json({ error: 'Invalid icon reference' }, { status: 400 });
|
||||
}
|
||||
const buf = await getSelfhstIcon(ref);
|
||||
if (!buf) {
|
||||
return json({ error: 'Icon not found' }, { status: 404 });
|
||||
}
|
||||
return new Response(new Uint8Array(buf), {
|
||||
headers: {
|
||||
'Content-Type': 'image/svg+xml',
|
||||
'Cache-Control': 'public, max-age=604800',
|
||||
// The SVG is sanitized at cache time; these headers are belt-and-braces so
|
||||
// even a direct navigation to this URL cannot execute embedded content.
|
||||
'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'; sandbox",
|
||||
'X-Content-Type-Options': 'nosniff'
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -113,6 +113,8 @@ export interface GeneralSettings {
|
||||
// Whether to surface a "view changelog" link next to the update badge.
|
||||
// Resolved client-side from OCI labels / GHCR image names; no server hit.
|
||||
showImageChangelogLinks: boolean;
|
||||
// Show selfh.st app logos as container icons (opt-in; off by default).
|
||||
useSelfhstIcons: boolean;
|
||||
// Show the "What's New" modal after an upgrade (#1235)
|
||||
showWhatsNew: boolean;
|
||||
// Whether spinning icons (animate-spin etc.) are animated (#1169)
|
||||
@@ -161,6 +163,7 @@ const DEFAULT_SETTINGS: Omit<GeneralSettings, 'scheduleRetentionDays' | 'eventRe
|
||||
labelFilterMode: 'any' as const,
|
||||
honorProxyLabels: true,
|
||||
showImageChangelogLinks: true,
|
||||
useSelfhstIcons: false,
|
||||
showWhatsNew: true,
|
||||
animateIcons: true,
|
||||
editorIndentGuides: false,
|
||||
@@ -276,6 +279,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
||||
defaultBackupImage,
|
||||
honorProxyLabels,
|
||||
showImageChangelogLinks,
|
||||
useSelfhstIcons,
|
||||
showWhatsNew,
|
||||
animateIcons,
|
||||
editorIndentGuides,
|
||||
@@ -327,6 +331,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
||||
getSetting('default_backup_image'),
|
||||
getSetting('honor_proxy_labels'),
|
||||
getSetting('show_image_changelog_links'),
|
||||
getSetting('use_selfhst_icons'),
|
||||
getSetting('show_whats_new'),
|
||||
getSetting('animate_icons'),
|
||||
getSetting('editor_indent_guides'),
|
||||
@@ -382,6 +387,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
||||
defaultBackupImage: defaultBackupImage ?? DEFAULT_BACKUP_IMAGE,
|
||||
honorProxyLabels: honorProxyLabels ?? DEFAULT_SETTINGS.honorProxyLabels,
|
||||
showImageChangelogLinks: showImageChangelogLinks ?? DEFAULT_SETTINGS.showImageChangelogLinks,
|
||||
useSelfhstIcons: useSelfhstIcons ?? DEFAULT_SETTINGS.useSelfhstIcons,
|
||||
showWhatsNew: showWhatsNew ?? DEFAULT_SETTINGS.showWhatsNew,
|
||||
animateIcons: animateIcons ?? DEFAULT_SETTINGS.animateIcons,
|
||||
editorIndentGuides: editorIndentGuides ?? DEFAULT_SETTINGS.editorIndentGuides,
|
||||
@@ -413,7 +419,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { confirmDestructive, showStoppedContainers, highlightUpdates, coloredActionButtons, actionIconSize, timeFormat, dateFormat, downloadFormat, defaultGrypeArgs, defaultTrivyArgs, scheduleRetentionDays, eventRetentionDays, scheduleCleanupCron, eventCleanupCron, scheduleCleanupEnabled, eventCleanupEnabled, scannerCleanupCron, scannerCleanupEnabled, logBufferSizeKb, logMaxLines, defaultTimezone, eventCollectionMode, eventPollInterval, metricsCollectionInterval, lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont, compactPorts, showExposedPorts, showGitCommitHash, formatLogTimestamps, externalStackPaths, primaryStackLocation, defaultGrypeImage, defaultTrivyImage, defaultComposeTemplate, labelFilterMode, defaultBackupImage, honorProxyLabels, showImageChangelogLinks, animateIcons, editorIndentGuides, protectScannerImages, showWhatsNew, defaultScannerNetworkMode, defaultScannerDns } = body;
|
||||
const { confirmDestructive, showStoppedContainers, highlightUpdates, coloredActionButtons, actionIconSize, timeFormat, dateFormat, downloadFormat, defaultGrypeArgs, defaultTrivyArgs, scheduleRetentionDays, eventRetentionDays, scheduleCleanupCron, eventCleanupCron, scheduleCleanupEnabled, eventCleanupEnabled, scannerCleanupCron, scannerCleanupEnabled, logBufferSizeKb, logMaxLines, defaultTimezone, eventCollectionMode, eventPollInterval, metricsCollectionInterval, lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont, compactPorts, showExposedPorts, showGitCommitHash, formatLogTimestamps, externalStackPaths, primaryStackLocation, defaultGrypeImage, defaultTrivyImage, defaultComposeTemplate, labelFilterMode, defaultBackupImage, honorProxyLabels, showImageChangelogLinks, useSelfhstIcons, animateIcons, editorIndentGuides, protectScannerImages, showWhatsNew, defaultScannerNetworkMode, defaultScannerDns } = body;
|
||||
|
||||
if (confirmDestructive !== undefined) {
|
||||
await setSetting('confirm_destructive', confirmDestructive);
|
||||
@@ -571,6 +577,9 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
if (showImageChangelogLinks !== undefined && typeof showImageChangelogLinks === 'boolean') {
|
||||
await setSetting('show_image_changelog_links', showImageChangelogLinks);
|
||||
}
|
||||
if (useSelfhstIcons !== undefined && typeof useSelfhstIcons === 'boolean') {
|
||||
await setSetting('use_selfhst_icons', useSelfhstIcons);
|
||||
}
|
||||
if (showWhatsNew !== undefined && typeof showWhatsNew === 'boolean') {
|
||||
await setSetting('show_whats_new', showWhatsNew);
|
||||
}
|
||||
@@ -644,6 +653,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
defaultBackupImageVal,
|
||||
honorProxyLabelsVal,
|
||||
showImageChangelogLinksVal,
|
||||
useSelfhstIconsVal,
|
||||
showWhatsNewVal,
|
||||
animateIconsVal,
|
||||
editorIndentGuidesVal,
|
||||
@@ -695,6 +705,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
getSetting('default_backup_image'),
|
||||
getSetting('honor_proxy_labels'),
|
||||
getSetting('show_image_changelog_links'),
|
||||
getSetting('use_selfhst_icons'),
|
||||
getSetting('show_whats_new'),
|
||||
getSetting('animate_icons'),
|
||||
getSetting('editor_indent_guides'),
|
||||
@@ -751,6 +762,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
honorProxyLabels: honorProxyLabelsVal ?? DEFAULT_SETTINGS.honorProxyLabels,
|
||||
protectScannerImages: protectScannerImagesVal ?? DEFAULT_SETTINGS.protectScannerImages,
|
||||
showImageChangelogLinks: showImageChangelogLinksVal ?? DEFAULT_SETTINGS.showImageChangelogLinks,
|
||||
useSelfhstIcons: useSelfhstIconsVal ?? DEFAULT_SETTINGS.useSelfhstIcons,
|
||||
showWhatsNew: showWhatsNewVal ?? DEFAULT_SETTINGS.showWhatsNew,
|
||||
animateIcons: animateIconsVal ?? DEFAULT_SETTINGS.animateIcons,
|
||||
editorIndentGuides: editorIndentGuidesVal ?? DEFAULT_SETTINGS.editorIndentGuides,
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { updateStackSource, getStackSource } from '$lib/server/db';
|
||||
import { saveStackIcon, deleteStackIcon, getStackIconBuffer, looksLikeImage } from '$lib/server/stack-icons';
|
||||
|
||||
function parseEnv(raw: string | null): number | null {
|
||||
if (!raw) return null;
|
||||
const n = parseInt(raw, 10);
|
||||
return Number.isNaN(n) ? null : n;
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* summary: Get a stack's uploaded custom icon (raw image/webp bytes, not JSON)
|
||||
* path: name:string! Stack name
|
||||
* query: env:integer Environment id the stack belongs to
|
||||
* resp-200: Binary image/webp response body, Cache-Control public max-age=3600
|
||||
* resp-403: Permission denied (needs stacks:view)
|
||||
* resp-404: No custom icon set for this stack
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
const envId = parseEnv(url.searchParams.get('env'));
|
||||
if (auth.authEnabled && !(await auth.can('stacks', 'view', envId ?? undefined))) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
const buffer = getStackIconBuffer(params.name, envId);
|
||||
if (!buffer) return json({ error: 'No custom icon' }, { status: 404 });
|
||||
return new Response(new Uint8Array(buffer), {
|
||||
headers: {
|
||||
'Content-Type': 'image/webp',
|
||||
'Cache-Control': 'public, max-age=3600',
|
||||
'X-Content-Type-Options': 'nosniff'
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* summary: Set a stack's icon - either a name reference or an uploaded image
|
||||
* 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.
|
||||
* path: name:string! Stack name
|
||||
* query: env:integer Environment id the stack belongs to
|
||||
* body: {icon:string, image:string}
|
||||
* resp-200: {success:boolean!, icon:string!}
|
||||
* resp-200-example: {"success":true,"icon":"selfhst:plex"}
|
||||
* resp-400: Neither icon nor image supplied, or image exceeds the size limit
|
||||
* resp-403: Permission denied (needs stacks:edit)
|
||||
*/
|
||||
export const POST: RequestHandler = async ({ params, url, request, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
const envId = parseEnv(url.searchParams.get('env'));
|
||||
if (auth.authEnabled && !(await auth.can('stacks', 'edit', envId ?? undefined))) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
const data = await request.json();
|
||||
|
||||
let iconValue: string;
|
||||
if (typeof data.image === 'string' && data.image) {
|
||||
if (data.image.length > 400_000) {
|
||||
return json({ error: 'Image too large' }, { status: 400 });
|
||||
}
|
||||
const raw = Buffer.from(data.image.replace(/^data:image\/\w+;base64,/, ''), 'base64');
|
||||
if (!looksLikeImage(raw)) {
|
||||
return json({ error: 'Uploaded file is not a recognised image' }, { status: 400 });
|
||||
}
|
||||
saveStackIcon(params.name, envId, data.image);
|
||||
iconValue = 'custom:stack';
|
||||
} else if (typeof data.icon === 'string' && data.icon) {
|
||||
// A referenced icon (lucide name or selfhst:<ref>) - drop any old upload.
|
||||
deleteStackIcon(params.name, envId);
|
||||
iconValue = data.icon;
|
||||
} else {
|
||||
return json({ error: 'Missing icon or image' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Ensure a stack_sources row exists, then set the icon.
|
||||
if (!(await getStackSource(params.name, envId))) {
|
||||
const { upsertStackSource } = await import('$lib/server/db');
|
||||
await upsertStackSource({ stackName: params.name, environmentId: envId, sourceType: 'external', icon: iconValue });
|
||||
} else {
|
||||
await updateStackSource(params.name, envId, { icon: iconValue });
|
||||
}
|
||||
return json({ success: true, icon: iconValue });
|
||||
};
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* summary: Remove a stack's custom icon
|
||||
* path: name:string! Stack name
|
||||
* query: env:integer Environment id the stack belongs to
|
||||
* resp-200: {success:boolean!}
|
||||
* resp-403: Permission denied (needs stacks:edit)
|
||||
*/
|
||||
export const DELETE: RequestHandler = async ({ params, url, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
const envId = parseEnv(url.searchParams.get('env'));
|
||||
if (auth.authEnabled && !(await auth.can('stacks', 'edit', envId ?? undefined))) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
deleteStackIcon(params.name, envId);
|
||||
await updateStackSource(params.name, envId, { icon: null });
|
||||
return json({ success: true });
|
||||
};
|
||||
@@ -32,6 +32,7 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
composePath?: string | null;
|
||||
repository?: any;
|
||||
secretProviderId?: number | null;
|
||||
icon?: string | null;
|
||||
}
|
||||
> = {};
|
||||
for (const source of sources) {
|
||||
@@ -40,6 +41,7 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
composePath: source.composePath,
|
||||
repository: source.repository,
|
||||
secretProviderId: source.secretProviderId,
|
||||
icon: source.icon ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
import SnapshotDiffModal from './SnapshotDiffModal.svelte';
|
||||
import CreateBackupModal from './CreateBackupModal.svelte';
|
||||
import EditBackupConfigModal from './EditBackupConfigModal.svelte';
|
||||
import ContainerIcon from '$lib/components/ContainerIcon.svelte';
|
||||
|
||||
interface BackupConfig {
|
||||
key: string;
|
||||
@@ -825,7 +826,10 @@
|
||||
{/if}
|
||||
</button>
|
||||
{:else if column.id === 'name'}
|
||||
<span class="text-xs font-medium truncate">{config.targetName}</span>
|
||||
<div class="flex items-center gap-1.5 min-w-0">
|
||||
<ContainerIcon image="" name={config.targetName} class="w-3.5 h-3.5" fallbackIcon={config.type === 'stack' ? Layers : Box} showFallbackWhenOff />
|
||||
<span class="text-xs font-medium truncate">{config.targetName}</span>
|
||||
</div>
|
||||
{:else if column.id === 'type'}
|
||||
<div class="flex justify-center">
|
||||
{#if config.type === 'container'}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
|
||||
import ContainerIcon from '$lib/components/ContainerIcon.svelte';
|
||||
import { formatPorts, formatExposedPorts } from '$lib/utils/port-format';
|
||||
import { formatBytes, formatBytesCompact } from '$lib/utils/format';
|
||||
import MultiSelectFilter from '$lib/components/MultiSelectFilter.svelte';
|
||||
@@ -90,7 +91,7 @@
|
||||
import { appSettings } from '$lib/stores/settings';
|
||||
import { canAccess } from '$lib/stores/auth';
|
||||
import { vulnerabilityCriteriaIcons } from '$lib/utils/update-steps';
|
||||
import { ipToNumber } from '$lib/utils/ip';
|
||||
import { compareIps } from '$lib/utils/ip';
|
||||
import { formatHostPortUrl } from '$lib/utils/url';
|
||||
import { parseCustomUrl } from '$lib/utils/custom-url';
|
||||
import { extractTraefikUrls } from '$lib/utils/traefik-urls';
|
||||
@@ -110,6 +111,16 @@
|
||||
|
||||
// Data from persistent store (survives page navigation)
|
||||
const containers = $derived($containerStore.data);
|
||||
// User-set per-container icon overrides for the current env (name -> icon), batch-loaded.
|
||||
let iconOverrides = $state<Record<string, string>>({});
|
||||
async function loadIconOverrides(forEnvId: number | null) {
|
||||
try {
|
||||
const res = await fetch(appendEnvParam('/api/container-icons', forEnvId));
|
||||
iconOverrides = res.ok ? await res.json() : {};
|
||||
} catch {
|
||||
iconOverrides = {};
|
||||
}
|
||||
}
|
||||
const containerStats = $derived($containerStore.stats);
|
||||
const autoUpdateSettings = $derived($containerStore.autoUpdateSettings);
|
||||
const envHasScanning = $derived($containerStore.envHasScanning);
|
||||
@@ -215,11 +226,13 @@
|
||||
}
|
||||
// Refresh data (store handles loading state internally)
|
||||
containerStore.refresh(newEnvId);
|
||||
loadIconOverrides(newEnvId);
|
||||
} else if (!env) {
|
||||
// No environment - clear data and stop loading
|
||||
envId = null;
|
||||
shellDetectionCache = {};
|
||||
containerStore.clear();
|
||||
iconOverrides = {};
|
||||
}
|
||||
});
|
||||
let showCreateModal = $state(false);
|
||||
@@ -235,6 +248,7 @@
|
||||
let showFileBrowserModal = $state(false);
|
||||
let fileBrowserContainerId = $state('');
|
||||
let fileBrowserContainerName = $state('');
|
||||
let fileBrowserContainerImage = $state('');
|
||||
|
||||
// Terminal state - track active terminals per container
|
||||
interface ActiveTerminal {
|
||||
@@ -271,6 +285,9 @@
|
||||
// Update confirmation
|
||||
let confirmUpdateAll = $state(false);
|
||||
let confirmUpdateId = $state<string | null>(null);
|
||||
// Separate open-state for the update icon in the image column so it does not
|
||||
// share a popover with the identical icon in the actions column (#1435).
|
||||
let confirmImageUpdateId = $state<string | null>(null);
|
||||
let confirmUpdateSelected = $state(false);
|
||||
|
||||
// Update check state
|
||||
@@ -833,7 +850,7 @@
|
||||
case 'ip':
|
||||
const ipA = getContainerIp(a.networks);
|
||||
const ipB = getContainerIp(b.networks);
|
||||
cmp = ipToNumber(ipA) - ipToNumber(ipB);
|
||||
cmp = compareIps(ipA, ipB);
|
||||
break;
|
||||
case 'cpu':
|
||||
const cpuA = containerStats.get(a.id)?.cpuPercent ?? -1;
|
||||
@@ -1171,6 +1188,7 @@
|
||||
function browseFiles(container: ContainerInfo) {
|
||||
fileBrowserContainerId = container.id;
|
||||
fileBrowserContainerName = container.name;
|
||||
fileBrowserContainerImage = container.image;
|
||||
showFileBrowserModal = true;
|
||||
}
|
||||
|
||||
@@ -1723,6 +1741,7 @@
|
||||
{@const stack = getComposeProject(container.labels)}
|
||||
{#if column.id === 'name'}
|
||||
<div class="flex items-center gap-1.5 min-w-0">
|
||||
<ContainerIcon image={container.image} name={container.name} override={iconOverrides[container.name]} {envId} class="w-4 h-4" />
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs font-medium truncate text-left hover:text-primary hover:underline cursor-pointer"
|
||||
@@ -1794,9 +1813,29 @@
|
||||
{:else if column.id === 'image'}
|
||||
<div class="flex items-center gap-1.5 {$appSettings.highlightUpdates && containersWithUpdatesSet.has(container.id) ? 'update-border' : ''}">
|
||||
{#if containersWithUpdatesSet.has(container.id)}
|
||||
<span title="Update available">
|
||||
<CircleArrowUp class="w-3 h-3 text-amber-500 {$appSettings.highlightUpdates ? 'glow-amber' : ''} shrink-0" />
|
||||
</span>
|
||||
{#if container.systemContainer}
|
||||
<!-- System containers cannot be updated from the UI - show the
|
||||
indicator but leave it non-clickable (matches the actions column). -->
|
||||
<span title="Update available">
|
||||
<CircleArrowUp class="w-3 h-3 text-amber-500 {$appSettings.highlightUpdates ? 'glow-amber' : ''} shrink-0" />
|
||||
</span>
|
||||
{:else}
|
||||
<ConfirmPopover
|
||||
open={confirmImageUpdateId === container.id}
|
||||
action="Update"
|
||||
itemType="container"
|
||||
itemName={container.name}
|
||||
title="Update available - click to update"
|
||||
onConfirm={() => updateSingleContainer(container.id, container.name)}
|
||||
onOpenChange={(open) => confirmImageUpdateId = open ? container.id : null}
|
||||
>
|
||||
{#snippet children({ open })}
|
||||
<span title="Update available" class="cursor-pointer">
|
||||
<CircleArrowUp class="w-3 h-3 text-amber-500 hover:text-amber-400 transition-colors {$appSettings.highlightUpdates ? 'glow-amber' : ''} shrink-0" />
|
||||
</span>
|
||||
{/snippet}
|
||||
</ConfirmPopover>
|
||||
{/if}
|
||||
{#if $appSettings.showImageChangelogLinks}
|
||||
{@const changelogUrl = resolveChangelogUrl(container.image, container.labels)}
|
||||
{#if changelogUrl}
|
||||
@@ -2462,6 +2501,7 @@
|
||||
containerId={editContainerId}
|
||||
onClose={() => (showEditModal = false)}
|
||||
onSuccess={fetchContainers}
|
||||
onIconChanged={() => loadIconOverrides(envId)}
|
||||
/>
|
||||
|
||||
<ContainerInspectModal
|
||||
@@ -2485,6 +2525,7 @@
|
||||
bind:open={showFileBrowserModal}
|
||||
containerId={fileBrowserContainerId}
|
||||
containerName={fileBrowserContainerName}
|
||||
containerImage={fileBrowserContainerImage}
|
||||
envId={envId ?? undefined}
|
||||
onclose={() => showFileBrowserModal = false}
|
||||
/>
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import { Loader2, Box, Info, Layers, Cpu, MemoryStick, HardDrive, Network, Shield, Settings2, Code, Copy, Check, XCircle, Activity, Wifi, Pencil, RefreshCw, X, Folder, FolderOpen, Moon, Tags, ExternalLink, Gpu, Globe, Link, Unlink, Play, Square as SquareIcon, RotateCw, Trash2 } from 'lucide-svelte';
|
||||
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
|
||||
import ContainerIcon from '$lib/components/ContainerIcon.svelte';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
@@ -640,7 +641,7 @@
|
||||
<Dialog.Content class="max-w-6xl w-[calc(100%-2rem)] h-[calc(100vh-2rem)] flex flex-col">
|
||||
<Dialog.Header class="shrink-0">
|
||||
<Dialog.Title class="flex items-center gap-2">
|
||||
<Box class="w-5 h-5" />
|
||||
<ContainerIcon image={containerData?.Config?.Image ?? ''} name={displayName} class="w-5 h-5" fallbackIcon={Box} showFallbackWhenOff />
|
||||
Container details:
|
||||
{#if isEditing}
|
||||
<input
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
import { currentEnvironment, appendEnvParam } from '$lib/stores/environment';
|
||||
import { page } from '$app/stores'; // BETA GATE: backups feature flag
|
||||
import { focusFirstInput } from '$lib/utils';
|
||||
import ContainerIcon from '$lib/components/ContainerIcon.svelte';
|
||||
import IconPickerModal from '../stacks/IconPickerModal.svelte';
|
||||
import { Box } from 'lucide-svelte';
|
||||
import ContainerSettingsTab from './ContainerSettingsTab.svelte';
|
||||
import BackupPanel from './BackupPanel.svelte';
|
||||
import { volumeInfoFromBind } from '$lib/utils/mounts';
|
||||
@@ -63,9 +66,11 @@
|
||||
containerId: string;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
/** Fired after the icon override changes, so the list can refresh its icons. */
|
||||
onIconChanged?: () => void;
|
||||
}
|
||||
|
||||
let { open = $bindable(), containerId, onClose, onSuccess }: Props = $props();
|
||||
let { open = $bindable(), containerId, onClose, onSuccess, onIconChanged }: Props = $props();
|
||||
|
||||
// Config sets
|
||||
let configSets = $state<ConfigSet[]>([]);
|
||||
@@ -88,6 +93,42 @@
|
||||
// Form state - Basic
|
||||
let name = $state('');
|
||||
let image = $state('');
|
||||
|
||||
// Per-container icon override (lucide / selfhst:<ref> / custom:container). Persisted
|
||||
// immediately via the name-keyed /api/container-icons endpoint, keyed by name + env.
|
||||
let iconOverride = $state<string | null>(null);
|
||||
let showIconPicker = $state(false);
|
||||
|
||||
async function loadIconOverride(containerName: string) {
|
||||
try {
|
||||
const res = await fetch(appendEnvParam('/api/container-icons', currentEnvId));
|
||||
iconOverride = res.ok ? ((await res.json())[containerName] ?? null) : null;
|
||||
} catch {
|
||||
iconOverride = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Picker emits '' (clear), 'upload:<dataUrl>' (custom upload), or a lucide/selfhst value.
|
||||
async function onIconSelect(value: string) {
|
||||
const target = appendEnvParam(`/api/container-icons/${encodeURIComponent(name)}`, currentEnvId);
|
||||
try {
|
||||
if (!value) {
|
||||
await fetch(target, { method: 'DELETE' });
|
||||
iconOverride = null;
|
||||
} else if (value.startsWith('upload:')) {
|
||||
const image = value.slice('upload:'.length);
|
||||
const res = await fetch(target, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ image }) });
|
||||
if (res.ok) iconOverride = (await res.json()).icon;
|
||||
} else {
|
||||
const res = await fetch(target, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ icon: value }) });
|
||||
if (res.ok) iconOverride = (await res.json()).icon;
|
||||
}
|
||||
// Let the list refresh so the row icon reflects the change without an env switch.
|
||||
onIconChanged?.();
|
||||
} catch (e) {
|
||||
console.error('Failed to set container icon:', e);
|
||||
}
|
||||
}
|
||||
let command = $state('');
|
||||
let restartPolicy = $state('no');
|
||||
let restartMaxRetries = $state<number | ''>('');
|
||||
@@ -347,6 +388,7 @@
|
||||
// Parse basic container data
|
||||
name = data.Name.replace(/^\//, '');
|
||||
image = data.Config.Image;
|
||||
loadIconOverride(name);
|
||||
command = data.Config.Cmd ? data.Config.Cmd.map((arg: string) =>
|
||||
arg.includes(' ') ? `"${arg}"` : arg
|
||||
).join(' ') : '';
|
||||
@@ -1114,6 +1156,15 @@
|
||||
<Dialog.Content class="max-w-4xl w-[calc(100%-2rem)] h-[85vh] p-0 flex flex-col overflow-hidden">
|
||||
<Dialog.Header class="px-5 py-4 border-b bg-muted/30 shrink-0 sticky top-0 z-10">
|
||||
<Dialog.Title class="text-base font-semibold flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showIconPicker = true)}
|
||||
title="Change icon"
|
||||
class="mr-1 rounded p-0.5 hover:bg-muted transition-colors cursor-pointer"
|
||||
aria-label="Change container icon"
|
||||
>
|
||||
<ContainerIcon {image} name={name} override={iconOverride} envId={currentEnvId} class="w-4 h-4" fallbackIcon={Box} showFallbackWhenOff />
|
||||
</button>
|
||||
Edit container
|
||||
{#if isEditingTitle}
|
||||
<span class="ml-1">-</span>
|
||||
@@ -1323,3 +1374,5 @@
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<IconPickerModal bind:open={showIconPicker} value={iconOverride} onselect={onIconSelect} title="Choose a container icon" />
|
||||
@@ -9,11 +9,12 @@
|
||||
open: boolean;
|
||||
containerId: string;
|
||||
containerName: string;
|
||||
containerImage?: string;
|
||||
envId?: number;
|
||||
onclose: () => void;
|
||||
}
|
||||
|
||||
let { open = $bindable(), containerId, containerName, envId, onclose }: Props = $props();
|
||||
let { open = $bindable(), containerId, containerName, containerImage = '', envId, onclose }: Props = $props();
|
||||
|
||||
function handleOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) {
|
||||
@@ -25,7 +26,7 @@
|
||||
<Dialog.Root bind:open onOpenChange={handleOpenChange}>
|
||||
<Dialog.Content class="max-w-4xl h-[90vh] sm:h-[80vh] flex flex-col" onOpenAutoFocus={(e) => e.preventDefault()}>
|
||||
<Dialog.Header>
|
||||
<ModalHeader icon={FolderOpen} title="Browse files" name={containerName} />
|
||||
<ModalHeader icon={FolderOpen} title="Browse files" name={containerName} iconImage={containerImage} iconName={containerName} />
|
||||
<Dialog.Description>
|
||||
Browse, upload, and download files from the container filesystem.
|
||||
</Dialog.Description>
|
||||
|
||||
@@ -20,6 +20,7 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
|
||||
import type { ContainerInfo } from '$lib/types';
|
||||
import { currentEnvironment, environments, appendEnvParam } from '$lib/stores/environment';
|
||||
import { appSettings, formatLogTimestamps } from '$lib/stores/settings';
|
||||
import ContainerIcon from '$lib/components/ContainerIcon.svelte';
|
||||
import { NoEnvironment } from '$lib/components/ui/empty-state';
|
||||
import { parseLines, renderLineHtml, sortByTimestampStable, type LogEntry } from '$lib/utils/log-entry';
|
||||
|
||||
@@ -1644,7 +1645,7 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
|
||||
onclick={() => selectContainer(container)}
|
||||
class="w-full px-3 py-2 text-left text-sm hover:bg-muted transition-colors flex items-center gap-2 {isCurrentSelection ? 'bg-muted' : ''}"
|
||||
>
|
||||
<Box class="w-3.5 h-3.5 shrink-0 {container.state === 'running' ? 'text-green-500' : 'text-muted-foreground'}" />
|
||||
<ContainerIcon image={container.image} name={container.name} class="w-3.5 h-3.5" fallbackClass={container.state === 'running' ? 'text-green-500' : 'text-muted-foreground'} showFallbackWhenOff />
|
||||
<span class="font-medium truncate">{container.name}</span>
|
||||
<span class="text-muted-foreground text-xs truncate">({container.image})</span>
|
||||
{#if isCurrentSelection}
|
||||
@@ -1780,7 +1781,7 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
|
||||
{/if}
|
||||
</button>
|
||||
<GripVertical class="w-3 h-3 shrink-0 text-muted-foreground/50 cursor-grab active:cursor-grabbing" />
|
||||
<Box class="w-3 h-3 shrink-0 {container.state === 'running' ? 'text-green-500' : 'text-muted-foreground'}" />
|
||||
<ContainerIcon image={container.image} name={container.name} class="w-3 h-3" fallbackClass={container.state === 'running' ? 'text-green-500' : 'text-muted-foreground'} showFallbackWhenOff />
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-medium truncate text-xs leading-tight">{container.name}</div>
|
||||
<div class="text-2xs text-muted-foreground truncate leading-tight">{container.image}</div>
|
||||
@@ -1823,7 +1824,7 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
|
||||
<div class="w-3.5 h-3.5 rounded border-2 border-muted-foreground/30"></div>
|
||||
{/if}
|
||||
</div>
|
||||
<Box class="w-3 h-3 shrink-0 {container.state === 'running' ? 'text-green-500' : 'text-muted-foreground'}" />
|
||||
<ContainerIcon image={container.image} name={container.name} class="w-3 h-3" fallbackClass={container.state === 'running' ? 'text-green-500' : 'text-muted-foreground'} showFallbackWhenOff />
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-medium truncate text-xs leading-tight">{container.name}</div>
|
||||
<div class="text-2xs text-muted-foreground truncate leading-tight">{container.image}</div>
|
||||
@@ -1885,7 +1886,7 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
<Box class="w-3 h-3 shrink-0 {container.state === 'running' ? 'text-green-500' : 'text-muted-foreground'}" />
|
||||
<ContainerIcon image={container.image} name={container.name} class="w-3 h-3" fallbackClass={container.state === 'running' ? 'text-green-500' : 'text-muted-foreground'} showFallbackWhenOff />
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-medium truncate text-xs leading-tight">{container.name}</div>
|
||||
<div class="text-2xs text-muted-foreground truncate leading-tight">{container.image}</div>
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
let showGitCommitHash = $derived($appSettings.showGitCommitHash);
|
||||
let honorProxyLabels = $derived($appSettings.honorProxyLabels);
|
||||
let showImageChangelogLinks = $derived($appSettings.showImageChangelogLinks);
|
||||
let useSelfhstIcons = $derived($appSettings.useSelfhstIcons);
|
||||
let showWhatsNew = $derived($appSettings.showWhatsNew);
|
||||
let timeFormat = $derived($appSettings.timeFormat);
|
||||
let dateFormat = $derived($appSettings.dateFormat);
|
||||
@@ -411,6 +412,28 @@ services:
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">Show a release-notes icon next to images with updates available</p>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center gap-3">
|
||||
<Label>Use selfh.st icons</Label>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<HelpCircle class="w-3.5 h-3.5 text-muted-foreground" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side="top" class="w-96 max-w-[90vw]">
|
||||
<p>Show app logos from <a href="https://selfh.st" target="_blank" rel="noopener" class="underline">selfh.st</a> as container icons, matched automatically from the image name. Logos are fetched once and cached locally, so your browser never contacts an external CDN. Off by default. Logos are CC BY 4.0; product names and trademarks are the property of their respective owners and are shown for identification only, without implying endorsement.</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
<TogglePill
|
||||
checked={useSelfhstIcons}
|
||||
onchange={(checked) => {
|
||||
appSettings.setUseSelfhstIcons(checked);
|
||||
toast.success(checked ? 'selfh.st icons enabled' : 'selfh.st icons disabled');
|
||||
}}
|
||||
disabled={!$canAccess('settings', 'edit')}
|
||||
/>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">Auto app logos on containers, fetched from selfh.st and cached locally</p>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center gap-3">
|
||||
<Label>Show "What's New"</Label>
|
||||
|
||||
@@ -23,6 +23,8 @@
|
||||
import { extractCaddyUrls } from '$lib/utils/caddy-urls';
|
||||
import { appSettings } from '$lib/stores/settings';
|
||||
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
|
||||
import StackIcon from '$lib/components/StackIcon.svelte';
|
||||
import ContainerIcon from '$lib/components/ContainerIcon.svelte';
|
||||
import BatchOperationModal from '$lib/components/BatchOperationModal.svelte';
|
||||
import type { ComposeStackInfo, ContainerStats, StackContainer } from '$lib/types';
|
||||
import StackModal from './StackModal.svelte';
|
||||
@@ -61,7 +63,7 @@
|
||||
// rate-limited), with the error text for the tooltip — session-only (#1255).
|
||||
let failedUpdateCheckIds = $state<Set<string>>(new Set());
|
||||
let failedUpdateCheckErrors = $state<Map<string, string>>(new Map());
|
||||
let stackSources = $state<Record<string, { sourceType: string; composePath?: string | null; repository?: any; gitStack?: any }>>({});
|
||||
let stackSources = $state<Record<string, { sourceType: string; composePath?: string | null; repository?: any; gitStack?: any; icon?: string | null }>>({});
|
||||
let stackEnvVarCounts = $state<Record<string, number>>({});
|
||||
let gitStacks = $state<any[]>([]);
|
||||
let gitRepositories = $state<any[]>([]);
|
||||
@@ -1687,7 +1689,10 @@
|
||||
{@const source = getStackSource(stack.name)}
|
||||
{#if column.id === 'name'}
|
||||
{@const systemType = getStackSystemType(stack)}
|
||||
<span class="flex items-center gap-1 min-w-0 w-full">
|
||||
<span class="flex items-center gap-1.5 min-w-0 w-full">
|
||||
{#if source.icon}
|
||||
<StackIcon icon={source.icon} stackName={stack.name} envId={$currentEnvironment?.id ?? null} class="w-4 h-4 shrink-0 text-muted-foreground" />
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="font-medium text-xs hover:text-primary hover:underline cursor-pointer text-left truncate min-w-0"
|
||||
@@ -2178,7 +2183,11 @@
|
||||
{@const isLoading = containerActionLoading === container.id}
|
||||
<div class="p-3 rounded-lg bg-background border text-xs">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<Box class="w-4 h-4 shrink-0 {container.state === 'running' ? 'text-emerald-500' : 'text-muted-foreground'}" />
|
||||
{#if $appSettings.useSelfhstIcons}
|
||||
<ContainerIcon image={container.image} name={container.service || container.name} class="w-4 h-4" />
|
||||
{:else}
|
||||
<Box class="w-4 h-4 shrink-0 {container.state === 'running' ? 'text-emerald-500' : 'text-muted-foreground'}" />
|
||||
{/if}
|
||||
<span class="font-medium truncate" title={container.name}>{container.service}</span>
|
||||
{#if container.updateAvailable && $appSettings.highlightUpdates}
|
||||
<!-- Update arrow + changelog link read as one pair — keep them tight. -->
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
<script lang="ts">
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import * as Tabs from '$lib/components/ui/tabs';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { stackIconMap, getStackIconComponent } from '$lib/utils/icons';
|
||||
import { Upload, Search, Loader2, ExternalLink, ImageOff } from 'lucide-svelte';
|
||||
import AvatarCropper from '$lib/components/AvatarCropper.svelte';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
/** Current icon value (lucide name / selfhst:<ref> / custom:...). */
|
||||
value?: string | null;
|
||||
/** Called with the chosen value string, or '' to clear. */
|
||||
onselect: (icon: string) => void;
|
||||
/** Dialog heading - defaults to a generic label; callers can scope it. */
|
||||
title?: string;
|
||||
}
|
||||
|
||||
let { open = $bindable(false), value = null, onselect, title = 'Choose an icon' }: Props = $props();
|
||||
|
||||
// --- Lucide tab ---
|
||||
const lucideNames = Object.keys(stackIconMap);
|
||||
let lucideQuery = $state('');
|
||||
const filteredLucide = $derived(
|
||||
lucideQuery.trim()
|
||||
? lucideNames.filter((n) => n.includes(lucideQuery.trim().toLowerCase()))
|
||||
: lucideNames
|
||||
);
|
||||
|
||||
function pickLucide(name: string) {
|
||||
onselect(name);
|
||||
open = false;
|
||||
}
|
||||
|
||||
// --- selfh.st tab (lazy) ---
|
||||
interface SelfhstEntry { Name: string; Reference: string; SVG: string }
|
||||
let manifest = $state<SelfhstEntry[]>([]);
|
||||
let manifestLoading = $state(false);
|
||||
let manifestError = $state('');
|
||||
let selfhstQuery = $state('');
|
||||
|
||||
async function loadManifest() {
|
||||
if (manifest.length || manifestLoading) return;
|
||||
manifestLoading = true;
|
||||
manifestError = '';
|
||||
try {
|
||||
const res = await fetch('/api/icons/selfhst-manifest');
|
||||
if (!res.ok) throw new Error(`manifest ${res.status}`);
|
||||
const all = (await res.json()) as SelfhstEntry[];
|
||||
// Only SVG-capable entries (that's what our proxy serves).
|
||||
manifest = all.filter((e) => e.SVG === 'Yes' && e.Reference);
|
||||
} catch (e) {
|
||||
manifestError = 'Could not load the selfh.st icon list. Check the server has internet access.';
|
||||
} finally {
|
||||
manifestLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Cap the rendered result count so a blank query doesn't try to lazy-load 2800 icons.
|
||||
const selfhstResults = $derived(
|
||||
(selfhstQuery.trim()
|
||||
? manifest.filter(
|
||||
(e) =>
|
||||
e.Name.toLowerCase().includes(selfhstQuery.trim().toLowerCase()) ||
|
||||
e.Reference.includes(selfhstQuery.trim().toLowerCase())
|
||||
)
|
||||
: manifest
|
||||
).slice(0, 120)
|
||||
);
|
||||
|
||||
function pickSelfhst(ref: string) {
|
||||
onselect(`selfhst:${ref}`);
|
||||
open = false;
|
||||
}
|
||||
|
||||
function onTabChange(v: string) {
|
||||
if (v === 'selfhst') loadManifest();
|
||||
}
|
||||
|
||||
// --- Upload tab (crop like env icons) ---
|
||||
let iconCropperImageUrl = $state('');
|
||||
let showIconCropper = $state(false);
|
||||
|
||||
function onFileSelect(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
iconCropperImageUrl = reader.result as string;
|
||||
input.value = '';
|
||||
showIconCropper = true;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
// The cropped 128px webp data URL goes to the parent, which owns the stack name
|
||||
// and POSTs it (same flow env icons use).
|
||||
function handleIconCropSave(dataUrl: string) {
|
||||
showIconCropper = false;
|
||||
onselect(`upload:${dataUrl}`);
|
||||
open = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content class="max-w-2xl h-[640px] flex flex-col">
|
||||
<Dialog.Header class="shrink-0">
|
||||
<Dialog.Title>{title}</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
|
||||
<Tabs.Root value="icons" onValueChange={onTabChange} class="flex-1 flex flex-col min-h-0">
|
||||
<Tabs.List class="grid grid-cols-3 shrink-0">
|
||||
<Tabs.Trigger value="icons">Icons</Tabs.Trigger>
|
||||
<Tabs.Trigger value="selfhst">App logos</Tabs.Trigger>
|
||||
<Tabs.Trigger value="upload">Upload</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
|
||||
<!-- Lucide icons -->
|
||||
<Tabs.Content value="icons" class="mt-3 flex-1 min-h-0 data-[state=active]:flex flex-col">
|
||||
<div class="relative mb-3 shrink-0">
|
||||
<Search class="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input bind:value={lucideQuery} placeholder="Search icons..." class="pl-8" />
|
||||
</div>
|
||||
<div class="grid grid-cols-12 gap-1 flex-1 overflow-y-auto pr-1 content-start">
|
||||
{#each filteredLucide as name (name)}
|
||||
{@const Icon = getStackIconComponent(name)}
|
||||
<button
|
||||
type="button"
|
||||
title={name}
|
||||
onclick={() => pickLucide(name)}
|
||||
class="flex items-center justify-center aspect-square rounded-md border hover:bg-accent hover:border-primary transition-colors {value === name ? 'border-primary bg-accent' : 'border-transparent'}"
|
||||
>
|
||||
<Icon class="w-4 h-4" />
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<!-- selfh.st app logos -->
|
||||
<Tabs.Content value="selfhst" class="mt-3 flex-1 min-h-0 data-[state=active]:flex flex-col">
|
||||
<div class="relative mb-3 shrink-0">
|
||||
<Search class="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input bind:value={selfhstQuery} placeholder="Search app logos (plex, jellyfin, grafana...)" class="pl-8" />
|
||||
</div>
|
||||
<div class="flex-1 min-h-0 overflow-y-auto pr-1">
|
||||
{#if manifestLoading}
|
||||
<div class="flex items-center justify-center gap-2 py-12 text-muted-foreground text-sm">
|
||||
<Loader2 class="w-4 h-4 animate-spin" /> Loading icon list...
|
||||
</div>
|
||||
{:else if manifestError}
|
||||
<div class="flex flex-col items-center gap-2 py-10 text-muted-foreground text-sm">
|
||||
<ImageOff class="w-6 h-6" />
|
||||
<span class="text-center max-w-sm">{manifestError}</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid grid-cols-12 gap-1 content-start">
|
||||
{#each selfhstResults as entry (entry.Reference)}
|
||||
<button
|
||||
type="button"
|
||||
title={entry.Name}
|
||||
onclick={() => pickSelfhst(entry.Reference)}
|
||||
class="flex items-center justify-center aspect-square rounded-md border p-1 hover:bg-accent hover:border-primary transition-colors {value === `selfhst:${entry.Reference}` ? 'border-primary bg-accent' : 'border-transparent'}"
|
||||
>
|
||||
<img src="/api/icons/selfhst/{entry.Reference}" alt={entry.Name} loading="lazy" class="w-full h-full object-contain" />
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{#if selfhstResults.length === 0 && selfhstQuery.trim()}
|
||||
<p class="py-8 text-center text-sm text-muted-foreground">No app logos match "{selfhstQuery}".</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
<p class="mt-3 shrink-0 text-[11px] text-muted-foreground leading-relaxed">
|
||||
<svg viewBox="0 0 64 64" class="inline-block w-3.5 h-3.5 align-text-bottom mr-1 fill-current" aria-hidden="true"><path d="M31.957,0.311c-8.682,0-16.322,3.213-22.226,9.203C3.653,15.678,0.354,23.666,0.354,32c0,8.422,3.212,16.236,9.29,22.313c6.078,6.078,13.978,9.377,22.313,9.377c8.334,0,16.409-3.299,22.66-9.463c5.904-5.817,9.029-13.544,9.029-22.227c0-8.595-3.125-16.408-9.116-22.399C48.453,3.523,40.639,0.311,31.957,0.311z M32.043,6.041c7.12,0,13.458,2.691,18.406,7.641c4.862,4.861,7.466,11.286,7.466,18.318c0,7.119-2.518,13.371-7.379,18.146c-5.123,5.035-11.721,7.727-18.493,7.727c-6.858,0-13.283-2.691-18.232-7.641C8.862,45.283,6.084,38.772,6.084,32c0-6.858,2.778-13.369,7.727-18.406C18.673,8.646,24.924,6.041,32.043,6.041z"/><path id="cc-c" d="M31.635,26.734c-1.79-3.264-4.844-4.563-8.389-4.563c-5.16,0-9.267,3.65-9.267,9.828c0,6.283,3.861,9.829,9.442,9.829c3.581,0,6.635-1.966,8.319-4.949l-3.931-2.001c-0.878,2.105-2.212,2.738-3.896,2.738c-2.914,0-4.248-2.422-4.248-5.617c0-3.193,1.124-5.616,4.248-5.616c0.842,0,2.527,0.456,3.51,2.563L31.635,26.734z"/><use href="#cc-c" transform="translate(18.281)"/></svg>App logos by
|
||||
<a href="https://selfh.st" target="_blank" rel="noopener" class="inline-flex items-center gap-0.5 underline hover:text-foreground">selfh.st<ExternalLink class="w-3 h-3" /></a>,
|
||||
licensed CC BY 4.0. Fetched once and cached locally.
|
||||
</p>
|
||||
<p class="mt-1 text-[11px] text-muted-foreground leading-relaxed">
|
||||
Product names, logos and trademarks are the property of their respective owners and are shown for identification only; their use does not imply endorsement.
|
||||
</p>
|
||||
</Tabs.Content>
|
||||
|
||||
<!-- Upload -->
|
||||
<Tabs.Content value="upload" class="mt-3 flex-1 min-h-0 data-[state=active]:flex flex-col justify-center gap-3">
|
||||
<label class="flex flex-col items-center justify-center gap-2 py-12 border-2 border-dashed rounded-lg cursor-pointer hover:border-primary transition-colors">
|
||||
<Upload class="w-6 h-6 text-muted-foreground" />
|
||||
<span class="text-sm text-muted-foreground">Click to upload an image, then crop it</span>
|
||||
<span class="text-xs text-muted-foreground">PNG, SVG or WebP</span>
|
||||
<input type="file" accept="image/*" class="hidden" onchange={onFileSelect} />
|
||||
</label>
|
||||
</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
|
||||
<Dialog.Footer>
|
||||
{#if value}
|
||||
<Button variant="ghost" onclick={() => { onselect(''); open = false; }}>Clear icon</Button>
|
||||
{/if}
|
||||
</Dialog.Footer>
|
||||
|
||||
<AvatarCropper
|
||||
show={showIconCropper}
|
||||
imageUrl={iconCropperImageUrl}
|
||||
cropShape="round"
|
||||
outputSize={128}
|
||||
outputFormat="image/webp"
|
||||
outputQuality={0.85}
|
||||
title="Crop icon"
|
||||
saveLabel="Save icon"
|
||||
onCancel={() => (showIconCropper = false)}
|
||||
onSave={handleIconCropSave}
|
||||
/>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -18,6 +18,8 @@
|
||||
import { fetchBackupExecutions } from '$lib/utils/backup';
|
||||
import type { Component } from 'svelte';
|
||||
import FilesystemBrowser from './FilesystemBrowser.svelte';
|
||||
import IconPickerModal from './IconPickerModal.svelte';
|
||||
import StackIcon from '$lib/components/StackIcon.svelte';
|
||||
import PathBarItem from './PathBarItem.svelte';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
@@ -56,6 +58,61 @@
|
||||
// Local effective state - can transition from create → edit after failed deploy
|
||||
let mode = $state(propMode);
|
||||
let stackName = $state(propStackName);
|
||||
let formIcon = $state<string | null>(null);
|
||||
let showIconPicker = $state(false);
|
||||
// Create mode has no stack to POST to yet - stash the pending upload data URL and
|
||||
// send it once the stack is created (see persistPendingIcon after handleCreate).
|
||||
let pendingUploadImage = $state<string | null>(null);
|
||||
|
||||
// The picker value is one of: '' (clear), 'upload:<dataUrl>' (custom upload), or a
|
||||
// lucide name / 'selfhst:<ref>'. In edit mode it persists immediately via the /icon
|
||||
// endpoint; in create mode it is held locally until the stack exists.
|
||||
async function onIconSelect(value: string) {
|
||||
if (mode !== 'edit' || !stackName) {
|
||||
// Create mode: hold locally, persist after the stack is created.
|
||||
if (!value) {
|
||||
formIcon = null;
|
||||
pendingUploadImage = null;
|
||||
} else if (value.startsWith('upload:')) {
|
||||
pendingUploadImage = value.slice('upload:'.length);
|
||||
formIcon = 'custom:stack';
|
||||
} else {
|
||||
pendingUploadImage = null;
|
||||
formIcon = value;
|
||||
}
|
||||
return;
|
||||
}
|
||||
const envId = $currentEnvironment?.id ?? null;
|
||||
const target = appendEnvParam(`/api/stacks/${encodeURIComponent(stackName)}/icon`, envId);
|
||||
try {
|
||||
if (!value) {
|
||||
await fetch(target, { method: 'DELETE' });
|
||||
formIcon = null;
|
||||
} else if (value.startsWith('upload:')) {
|
||||
const image = value.slice('upload:'.length);
|
||||
const res = await fetch(target, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ image }) });
|
||||
if (res.ok) formIcon = (await res.json()).icon;
|
||||
} else {
|
||||
const res = await fetch(target, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ icon: value }) });
|
||||
if (res.ok) formIcon = (await res.json()).icon;
|
||||
}
|
||||
onSuccess?.();
|
||||
} catch (e) {
|
||||
console.error('Failed to set stack icon:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// After a stack is created, persist the icon picked in create mode to the new stack.
|
||||
async function persistPendingIcon(name: string, envId: number | null) {
|
||||
if (!formIcon) return;
|
||||
const target = appendEnvParam(`/api/stacks/${encodeURIComponent(name)}/icon`, envId);
|
||||
const body = pendingUploadImage ? { image: pendingUploadImage } : { icon: formIcon };
|
||||
try {
|
||||
await fetch(target, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
} catch (e) {
|
||||
console.error('Failed to set stack icon:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Form state
|
||||
let newStackName = $state('');
|
||||
@@ -1107,6 +1164,7 @@
|
||||
const sourceMap = await sourcesRes.json();
|
||||
const source = sourceMap?.[stackName];
|
||||
formSecretProviderId = source?.secretProviderId ?? null;
|
||||
formIcon = source?.icon ?? null;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to load stack source for secret provider binding:', e);
|
||||
@@ -1290,6 +1348,8 @@
|
||||
throw new Error(data.error || 'Failed to create stack');
|
||||
}
|
||||
|
||||
await persistPendingIcon(newStackName.trim(), envId);
|
||||
|
||||
toast.success(`Created stack "${newStackName.trim()}"`);
|
||||
onSuccess();
|
||||
handleClose();
|
||||
@@ -1528,6 +1588,8 @@
|
||||
loadError = null;
|
||||
rawEnvContent = '';
|
||||
errors = {};
|
||||
formIcon = null;
|
||||
pendingUploadImage = null;
|
||||
composeContent = '';
|
||||
envVars = [];
|
||||
envValidation = null;
|
||||
@@ -1714,9 +1776,26 @@
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="p-1.5 rounded-md bg-zinc-200 dark:bg-zinc-700">
|
||||
<Layers class="w-4 h-4 text-zinc-600 dark:text-zinc-300" />
|
||||
</div>
|
||||
{#if !readonly}
|
||||
<button
|
||||
type="button"
|
||||
title="Change stack icon"
|
||||
onclick={() => (showIconPicker = true)}
|
||||
class="p-1.5 rounded-md bg-zinc-200 dark:bg-zinc-700 hover:ring-2 hover:ring-primary transition-shadow"
|
||||
>
|
||||
{#if pendingUploadImage}
|
||||
<img src={pendingUploadImage} alt="" class="w-4 h-4 rounded object-cover" />
|
||||
{:else if formIcon}
|
||||
<StackIcon icon={formIcon} {stackName} envId={$currentEnvironment?.id ?? null} class="w-4 h-4 text-zinc-600 dark:text-zinc-300" />
|
||||
{:else}
|
||||
<Layers class="w-4 h-4 text-zinc-600 dark:text-zinc-300" />
|
||||
{/if}
|
||||
</button>
|
||||
{:else}
|
||||
<div class="p-1.5 rounded-md bg-zinc-200 dark:bg-zinc-700">
|
||||
<Layers class="w-4 h-4 text-zinc-600 dark:text-zinc-300" />
|
||||
</div>
|
||||
{/if}
|
||||
<div>
|
||||
<Dialog.Title class="text-sm font-semibold text-zinc-800 dark:text-zinc-100">
|
||||
{#if mode === 'create'}
|
||||
@@ -2190,6 +2269,8 @@
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<IconPickerModal bind:open={showIconPicker} value={formIcon} onselect={onIconSelect} title="Choose a stack icon" />
|
||||
|
||||
<!-- Unsaved changes confirmation dialog -->
|
||||
<Dialog.Root bind:open={showConfirmClose}>
|
||||
<Dialog.Content class="max-w-sm">
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import { Search, ChevronDown, Terminal as TerminalIcon, Unplug, RefreshCw, Trash2, Copy, Shell, User, Loader2, AlertCircle } from 'lucide-svelte';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
import ContainerIcon from '$lib/components/ContainerIcon.svelte';
|
||||
import type { ContainerInfo } from '$lib/types';
|
||||
import { currentEnvironment, environments, appendEnvParam } from '$lib/stores/environment';
|
||||
import Terminal from './Terminal.svelte';
|
||||
@@ -294,6 +295,7 @@
|
||||
onclick={() => selectContainer(container)}
|
||||
class="w-full px-3 py-2 text-left text-sm hover:bg-muted transition-colors flex items-center gap-2 {selectedContainer?.id === container.id ? 'bg-muted' : ''}"
|
||||
>
|
||||
<ContainerIcon image={container.image} name={container.name} class="w-3.5 h-3.5" fallbackClass="text-green-500" showFallbackWhenOff />
|
||||
<span class="font-medium truncate">{container.name}</span>
|
||||
<span class="text-muted-foreground text-xs truncate">({container.image})</span>
|
||||
{#if selectedContainer?.id === container.id}
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
import CloneVolumeModal from './CloneVolumeModal.svelte';
|
||||
import ContainerInspectModal from '../containers/ContainerInspectModal.svelte';
|
||||
import { appSettings } from '$lib/stores/settings';
|
||||
import ContainerIcon from '$lib/components/ContainerIcon.svelte';
|
||||
import type { VolumeInfo } from '$lib/types';
|
||||
import { currentEnvironment, environments, appendEnvParam, clearStaleEnvironment } from '$lib/stores/environment';
|
||||
import MultiSelectFilter from '$lib/components/MultiSelectFilter.svelte';
|
||||
@@ -575,10 +576,11 @@
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => openContainerInspect(container.containerId, container.containerName)}
|
||||
class="text-xs text-primary hover:underline cursor-pointer truncate max-w-[100px]"
|
||||
class="text-xs text-primary hover:underline cursor-pointer truncate max-w-[110px] inline-flex items-center gap-1"
|
||||
title={container.containerName}
|
||||
>
|
||||
{container.containerName}
|
||||
<ContainerIcon image="" name={container.containerName} class="w-3 h-3" hideWhenNoMatch />
|
||||
<span class="truncate">{container.containerName}</span>
|
||||
</button>
|
||||
{/each}
|
||||
{#if volume.usedBy.length > 3}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
// @ts-expect-error -- bun:test is a runtime built-in with no types installed
|
||||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||||
import { mkdtempSync, writeFileSync, readFileSync, existsSync, utimesSync, statSync, rmSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import {
|
||||
isValidSelfhstRef,
|
||||
selfhstCachePath,
|
||||
sanitizeSvg,
|
||||
getSelfhstIcon
|
||||
} from '../src/lib/server/selfhst-icons';
|
||||
import { looksLikeImage } from '../src/lib/server/stack-icons';
|
||||
|
||||
describe('isValidSelfhstRef', () => {
|
||||
test('accepts normal references', () => {
|
||||
for (const r of ['plex', 'jellyfin', 'home-assistant', 'pihole', 'nextcloud', '2fauth']) {
|
||||
expect(isValidSelfhstRef(r)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects path traversal and junk', () => {
|
||||
for (const r of ['../etc/passwd', 'a/b', 'foo.svg', 'Foo', 'a_b', 'a b', '', '..', './x', 'x/../y']) {
|
||||
expect(isValidSelfhstRef(r)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects an over-long ref', () => {
|
||||
expect(isValidSelfhstRef('a'.repeat(65))).toBe(false);
|
||||
});
|
||||
|
||||
test('selfhstCachePath throws on an invalid ref (never builds a path from junk)', () => {
|
||||
expect(() => selfhstCachePath('../../etc/passwd')).toThrow();
|
||||
expect(() => selfhstCachePath('ok-ref')).not.toThrow();
|
||||
});
|
||||
|
||||
test('selfhstCachePath keeps the ref inside the cache dir', () => {
|
||||
const p = selfhstCachePath('plex');
|
||||
expect(p.endsWith('/icon-cache/selfhst/plex.svg')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeSvg (SVG-XSS defense)', () => {
|
||||
test('strips <script> elements', () => {
|
||||
const out = sanitizeSvg('<svg><script>alert(document.cookie)</script><rect/></svg>');
|
||||
expect(out).not.toContain('<script');
|
||||
expect(out).toContain('<rect');
|
||||
});
|
||||
|
||||
test('strips on* event-handler attributes', () => {
|
||||
const out = sanitizeSvg('<svg onload="evil()"><rect onclick=\'x\'/></svg>');
|
||||
expect(out).not.toMatch(/onload/i);
|
||||
expect(out).not.toMatch(/onclick/i);
|
||||
});
|
||||
|
||||
test('strips <foreignObject> (can embed HTML)', () => {
|
||||
const out = sanitizeSvg('<svg><foreignObject><body onload="x"/></foreignObject></svg>');
|
||||
expect(out).not.toMatch(/foreignObject/i);
|
||||
});
|
||||
|
||||
test('strips javascript: URLs', () => {
|
||||
const out = sanitizeSvg('<svg><a href="javascript:alert(1)">x</a></svg>');
|
||||
expect(out.toLowerCase()).not.toContain('javascript:');
|
||||
});
|
||||
|
||||
test('leaves a clean logo SVG intact', () => {
|
||||
const clean = '<svg viewBox="0 0 24 24"><path d="M1 1h2v2z" fill="#fff"/></svg>';
|
||||
expect(sanitizeSvg(clean)).toBe(clean);
|
||||
});
|
||||
});
|
||||
|
||||
describe('looksLikeImage (upload magic-byte check)', () => {
|
||||
test('accepts real image magic bytes', () => {
|
||||
expect(looksLikeImage(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0, 0]))).toBe(true); // PNG
|
||||
expect(looksLikeImage(Buffer.from([0xff, 0xd8, 0xff, 0xe0]))).toBe(true); // JPEG
|
||||
expect(looksLikeImage(Buffer.from('GIF89a'))).toBe(true);
|
||||
expect(looksLikeImage(Buffer.from('RIFF\0\0\0\0WEBP'))).toBe(true);
|
||||
expect(looksLikeImage(Buffer.from('<svg></svg>'))).toBe(true);
|
||||
});
|
||||
|
||||
test('rejects arbitrary / non-image bytes', () => {
|
||||
expect(looksLikeImage(Buffer.from('#!/bin/sh\nrm -rf /'))).toBe(false);
|
||||
expect(looksLikeImage(Buffer.from([0x00, 0x01, 0x02, 0x03]))).toBe(false);
|
||||
expect(looksLikeImage(Buffer.from('MZ\x90\x00'))).toBe(false); // PE/exe
|
||||
expect(looksLikeImage(Buffer.alloc(0))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// getSelfhstIcon's cache-HIT paths are deterministic (no network). We seed the on-disk
|
||||
// cache under a temp DATA_DIR and assert what the cache read returns without ever
|
||||
// reaching the CDN. Stale/corrupt entries fall through to a fetch, which fails offline
|
||||
// and returns null - that null still proves the bad file was NOT served.
|
||||
describe('getSelfhstIcon cache-hit handling', () => {
|
||||
let dir: string;
|
||||
beforeAll(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'selfhst-cache-'));
|
||||
process.env.DATA_DIR = dir;
|
||||
});
|
||||
afterAll(() => {
|
||||
delete process.env.DATA_DIR;
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('serves a valid cached SVG without refetching', async () => {
|
||||
const p = selfhstCachePath('plex');
|
||||
writeFileSync(p, '<svg>ok</svg>');
|
||||
const buf = await getSelfhstIcon('plex');
|
||||
expect(buf?.toString()).toBe('<svg>ok</svg>');
|
||||
});
|
||||
|
||||
test('a fresh zero-byte tombstone returns null (no refetch)', async () => {
|
||||
const p = selfhstCachePath('jellyfin');
|
||||
writeFileSync(p, Buffer.alloc(0)); // mtime = now -> within TTL
|
||||
expect(await getSelfhstIcon('jellyfin')).toBeNull();
|
||||
expect(existsSync(p)).toBe(true); // tombstone kept
|
||||
});
|
||||
|
||||
// A ref that can NEVER exist upstream (valid syntactically, but no such icon) so the
|
||||
// fall-through fetch deterministically returns a 404/miss regardless of network.
|
||||
const MISSING = 'zzz-nonexistent-test-icon';
|
||||
|
||||
test('a corrupt (nonzero non-SVG) cache file is never served (dropped, refetched)', async () => {
|
||||
const p = selfhstCachePath(MISSING);
|
||||
writeFileSync(p, Buffer.from([0x00, 0x01, 0x02])); // binary, no leading '<'
|
||||
const buf = await getSelfhstIcon(MISSING);
|
||||
// Whatever happens, the corrupt bytes are NOT returned. Offline/404 -> null; the file
|
||||
// is either gone or replaced by a 0-byte tombstone, never the original garbage.
|
||||
expect(buf).toBeNull();
|
||||
if (existsSync(p)) {
|
||||
expect(readFileSync(p).length).toBe(0); // tombstone, not the corrupt content
|
||||
}
|
||||
});
|
||||
|
||||
test('a stale tombstone (older than the TTL) is not treated as a live miss', async () => {
|
||||
const p = selfhstCachePath(MISSING);
|
||||
writeFileSync(p, Buffer.alloc(0));
|
||||
const old = Date.now() / 1000 - 3 * 24 * 60 * 60; // 3 days ago (TTL is 1 day)
|
||||
utimesSync(p, old, old);
|
||||
const before = statSync(p).mtimeMs;
|
||||
await getSelfhstIcon(MISSING); // stale -> refetch (misses) -> fresh tombstone
|
||||
// The stale tombstone was acted on (dropped then rewritten), i.e. its mtime advanced -
|
||||
// proving it was NOT returned as a still-valid negative-cache hit.
|
||||
if (existsSync(p)) expect(statSync(p).mtimeMs).toBeGreaterThan(before);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
// @ts-expect-error -- bun:test is a runtime built-in with no types installed
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import {
|
||||
imageBasename,
|
||||
matchSelfhstRef,
|
||||
createSelfhstMatcher,
|
||||
containerNameBase,
|
||||
matchSelfhstByName
|
||||
} from '../src/lib/utils/selfhst-match';
|
||||
|
||||
describe('imageBasename', () => {
|
||||
test('strips registry, namespace, tag and digest', () => {
|
||||
expect(imageBasename('lscr.io/linuxserver/sonarr:latest')).toBe('sonarr');
|
||||
expect(imageBasename('plexinc/pms-docker')).toBe('pms-docker');
|
||||
expect(imageBasename('grafana/grafana:10.2.0')).toBe('grafana');
|
||||
expect(imageBasename('redis@sha256:abcd')).toBe('redis');
|
||||
expect(imageBasename('registry.example.com:5000/team/app:v1')).toBe('app');
|
||||
expect(imageBasename('nginx')).toBe('nginx');
|
||||
expect(imageBasename('')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('matchSelfhstRef', () => {
|
||||
const refs = new Set(['plex', 'sonarr', 'grafana', 'home-assistant', 'pi-hole', 'redis', 'postgresql']);
|
||||
|
||||
test('alias table resolves known mismatches', () => {
|
||||
expect(matchSelfhstRef('plexinc/pms-docker:latest', refs)).toBe('plex');
|
||||
expect(matchSelfhstRef('homeassistant/home-assistant', refs)).toBe('home-assistant');
|
||||
expect(matchSelfhstRef('pihole/pihole', refs)).toBe('pi-hole');
|
||||
expect(matchSelfhstRef('postgres:16', refs)).toBe('postgresql');
|
||||
});
|
||||
|
||||
test('exact basename matches a reference', () => {
|
||||
expect(matchSelfhstRef('lscr.io/linuxserver/sonarr', refs)).toBe('sonarr');
|
||||
expect(matchSelfhstRef('grafana/grafana', refs)).toBe('grafana');
|
||||
});
|
||||
|
||||
test('NO fuzzy false positives (the whole point)', () => {
|
||||
// 'redis-exporter' must NOT become 'redis'
|
||||
expect(matchSelfhstRef('oliver006/redis-exporter', refs)).toBeNull();
|
||||
// unknown app -> null, never a wrong logo
|
||||
expect(matchSelfhstRef('mycompany/secret-internal-app', refs)).toBeNull();
|
||||
expect(matchSelfhstRef('', refs)).toBeNull();
|
||||
});
|
||||
|
||||
test('alias only wins when the aliased ref is actually in the manifest', () => {
|
||||
const tiny = new Set(['sonarr']);
|
||||
// pms-docker aliases to plex, but plex is not in this manifest -> null
|
||||
expect(matchSelfhstRef('plexinc/pms-docker', tiny)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('containerNameBase', () => {
|
||||
test('strips leading slash and compose replica suffix, lowercases', () => {
|
||||
expect(containerNameBase('/traefik')).toBe('traefik');
|
||||
expect(containerNameBase('/traefik-1')).toBe('traefik');
|
||||
expect(containerNameBase('immich_server_1')).toBe('immich_server');
|
||||
expect(containerNameBase('Sonarr')).toBe('sonarr');
|
||||
expect(containerNameBase('/web-2')).toBe('web');
|
||||
expect(containerNameBase('')).toBe('');
|
||||
});
|
||||
|
||||
test('does not strip a non-replica trailing number word', () => {
|
||||
// only a -N / _N SUFFIX is a replica index; an embedded digit stays
|
||||
expect(containerNameBase('mc-atm9')).toBe('mc-atm9');
|
||||
});
|
||||
});
|
||||
|
||||
describe('matchSelfhstByName (fallback)', () => {
|
||||
const refs = new Set(['syncthing', 'traefik', 'grafana', 'pi-hole']);
|
||||
|
||||
test('exact container name matches a reference', () => {
|
||||
expect(matchSelfhstByName('/syncthing', refs)).toBe('syncthing');
|
||||
expect(matchSelfhstByName('traefik-1', refs)).toBe('traefik');
|
||||
});
|
||||
|
||||
test('alias table applies to names too', () => {
|
||||
expect(matchSelfhstByName('pihole', refs)).toBe('pi-hole');
|
||||
});
|
||||
|
||||
test('generic names never false-match', () => {
|
||||
expect(matchSelfhstByName('web', refs)).toBeNull();
|
||||
expect(matchSelfhstByName('app-1', refs)).toBeNull();
|
||||
expect(matchSelfhstByName('', refs)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createSelfhstMatcher (memoized, image-first with name fallback)', () => {
|
||||
test('returns the same result and caches per (image, name) pair', () => {
|
||||
const m = createSelfhstMatcher(new Set(['grafana']));
|
||||
expect(m('grafana/grafana:1')).toBe('grafana');
|
||||
expect(m('grafana/grafana:1')).toBe('grafana'); // cached
|
||||
expect(m('unknown/thing')).toBeNull();
|
||||
});
|
||||
|
||||
test('falls back to the container name when the image does not resolve', () => {
|
||||
const m = createSelfhstMatcher(new Set(['syncthing', 'traefik']));
|
||||
// image pinned by digest -> no readable basename match; name saves it
|
||||
expect(m('sha256:3de5a32e11c1cc', '/syncthing')).toBe('syncthing');
|
||||
expect(m('some/private-mirror@sha256:abcd', 'traefik-1')).toBe('traefik');
|
||||
});
|
||||
|
||||
test('image wins over name when both could match', () => {
|
||||
const m = createSelfhstMatcher(new Set(['grafana', 'traefik']));
|
||||
// image resolves to grafana even though the name says traefik
|
||||
expect(m('grafana/grafana', 'traefik')).toBe('grafana');
|
||||
});
|
||||
|
||||
test('a matched image is not overridden by an unmatchable name, and vice versa', () => {
|
||||
const m = createSelfhstMatcher(new Set(['traefik']));
|
||||
expect(m('unknown/thing', 'traefik')).toBe('traefik'); // name saves it
|
||||
expect(m('unknown/thing', 'web')).toBeNull(); // neither matches
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user