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

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

No files matched your search

+46 -5
View File
@@ -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>