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