mirror of
https://github.com/Finsys/dockhand.git
synced 2026-06-17 19:09:33 +03:00
1196 lines
39 KiB
Svelte
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>
|