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,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}
|
||||
|
||||
Reference in new issue
Block a user