Files
dockhand/src/routes/containers/EditContainerModal.svelte
T
2026-06-15 14:56:51 +02:00

1196 lines
39 KiB
Svelte

<script lang="ts">
import * as Dialog from '$lib/components/ui/dialog';
import { Button } from '$lib/components/ui/button';
import { Pencil, Check, Loader2, X, Layers } from 'lucide-svelte';
import { currentEnvironment, appendEnvParam } from '$lib/stores/environment';
import { focusFirstInput } from '$lib/utils';
import ContainerSettingsTab from './ContainerSettingsTab.svelte';
import type { VulnerabilityCriteria } from '$lib/components/VulnerabilityCriteriaSelector.svelte';
import { parseHostPort, expandPortBindings, formatHostPort } from '$lib/utils/port-parse';
import { formatBytes } from '$lib/utils/format';
// Parse shell command respecting quotes
function parseShellCommand(cmd: string): string[] {
const args: string[] = [];
let current = '';
let inQuotes = false;
let quoteChar = '';
for (let i = 0; i < cmd.length; i++) {
const char = cmd[i];
if ((char === '"' || char === "'") && !inQuotes) {
inQuotes = true;
quoteChar = char;
} else if (char === quoteChar && inQuotes) {
inQuotes = false;
quoteChar = '';
} else if (char === ' ' && !inQuotes) {
if (current) {
args.push(current);
current = '';
}
} else {
current += char;
}
}
if (current) {
args.push(current);
}
return args;
}
interface ConfigSet {
id: number;
name: string;
description?: string;
envVars?: { key: string; value: string }[];
labels?: { key: string; value: string }[];
ports?: { hostPort: string; containerPort: string; protocol: string }[];
volumes?: { hostPath: string; containerPath: string; mode: string }[];
networkMode: string;
restartPolicy: string;
}
interface Props {
open: boolean;
containerId: string;
onClose: () => void;
onSuccess: () => void;
}
let { open = $bindable(), containerId, onClose, onSuccess }: Props = $props();
// Config sets
let configSets = $state<ConfigSet[]>([]);
let selectedConfigSetId = $state<string>('');
// Guard to prevent reloading data while user is editing
let hasLoadedData = $state(false);
async function fetchConfigSets() {
try {
const response = await fetch('/api/config-sets');
if (response.ok) {
configSets = await response.json();
}
} catch (err) {
console.error('Failed to fetch config sets:', err);
}
}
// Form state - Basic
let name = $state('');
let image = $state('');
let command = $state('');
let restartPolicy = $state('no');
let restartMaxRetries = $state<number | ''>('');
let networkMode = $state('bridge');
let startAfterUpdate = $state(true);
let repullImage = $state(true);
// Port mappings
let portMappings = $state<{ hostPort: string; containerPort: string; protocol: string }[]>([
{ hostPort: '', containerPort: '', protocol: 'tcp' }
]);
// Volume mappings
let volumeMappings = $state<{ hostPath: string; containerPath: string; mode: string }[]>([
{ hostPath: '', containerPath: '', mode: 'rw' }
]);
// Environment variables
let envVars = $state<{ key: string; value: string }[]>([{ key: '', value: '' }]);
// Labels
let labels = $state<{ key: string; value: string }[]>([{ key: '', value: '' }]);
// Networks
let selectedNetworks = $state<string[]>([]);
let networkConfigs = $state<Record<string, { ipv4Address: string; ipv6Address: string; aliases: string }>>({});
let macAddress = $state('');
// User/Group
let containerUser = $state('');
// Privileged mode
let privilegedMode = $state(false);
// Healthcheck settings
let healthcheckEnabled = $state(false);
let healthcheckCommand = $state('');
let healthcheckInterval = $state(30);
let healthcheckTimeout = $state(30);
let healthcheckRetries = $state(3);
let healthcheckStartPeriod = $state(0);
// Resource limits
let memoryLimit = $state('');
let memoryReservation = $state('');
let cpuShares = $state('');
let nanoCpus = $state('');
let cpuQuota = $state('');
let cpuPeriod = $state('');
// Capabilities
let capAdd = $state<string[]>([]);
let capDrop = $state<string[]>([]);
// Devices
let deviceMappings = $state<{ hostPath: string; containerPath: string; permissions: string }[]>([]);
// DNS settings
let dnsServers = $state<string[]>([]);
let dnsSearch = $state<string[]>([]);
let dnsOptions = $state<string[]>([]);
// Security options
let securityOptions = $state<string[]>([]);
// Ulimits
let ulimits = $state<{ name: string; soft: string; hard: string }[]>([]);
// GPU settings
let gpuEnabled = $state(false);
let gpuMode = $state<'all' | 'count' | 'specific'>('all');
let gpuCount = $state(1);
let gpuDeviceIds = $state<string[]>([]);
let gpuDriver = $state('');
let gpuCapabilities = $state<string[]>(['gpu']);
let runtime = $state('');
// Auto-update settings
let autoUpdateEnabled = $state(false);
let autoUpdateCronExpression = $state('0 3 * * *');
let vulnerabilityCriteria = $state<VulnerabilityCriteria>('never');
let currentEnvId = $state<number | null>(null);
currentEnvironment.subscribe(env => currentEnvId = env?.id || null);
// Track original values to detect changes
let originalConfig = $state<{
name: string;
image: string;
command: string;
restartPolicy: string;
networkMode: string;
portMappings: typeof portMappings;
volumeMappings: typeof volumeMappings;
envVars: typeof envVars;
labels: typeof labels;
selectedNetworks: string[];
networkConfigs: Record<string, { ipv4Address: string; ipv6Address: string; aliases: string }>;
macAddress: string;
// Advanced options
containerUser: string;
privilegedMode: boolean;
healthcheckEnabled: boolean;
healthcheckCommand: string;
healthcheckInterval: number;
healthcheckTimeout: number;
healthcheckRetries: number;
healthcheckStartPeriod: number;
memoryLimit: string;
memoryReservation: string;
cpuShares: string;
nanoCpus: string;
cpuQuota: string;
cpuPeriod: string;
capAdd: string[];
capDrop: string[];
securityOptions: string[];
deviceMappings: typeof deviceMappings;
dnsServers: string[];
dnsSearch: string[];
dnsOptions: string[];
ulimits: typeof ulimits;
gpuEnabled: boolean;
gpuMode: 'all' | 'count' | 'specific';
gpuCount: number;
gpuDeviceIds: string[];
gpuDriver: string;
gpuCapabilities: string[];
runtime: string;
} | null>(null);
// Compose container detection
let isComposeContainer = $state(false);
let composeStackName = $state('');
let originalAutoUpdate = $state<{
enabled: boolean;
cronExpression: string;
vulnerabilityCriteria: string;
} | null>(null);
let loading = $state(false);
let loadingData = $state(true);
let error = $state('');
let abortController: AbortController | null = null;
let statusMessage = $state('');
let visible = $state(false);
// Field-specific errors for inline validation
let errors = $state<{ name?: string; image?: string }>({});
// Inline rename state (for title bar)
let isEditingTitle = $state(false);
let editTitleName = $state('');
let renamingTitle = $state(false);
// Inline title rename functions
let titleInputRef: HTMLInputElement | null = null;
function startEditingTitle() {
editTitleName = name;
isEditingTitle = true;
setTimeout(() => {
titleInputRef?.focus();
titleInputRef?.select();
}, 0);
}
function cancelEditingTitle() {
isEditingTitle = false;
editTitleName = '';
}
function saveEditingTitle() {
if (!editTitleName.trim() || editTitleName === name) {
cancelEditingTitle();
return;
}
name = editTitleName.trim();
isEditingTitle = false;
}
async function checkScannerSettings() {
if (!currentEnvId) {
return;
}
try {
const response = await fetch(`/api/settings/scanner?env=${currentEnvId}&settingsOnly=true`);
if (response.ok) {
const data = await response.json();
const settings = data.settings || data;
}
} catch (err) {
console.error('Failed to check scanner settings:', err);
}
}
async function fetchAutoUpdateSettings(containerName: string) {
try {
const envParam = currentEnvId ? `?env=${currentEnvId}` : '';
const [autoUpdateResponse] = await Promise.all([
fetch(`/api/auto-update/${encodeURIComponent(containerName)}${envParam}`),
checkScannerSettings()
]);
if (autoUpdateResponse.ok) {
const data = await autoUpdateResponse.json();
autoUpdateEnabled = data.enabled || false;
autoUpdateCronExpression = data.cronExpression || '0 3 * * *';
vulnerabilityCriteria = data.vulnerabilityCriteria || 'never';
originalAutoUpdate = {
enabled: autoUpdateEnabled,
cronExpression: autoUpdateCronExpression,
vulnerabilityCriteria: vulnerabilityCriteria
};
}
} catch (err) {
console.error('Failed to fetch auto-update settings:', err);
}
}
async function saveAutoUpdateSettings(containerName: string) {
try {
const envParam = currentEnvId ? `?env=${currentEnvId}` : '';
await fetch(`/api/auto-update/${encodeURIComponent(containerName)}${envParam}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
enabled: autoUpdateEnabled,
cronExpression: autoUpdateCronExpression,
vulnerabilityCriteria: vulnerabilityCriteria
})
});
} catch (err) {
console.error('Failed to save auto-update settings:', err);
}
}
async function loadContainerData() {
loadingData = true;
try {
const response = await fetch(appendEnvParam(`/api/containers/${containerId}`, $currentEnvironment?.id));
const data = await response.json();
if (!response.ok || data.error) {
throw new Error(data.error || `Failed to fetch container: ${response.status}`);
}
// Parse basic container data
name = data.Name.replace(/^\//, '');
image = data.Config.Image;
command = data.Config.Cmd ? data.Config.Cmd.map((arg: string) =>
arg.includes(' ') ? `"${arg}"` : arg
).join(' ') : '';
restartPolicy = data.HostConfig.RestartPolicy?.Name || 'no';
restartMaxRetries = data.HostConfig.RestartPolicy?.MaximumRetryCount ?? '';
// Normalize network mode. NetworkMode can be:
// - bridge / host / none / default → built-in modes
// - container:<id|name> → shared namespace
// - <custom-network-name> → primary network is that custom network
// We preserve the raw value (except map "default" → "bridge" since they're equivalent).
const rawNetworkMode = data.HostConfig.NetworkMode || 'bridge';
networkMode = rawNetworkMode === 'default' ? 'bridge' : rawNetworkMode;
// Parse port mappings (include HostIp if present)
const ports = data.HostConfig.PortBindings || {};
portMappings = Object.keys(ports).length > 0
? Object.entries(ports).map(([containerPort, bindings]: [string, any]) => {
const [port, protocol] = containerPort.split('/');
const hostIp = bindings[0]?.HostIp || '';
const hostPort = bindings[0]?.HostPort || '';
return {
containerPort: port,
hostPort: formatHostPort(hostIp, hostPort),
protocol: protocol || 'tcp'
};
})
: [{ hostPort: '', containerPort: '', protocol: 'tcp' }];
// Parse volume mappings
const binds = data.HostConfig.Binds || [];
volumeMappings = binds.length > 0
? binds.map((bind: string) => {
const [hostPath, containerPath, mode] = bind.split(':');
return {
hostPath,
containerPath,
mode: mode || 'rw'
};
})
: [{ hostPath: '', containerPath: '', mode: 'rw' }];
// Parse environment variables
const env = data.Config.Env || [];
envVars = env.length > 0
? env
.filter((e: string) => !e.startsWith('PATH='))
.map((e: string) => {
const [key, ...valueParts] = e.split('=');
return { key, value: valueParts.join('=') };
})
: [{ key: '', value: '' }];
// Parse labels - filter out com.docker.* labels for UI (they're preserved automatically by updateContainer)
const containerLabels = data.Config.Labels || {};
const labelEntries = Object.entries(containerLabels).filter(
([key]) => !key.startsWith('com.docker.')
);
labels = labelEntries.length > 0
? labelEntries.map(([key, value]) => ({ key, value: String(value) }))
: [{ key: '', value: '' }];
// Detect if container belongs to a compose stack
const composeProject = containerLabels['com.docker.compose.project'];
if (composeProject) {
isComposeContainer = true;
composeStackName = composeProject;
} else {
isComposeContainer = false;
composeStackName = '';
}
// Parse connected networks. selectedNetworks holds ADDITIONAL networks only —
// the primary lives in networkMode, never in this list (Portainer-style).
const networks = data.NetworkSettings?.Networks || {};
selectedNetworks = Object.keys(networks).filter(n => n !== networkMode);
// Parse per-network IP/alias config from NetworkSettings
const parsedNetConfigs: Record<string, { ipv4Address: string; ipv6Address: string; aliases: string }> = {};
for (const [netName, netData] of Object.entries(networks) as [string, any][]) {
const ipam = netData.IPAMConfig || {};
const aliases = netData.Aliases || [];
if (ipam.IPv4Address || ipam.IPv6Address || aliases.length > 0) {
parsedNetConfigs[netName] = {
ipv4Address: ipam.IPv4Address || '',
ipv6Address: ipam.IPv6Address || '',
aliases: aliases.join(', ')
};
}
}
networkConfigs = parsedNetConfigs;
// Parse MAC address
macAddress = data.Config?.MacAddress || '';
// Parse advanced options - User
containerUser = data.Config.User || '';
// Parse advanced options - Privileged
privilegedMode = data.HostConfig.Privileged || false;
// Parse advanced options - Healthcheck
const healthcheck = data.Config.Healthcheck;
if (healthcheck && healthcheck.Test && healthcheck.Test.length > 0) {
healthcheckEnabled = true;
// Extract command from Test array (first element is usually CMD-SHELL or CMD)
if (healthcheck.Test[0] === 'CMD-SHELL') {
healthcheckCommand = healthcheck.Test.slice(1).join(' ');
} else if (healthcheck.Test[0] === 'CMD') {
healthcheckCommand = healthcheck.Test.slice(1).join(' ');
} else if (healthcheck.Test[0] === 'NONE') {
healthcheckEnabled = false;
} else {
healthcheckCommand = healthcheck.Test.join(' ');
}
healthcheckInterval = Math.floor((healthcheck.Interval || 30e9) / 1e9);
healthcheckTimeout = Math.floor((healthcheck.Timeout || 30e9) / 1e9);
healthcheckRetries = healthcheck.Retries || 3;
healthcheckStartPeriod = Math.floor((healthcheck.StartPeriod || 0) / 1e9);
} else {
healthcheckEnabled = false;
healthcheckCommand = '';
healthcheckInterval = 30;
healthcheckTimeout = 30;
healthcheckRetries = 3;
healthcheckStartPeriod = 0;
}
// Parse advanced options - Resources (reset first to avoid stale values)
memoryLimit = '';
memoryReservation = '';
cpuShares = '';
nanoCpus = '';
cpuQuota = '';
cpuPeriod = '';
if (data.HostConfig.Memory) {
memoryLimit = formatBytes(data.HostConfig.Memory);
}
if (data.HostConfig.MemoryReservation) {
memoryReservation = formatBytes(data.HostConfig.MemoryReservation);
}
if (data.HostConfig.CpuShares && data.HostConfig.CpuShares !== 0) {
cpuShares = String(data.HostConfig.CpuShares);
}
if (data.HostConfig.NanoCpus) {
nanoCpus = String(data.HostConfig.NanoCpus / 1e9);
}
if (data.HostConfig.CpuQuota) {
cpuQuota = String(data.HostConfig.CpuQuota);
}
if (data.HostConfig.CpuPeriod) {
cpuPeriod = String(data.HostConfig.CpuPeriod);
}
// Parse advanced options - Capabilities
capAdd = data.HostConfig.CapAdd || [];
capDrop = data.HostConfig.CapDrop || [];
// Parse advanced options - Devices
const devices = data.HostConfig.Devices || [];
deviceMappings = devices.map((d: any) => ({
hostPath: d.PathOnHost || '',
containerPath: d.PathInContainer || '',
permissions: d.CgroupPermissions || 'rwm'
}));
// Parse advanced options - DNS
dnsServers = data.HostConfig.Dns || [];
dnsSearch = data.HostConfig.DnsSearch || [];
dnsOptions = data.HostConfig.DnsOptions || [];
// Parse advanced options - Security options
securityOptions = data.HostConfig.SecurityOpt || [];
// Parse advanced options - Ulimits
const ulimitsList = data.HostConfig.Ulimits || [];
ulimits = ulimitsList.map((u: any) => ({
name: u.Name || '',
soft: String(u.Soft || 0),
hard: String(u.Hard || 0)
}));
// Parse GPU / Device Requests
const deviceReqs = data.HostConfig?.DeviceRequests || [];
if (deviceReqs.length > 0) {
gpuEnabled = true;
const firstReq = deviceReqs[0];
if (firstReq.DeviceIDs?.length > 0) {
gpuMode = 'specific';
gpuDeviceIds = [...firstReq.DeviceIDs];
} else if (firstReq.Count === -1) {
gpuMode = 'all';
} else {
gpuMode = 'count';
gpuCount = firstReq.Count || 1;
}
gpuCapabilities = firstReq.Capabilities?.length > 0
? firstReq.Capabilities.flat() : ['gpu'];
gpuDriver = firstReq.Driver || '';
} else {
gpuEnabled = false;
gpuMode = 'all';
gpuCount = 1;
gpuDeviceIds = [];
gpuDriver = '';
gpuCapabilities = ['gpu'];
}
// Parse runtime
runtime = (data.HostConfig?.Runtime && data.HostConfig.Runtime !== 'runc')
? data.HostConfig.Runtime : '';
// Fetch auto-update settings
await fetchAutoUpdateSettings(name);
// Store original config for change detection
originalConfig = {
name,
image,
command,
restartPolicy,
networkMode,
portMappings: JSON.parse(JSON.stringify(portMappings)),
volumeMappings: JSON.parse(JSON.stringify(volumeMappings)),
envVars: JSON.parse(JSON.stringify(envVars)),
labels: JSON.parse(JSON.stringify(labels)),
selectedNetworks: [...selectedNetworks],
networkConfigs: JSON.parse(JSON.stringify(networkConfigs)),
macAddress,
// Advanced options
containerUser,
privilegedMode,
healthcheckEnabled,
healthcheckCommand,
healthcheckInterval,
healthcheckTimeout,
healthcheckRetries,
healthcheckStartPeriod,
memoryLimit,
memoryReservation,
cpuShares,
nanoCpus,
cpuQuota,
cpuPeriod,
capAdd: [...capAdd],
capDrop: [...capDrop],
securityOptions: [...securityOptions],
deviceMappings: JSON.parse(JSON.stringify(deviceMappings)),
dnsServers: [...dnsServers],
dnsSearch: [...dnsSearch],
dnsOptions: [...dnsOptions],
ulimits: JSON.parse(JSON.stringify(ulimits)),
gpuEnabled,
gpuMode,
gpuCount,
gpuDeviceIds: [...gpuDeviceIds],
gpuDriver,
gpuCapabilities: [...gpuCapabilities],
runtime
};
} catch (err) {
error = 'Failed to load container data: ' + String(err);
} finally {
loadingData = false;
requestAnimationFrame(() => {
visible = true;
focusFirstInput();
});
}
}
// Check if container configuration has changed
function hasContainerConfigChanged(): boolean {
if (!originalConfig) return true;
// Basic options
if (name.trim() !== originalConfig.name) return true;
if (image.trim() !== originalConfig.image) return true;
if (command.trim() !== originalConfig.command) return true;
if (restartPolicy !== originalConfig.restartPolicy) return true;
if (networkMode !== originalConfig.networkMode) return true;
const currentPorts = portMappings.filter(p => p.containerPort && p.hostPort);
const originalPorts = originalConfig.portMappings.filter(p => p.containerPort && p.hostPort);
if (JSON.stringify(currentPorts) !== JSON.stringify(originalPorts)) return true;
const currentVolumes = volumeMappings.filter(v => v.hostPath && v.containerPath);
const originalVolumes = originalConfig.volumeMappings.filter(v => v.hostPath && v.containerPath);
if (JSON.stringify(currentVolumes) !== JSON.stringify(originalVolumes)) return true;
const currentEnvs = envVars.filter(e => e.key);
const originalEnvs = originalConfig.envVars.filter(e => e.key);
if (JSON.stringify(currentEnvs) !== JSON.stringify(originalEnvs)) return true;
const currentLabels = labels.filter(l => l.key);
const originalLabels = originalConfig.labels.filter(l => l.key);
if (JSON.stringify(currentLabels) !== JSON.stringify(originalLabels)) return true;
if (JSON.stringify([...selectedNetworks].sort()) !== JSON.stringify([...originalConfig.selectedNetworks].sort())) return true;
if (JSON.stringify(networkConfigs) !== JSON.stringify(originalConfig.networkConfigs)) return true;
if (macAddress !== originalConfig.macAddress) return true;
// Advanced options - User & Security
if (containerUser !== originalConfig.containerUser) return true;
if (privilegedMode !== originalConfig.privilegedMode) return true;
if (JSON.stringify([...capAdd].sort()) !== JSON.stringify([...originalConfig.capAdd].sort())) return true;
if (JSON.stringify([...capDrop].sort()) !== JSON.stringify([...originalConfig.capDrop].sort())) return true;
if (JSON.stringify([...securityOptions].sort()) !== JSON.stringify([...originalConfig.securityOptions].sort())) return true;
// Advanced options - Healthcheck
if (healthcheckEnabled !== originalConfig.healthcheckEnabled) return true;
if (healthcheckCommand !== originalConfig.healthcheckCommand) return true;
if (healthcheckInterval !== originalConfig.healthcheckInterval) return true;
if (healthcheckTimeout !== originalConfig.healthcheckTimeout) return true;
if (healthcheckRetries !== originalConfig.healthcheckRetries) return true;
if (healthcheckStartPeriod !== originalConfig.healthcheckStartPeriod) return true;
// Advanced options - Resources
if (memoryLimit !== originalConfig.memoryLimit) return true;
if (memoryReservation !== originalConfig.memoryReservation) return true;
if (cpuShares !== originalConfig.cpuShares) return true;
if (nanoCpus !== originalConfig.nanoCpus) return true;
if (cpuQuota !== originalConfig.cpuQuota) return true;
if (cpuPeriod !== originalConfig.cpuPeriod) return true;
// Advanced options - Devices
const currentDevices = deviceMappings.filter(d => d.hostPath && d.containerPath);
const originalDevices = originalConfig.deviceMappings.filter(d => d.hostPath && d.containerPath);
if (JSON.stringify(currentDevices) !== JSON.stringify(originalDevices)) return true;
// Advanced options - DNS
if (JSON.stringify([...dnsServers]) !== JSON.stringify([...originalConfig.dnsServers])) return true;
if (JSON.stringify([...dnsSearch]) !== JSON.stringify([...originalConfig.dnsSearch])) return true;
if (JSON.stringify([...dnsOptions]) !== JSON.stringify([...originalConfig.dnsOptions])) return true;
// Advanced options - Ulimits
const currentUlimits = ulimits.filter(u => u.name && u.soft && u.hard);
const originalUlimits = originalConfig.ulimits.filter(u => u.name && u.soft && u.hard);
if (JSON.stringify(currentUlimits) !== JSON.stringify(originalUlimits)) return true;
// GPU settings
if (gpuEnabled !== originalConfig.gpuEnabled) return true;
if (gpuMode !== originalConfig.gpuMode) return true;
if (gpuCount !== originalConfig.gpuCount) return true;
if (JSON.stringify([...gpuDeviceIds].sort()) !== JSON.stringify([...originalConfig.gpuDeviceIds].sort())) return true;
if (gpuDriver !== originalConfig.gpuDriver) return true;
if (JSON.stringify([...gpuCapabilities].sort()) !== JSON.stringify([...originalConfig.gpuCapabilities].sort())) return true;
if (runtime !== originalConfig.runtime) return true;
return false;
}
function hasAutoUpdateChanged(): boolean {
if (!originalAutoUpdate) return true;
return (
autoUpdateEnabled !== originalAutoUpdate.enabled ||
autoUpdateCronExpression !== originalAutoUpdate.cronExpression ||
vulnerabilityCriteria !== originalAutoUpdate.vulnerabilityCriteria
);
}
function serializeConfigWithoutName() {
return JSON.stringify({
image: image.trim(),
command: command.trim(),
restartPolicy,
networkMode,
portMappings: portMappings.filter(p => p.containerPort && p.hostPort),
volumeMappings: volumeMappings.filter(v => v.hostPath && v.containerPath),
envVars: envVars.filter(e => e.key),
labels: labels.filter(l => l.key),
selectedNetworks: [...selectedNetworks].sort(),
networkConfigs,
macAddress
});
}
function serializeOriginalConfigWithoutName() {
if (!originalConfig) return null;
return JSON.stringify({
image: originalConfig.image,
command: originalConfig.command,
restartPolicy: originalConfig.restartPolicy,
networkMode: originalConfig.networkMode,
portMappings: originalConfig.portMappings.filter(p => p.containerPort && p.hostPort),
volumeMappings: originalConfig.volumeMappings.filter(v => v.hostPath && v.containerPath),
envVars: originalConfig.envVars.filter(e => e.key),
labels: originalConfig.labels.filter(l => l.key),
selectedNetworks: [...originalConfig.selectedNetworks].sort(),
networkConfigs: originalConfig.networkConfigs,
macAddress: originalConfig.macAddress
});
}
function hasOnlyNameChanged(): boolean {
if (!originalConfig) return false;
if (name.trim() === originalConfig.name) return false;
return serializeConfigWithoutName() === serializeOriginalConfigWithoutName();
}
function hasConfigChangedBesidesName(): boolean {
if (!originalConfig) return false;
return serializeConfigWithoutName() !== serializeOriginalConfigWithoutName();
}
let showComposeRenameWarning = $derived(isComposeContainer && hasOnlyNameChanged());
let showComposeConfigWarning = $derived(isComposeContainer && hasConfigChangedBesidesName());
function parseMemory(value: string): number | undefined {
if (!value) return undefined;
const match = value.trim().toLowerCase().match(/^(\d+(?:\.\d+)?)\s*([kmgt]?b?)?$/);
if (!match) return undefined;
const num = parseFloat(match[1]);
const unit = match[2] || '';
switch (unit) {
case 'k': case 'kb': return Math.floor(num * 1024);
case 'm': case 'mb': return Math.floor(num * 1024 * 1024);
case 'g': case 'gb': return Math.floor(num * 1024 * 1024 * 1024);
case 't': case 'tb': return Math.floor(num * 1024 * 1024 * 1024 * 1024);
default: return Math.floor(num);
}
}
function parseNanoCpus(value: string): number | undefined {
if (!value) return undefined;
const num = parseFloat(value);
if (isNaN(num)) return undefined;
return Math.floor(num * 1e9);
}
async function handleSubmit(e: Event) {
e.preventDefault();
error = '';
errors = {};
statusMessage = '';
let hasErrors = false;
if (!name.trim()) {
errors.name = 'Container name is required';
hasErrors = true;
}
if (!image.trim()) {
errors.image = 'Image name is required';
hasErrors = true;
}
if (hasErrors) {
return;
}
loading = true;
abortController = new AbortController();
const signal = abortController.signal;
const containerConfigChanged = hasContainerConfigChanged();
const autoUpdateChanged = hasAutoUpdateChanged();
if (!containerConfigChanged && !autoUpdateChanged) {
onClose();
loading = false;
return;
}
try {
// If only name changed, use the rename endpoint
if (hasOnlyNameChanged()) {
statusMessage = 'Renaming container...';
const response = await fetch(appendEnvParam(
`/api/containers/${containerId}/rename`,
$currentEnvironment?.id
), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: name.trim() }),
signal
});
const result = await response.json();
if (!response.ok) {
error = result.error || 'Failed to rename container';
loading = false;
return;
}
statusMessage = 'Container renamed successfully!';
if (autoUpdateChanged) {
await saveAutoUpdateSettings(name.trim());
}
await new Promise(resolve => setTimeout(resolve, 500));
onSuccess();
onClose();
loading = false;
return;
}
// Full update required - recreate container
if (containerConfigChanged) {
statusMessage = 'Updating container...';
const ports: Record<string, { HostIp?: string; HostPort: string }> = {};
portMappings
.filter((p) => p.containerPort)
.forEach((p) => {
const parsed = parseHostPort(p.hostPort);
const bindings = expandPortBindings(parsed.hostPort, p.containerPort, p.protocol, parsed.hostIp);
Object.assign(ports, bindings);
});
const volumeBinds = volumeMappings
.filter((v) => v.hostPath && v.containerPath)
.map((v) => `${v.hostPath}:${v.containerPath}:${v.mode}`);
const env = envVars
.filter((e) => e.key && e.value)
.map((e) => `${e.key}=${e.value}`);
const labelsObj: any = {};
labels
.filter((l) => l.key && l.value)
.forEach((l) => {
labelsObj[l.key] = l.value;
});
const cmd = command.trim() ? parseShellCommand(command.trim()) : undefined;
let healthcheck: any = undefined;
if (healthcheckEnabled && healthcheckCommand.trim()) {
healthcheck = {
test: ['CMD-SHELL', healthcheckCommand.trim()],
interval: healthcheckInterval * 1e9,
timeout: healthcheckTimeout * 1e9,
retries: healthcheckRetries,
startPeriod: healthcheckStartPeriod * 1e9
};
} else if (!healthcheckEnabled) {
healthcheck = null;
}
const devices = deviceMappings
.filter(d => d.hostPath && d.containerPath)
.map(d => ({
hostPath: d.hostPath,
containerPath: d.containerPath,
permissions: d.permissions || 'rwm'
}));
const ulimitsArray = ulimits
.filter(u => u.name && u.soft && u.hard)
.map(u => ({
name: u.name,
soft: parseInt(u.soft) || 0,
hard: parseInt(u.hard) || 0
}));
let deviceRequests: any[] | undefined = undefined;
if (gpuEnabled) {
const dr: any = {
capabilities: gpuCapabilities.length > 0 ? [gpuCapabilities] : [['gpu']],
driver: gpuDriver || undefined
};
if (gpuMode === 'all') {
dr.count = -1;
} else if (gpuMode === 'count') {
dr.count = gpuCount || 1;
} else {
dr.count = 0;
dr.deviceIDs = gpuDeviceIds.filter(id => id.trim());
}
deviceRequests = [dr];
}
// Build per-network configs for the API
const netConfigs: Record<string, { ipv4Address?: string; ipv6Address?: string; aliases?: string[] }> = {};
for (const [netName, cfg] of Object.entries(networkConfigs)) {
if (!selectedNetworks.includes(netName)) continue;
const entry: { ipv4Address?: string; ipv6Address?: string; aliases?: string[] } = {};
if (cfg.ipv4Address) entry.ipv4Address = cfg.ipv4Address;
if (cfg.ipv6Address) entry.ipv6Address = cfg.ipv6Address;
if (cfg.aliases) entry.aliases = cfg.aliases.split(',').map(a => a.trim()).filter(Boolean);
if (Object.keys(entry).length > 0) netConfigs[netName] = entry;
}
const payload = {
name: name.trim(),
image: image.trim(),
ports: Object.keys(ports).length > 0 ? ports : null,
volumeBinds: volumeBinds.length > 0 ? volumeBinds : null,
// Fields the form manages: send `null` when the user cleared them
// so the server can distinguish "cleared" from "absent" (#1119).
// Server's updateContainer merges payload over existing config and
// treats explicit null as "clear this field" — undefined would be
// dropped by JSON.stringify and the merge would preserve the old value.
env: env.length > 0 ? env : null,
labels: labelsObj,
cmd,
restartPolicy,
restartMaxRetries: restartPolicy === 'on-failure' && restartMaxRetries !== '' ? Number(restartMaxRetries) : null,
networkMode,
// Additional networks only — primary is networkMode. Shared modes
// (host/none/container:X) reject extras; UI already hides the section.
additionalNetworks: (() => {
const isSharedMode = networkMode === 'host' || networkMode === 'none' || networkMode.startsWith('container:');
if (isSharedMode) return null;
return selectedNetworks.length > 0 ? selectedNetworks : null;
})(),
networkConfigs: Object.keys(netConfigs).length > 0 ? netConfigs : null,
macAddress: macAddress.trim() || null,
startAfterUpdate,
repullImage,
user: containerUser.trim() || null,
privileged: privilegedMode,
healthcheck,
memory: parseMemory(memoryLimit) ?? null,
memoryReservation: parseMemory(memoryReservation) ?? null,
cpuShares: cpuShares ? parseInt(cpuShares) : null,
nanoCpus: parseNanoCpus(nanoCpus) ?? null,
cpuQuota: cpuQuota ? parseInt(cpuQuota) : null,
cpuPeriod: cpuPeriod ? parseInt(cpuPeriod) : null,
capAdd: capAdd.length > 0 ? capAdd : null,
capDrop: capDrop.length > 0 ? capDrop : null,
devices: devices.length > 0 ? devices : null,
deviceRequests: deviceRequests ?? null,
runtime: runtime || null,
dns: dnsServers.length > 0 ? dnsServers : null,
dnsSearch: dnsSearch.length > 0 ? dnsSearch : null,
dnsOptions: dnsOptions.length > 0 ? dnsOptions : null,
securityOpt: securityOptions.length > 0 ? securityOptions : null,
ulimits: ulimitsArray.length > 0 ? ulimitsArray : null
};
const response = await fetch(appendEnvParam(`/api/containers/${containerId}/update`, $currentEnvironment?.id), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
signal
});
const result = await response.json();
if (!response.ok) {
error = result.error || 'Failed to update container';
if (result.details) {
error += ': ' + result.details;
}
return;
}
statusMessage = 'Container updated successfully!';
}
if (autoUpdateChanged) {
if (!containerConfigChanged) {
statusMessage = 'Saving auto-update settings...';
}
await saveAutoUpdateSettings(name.trim());
if (!containerConfigChanged) {
statusMessage = 'Auto-update settings saved!';
}
}
await new Promise(resolve => setTimeout(resolve, 500));
onSuccess();
onClose();
} catch (err) {
if (signal.aborted) return;
error = 'Failed to update container: ' + String(err);
} finally {
loading = false;
abortController = null;
}
}
function handleClose() {
if (abortController) {
abortController.abort();
abortController = null;
}
loading = false;
onClose();
}
$effect(() => {
if (open && containerId && !hasLoadedData) {
visible = false;
loadingData = true;
name = ''; // Reset to prevent showing stale name in header
statusMessage = '';
error = '';
hasLoadedData = true;
loadContainerData();
fetchConfigSets();
selectedConfigSetId = '';
} else if (!open) {
loadingData = true;
visible = false;
hasLoadedData = false;
}
});
</script>
<Dialog.Root bind:open onOpenChange={(isOpen) => isOpen && focusFirstInput()}>
<Dialog.Content class="max-w-4xl w-full max-h-[90vh] p-0 flex flex-col overflow-hidden sm:max-h-[85vh]">
<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">
Edit container
{#if isEditingTitle}
<span class="ml-1">-</span>
<input
type="text"
bind:value={editTitleName}
bind:this={titleInputRef}
class="text-muted-foreground font-normal bg-muted border border-input rounded px-2 py-0.5 text-sm outline-none focus:ring-1 focus:ring-ring"
onkeydown={(e) => {
if (e.key === 'Enter') saveEditingTitle();
if (e.key === 'Escape') cancelEditingTitle();
}}
/>
<button
type="button"
onclick={saveEditingTitle}
title="Save"
class="p-0.5 rounded hover:bg-muted transition-colors"
>
<Check class="w-3 h-3 text-green-500 hover:text-green-600" />
</button>
<button
type="button"
onclick={cancelEditingTitle}
title="Cancel"
class="p-0.5 rounded hover:bg-muted transition-colors"
>
<X class="w-3 h-3 text-muted-foreground hover:text-foreground" />
</button>
{:else if name}
<span class="font-normal text-muted-foreground ml-1">- {name}</span>
<button
type="button"
onclick={startEditingTitle}
title="Rename container"
class="p-0.5 rounded hover:bg-muted transition-colors ml-0.5"
>
<Pencil class="w-3 h-3 text-muted-foreground hover:text-foreground" />
</button>
{/if}
</Dialog.Title>
</Dialog.Header>
{#if loadingData}
<div class="flex-1 flex items-center justify-center text-muted-foreground text-sm min-h-[200px]">
<Loader2 class="w-5 h-5 animate-spin mr-2" />
Loading container data...
</div>
{:else}
<div class="px-5 py-4 flex-1 overflow-y-auto">
<!-- Compose warning banners -->
{#if showComposeRenameWarning}
<div class="mb-4 px-3 py-2 text-xs text-amber-700 dark:text-amber-300 bg-amber-100/50 dark:bg-amber-900/30 rounded-md flex items-start gap-2">
<Layers class="w-4 h-4 shrink-0 mt-0.5" />
<span>This container is part of the <strong>{composeStackName}</strong> compose stack. Renaming it may cause issues with stack management.</span>
</div>
{/if}
{#if showComposeConfigWarning}
<div class="mb-4 px-3 py-2 text-xs text-amber-700 dark:text-amber-300 bg-amber-100/50 dark:bg-amber-900/30 rounded-md flex items-start gap-2">
<Layers class="w-4 h-4 shrink-0 mt-0.5" />
<span>This container is part of the <strong>{composeStackName}</strong> compose stack. Changes may be overwritten when the stack is redeployed.</span>
</div>
{/if}
<ContainerSettingsTab
mode="edit"
{containerId}
envId={$currentEnvironment?.id ?? undefined}
bind:name
bind:image
bind:command
bind:restartPolicy
bind:restartMaxRetries
bind:networkMode
bind:startAfterCreate={startAfterUpdate}
bind:repullImage
bind:portMappings
bind:volumeMappings
bind:envVars
bind:labels
bind:selectedNetworks
bind:networkConfigs
bind:macAddress
bind:containerUser
bind:privilegedMode
bind:healthcheckEnabled
bind:healthcheckCommand
bind:healthcheckInterval
bind:healthcheckTimeout
bind:healthcheckRetries
bind:healthcheckStartPeriod
bind:memoryLimit
bind:memoryReservation
bind:cpuShares
bind:nanoCpus
bind:cpuQuota
bind:cpuPeriod
bind:capAdd
bind:capDrop
bind:securityOptions
bind:deviceMappings
bind:gpuEnabled
bind:gpuMode
bind:gpuCount
bind:gpuDeviceIds
bind:gpuDriver
bind:gpuCapabilities
bind:runtime
bind:dnsServers
bind:dnsSearch
bind:dnsOptions
bind:ulimits
bind:autoUpdateEnabled
bind:autoUpdateCronExpression
bind:vulnerabilityCriteria
{configSets}
bind:selectedConfigSetId
bind:errors
/>
{#if statusMessage}
<div class="mt-4 px-3 py-2 text-xs text-primary bg-primary/10 rounded-md">
{statusMessage}
</div>
{/if}
{#if error}
<div class="mt-4 px-3 py-2 text-xs text-destructive bg-destructive/10 rounded-md">
{error}
</div>
{/if}
</div>
<div class="flex justify-end gap-2 px-5 py-3 border-t bg-muted/30 shrink-0">
<Button type="button" variant="outline" onclick={handleClose} size="sm">
Cancel
</Button>
<Button type="button" variant="secondary" disabled={loading} size="sm" onclick={handleSubmit}>
{#if loading}
<Loader2 class="w-4 h-4 mr-1 animate-spin" />
Updating...
{:else}
Update container
{/if}
</Button>
</div>
{/if}
</Dialog.Content>
</Dialog.Root>