This commit is contained in:
jarek committed 2026-06-06 16:18:05 +02:00
1 parent 00bd09df55
commit 3cbcfa3cdb
26 files changed
+1367 -765

No files matched your search

@@ -38,6 +38,23 @@
let loading = $state(true);
let error = $state('');
let containerData = $state<any>(null);
// Peer containers in the current env — used to resolve "container:<sha>" mode to a friendly name
let peerContainers = $state<Array<{ id: string; name: string }>>([]);
const networkModeLabel = $derived.by(() => {
const raw = containerData?.HostConfig?.NetworkMode || 'default';
if (!raw.startsWith('container:')) return raw;
const ref = raw.slice('container:'.length);
const match = peerContainers.find(c => c.id === ref || c.id.startsWith(ref));
return match ? `container:${match.name}` : raw;
});
// Docker rejects attaching extra networks when the container shares another
// namespace (host / none / container:X / service:X). Hide join controls then.
const isSharedNetworkMode = $derived.by(() => {
const mode = containerData?.HostConfig?.NetworkMode || '';
return mode === 'host' || mode === 'none' || mode.startsWith('container:') || mode.startsWith('service:');
});
// Active tab state for layers visibility
let activeTab = $state('overview');
@@ -52,6 +69,8 @@
// Label copy state
let copiedLabel = $state<string | null>(null);
let copyLabelFailed = $state(false);
let labelFilter = $state('');
let copiedAllLabels = $state(false);
async function copyLabel(key: string, value: string) {
const ok = await copyToClipboard(`${key}=${value}`);
@@ -64,6 +83,19 @@
}
}
async function copyAllLabels(entries: [string, string][]) {
if (entries.length === 0) return;
const text = entries.map(([k, v]) => `${k}=${v}`).join('\n');
const ok = await copyToClipboard(text);
if (ok) {
copiedAllLabels = true;
setTimeout(() => copiedAllLabels = false, 2000);
} else {
copyLabelFailed = true;
setTimeout(() => copyLabelFailed = false, 2000);
}
}
// Processes state
interface ProcessesData {
Titles: string[];
@@ -281,6 +313,9 @@
$effect(() => {
if (open && containerData?.State?.Running) {
startStatsCollection();
// One-shot fetch so the Overview's process count tile renders
// immediately, even if the user never opens the Processes tab.
if (!processesData) fetchProcesses();
} else {
stopStatsCollection();
}
@@ -317,6 +352,7 @@
lastFetchedId = '';
isLiveConnected = false;
lastStatsUpdate = 0;
labelFilter = '';
displayName = '';
isEditing = false;
editName = '';
@@ -335,6 +371,19 @@
throw new Error('Failed to fetch container details');
}
containerData = await response.json();
// Fetch peers only when this container shares another container's namespace —
// keeps the dialog snappy when the network mode is bridge/host/none/custom.
if (containerData?.HostConfig?.NetworkMode?.startsWith('container:')) {
try {
const peersRes = await fetch(appendEnvParam('/api/containers', envId));
if (peersRes.ok) {
const list = await peersRes.json();
peerContainers = list.map((c: any) => ({ id: c.id, name: c.name }));
}
} catch {
// Non-fatal — falls back to the raw SHA
}
}
} catch (err: any) {
error = err.message || 'Failed to load container details';
console.error('Failed to fetch container inspect:', err);
@@ -583,11 +632,11 @@
variant="outline"
size="sm"
onclick={() => showRawJson = true}
title="View raw JSON"
title="View raw inspect data"
class="ml-auto mr-6"
>
<Code class="w-4 h-4 mr-1.5" />
JSON
Inspect
</Button>
{/if}
</Dialog.Title>
@@ -623,7 +672,7 @@
<Tabs.Content value="overview" class="space-y-4 overflow-auto">
<!-- Real-time Stats (only for running containers) -->
{#if containerData.State?.Running}
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3">
<div class="grid grid-cols-2 lg:grid-cols-5 gap-3">
<!-- CPU -->
<div class="p-3 border border-border rounded-lg">
<div class="flex items-center gap-2 mb-2">
@@ -709,6 +758,30 @@
</div>
</div>
</div>
<!-- Processes -->
<div class="p-3 border border-border rounded-lg">
<div class="flex items-center gap-2 mb-2">
<Activity class="w-4 h-4 text-pink-500" />
<span class="text-xs font-medium">Processes</span>
<button
type="button"
class="ml-auto text-sm font-bold hover:text-foreground/80 transition-colors"
onclick={() => activeTab = 'processes'}
title="View process list"
>
{processesData?.Processes?.length ?? '—'}
</button>
</div>
<div class="h-8 flex items-center justify-center text-2xs text-muted-foreground">
{#if processesData?.Processes?.length}
running in container
{:else if processesLoading}
<Loader2 class="w-3 h-3 animate-spin" />
{:else}
—
{/if}
</div>
</div>
</div>
{/if}
@@ -865,7 +938,7 @@
<!-- Network Mode -->
<div class="space-y-2">
<h3 class="text-sm font-semibold">Network mode</h3>
<Badge variant="outline">{containerData.HostConfig?.NetworkMode || 'default'}</Badge>
<Badge variant="outline">{networkModeLabel}</Badge>
</div>
<!-- DNS Settings -->
@@ -918,7 +991,11 @@
<!-- Networks -->
<div class="space-y-2">
<h3 class="text-sm font-semibold">Connected networks</h3>
{#if containerData.NetworkSettings?.Networks && Object.keys(containerData.NetworkSettings.Networks).length > 0}
{#if isSharedNetworkMode}
<p class="text-xs text-muted-foreground">
Network namespace is shared via <code class="px-1 py-0.5 rounded bg-muted">{containerData.HostConfig?.NetworkMode}</code> — additional networks cannot be attached.
</p>
{:else if containerData.NetworkSettings?.Networks && Object.keys(containerData.NetworkSettings.Networks).length > 0}
<div class="space-y-2">
{#each Object.entries(containerData.NetworkSettings.Networks) as [networkName, networkData]}
{@const netData = networkData as any}
@@ -985,7 +1062,7 @@
{/if}
<!-- Join network dropdown -->
{#if containerData.State?.Running}
{#if containerData.State?.Running && !isSharedNetworkMode}
<div class="flex items-center gap-2 pt-1">
<Select.Root type="single" bind:value={selectedNetwork}>
<Select.Trigger class="flex-1 h-8 text-xs">
@@ -1152,12 +1229,23 @@
<!-- Environment Tab -->
<Tabs.Content value="env" class="space-y-4 overflow-auto">
{#if containerData.divergence?.env?.length > 0}
<div class="flex items-start gap-2 text-xs p-2.5 rounded border border-amber-500/30 bg-amber-500/5 text-amber-700 dark:text-amber-300">
<Info class="w-3.5 h-3.5 shrink-0 mt-0.5" />
<div class="min-w-0">
{containerData.divergence.env.length} env var{containerData.divergence.env.length === 1 ? '' : 's'} differ from the image:
<span class="font-mono">{containerData.divergence.env.join(', ')}</span>.
Values set by you at create time will stay. To reset to the image's current values, Remove &amp; Deploy.
</div>
</div>
{/if}
{#if containerData.Config?.Env && containerData.Config.Env.length > 0}
<div class="space-y-1">
{#each [...containerData.Config.Env].sort((a, b) => a.split('=')[0].localeCompare(b.split('=')[0])) as envVar}
{@const [key, ...valueParts] = envVar.split('=')}
{@const value = valueParts.join('=')}
<div class="text-xs p-2 bg-muted rounded">
{@const diverges = containerData.divergence?.env?.includes(key)}
<div class="text-xs p-2 rounded {diverges ? 'bg-amber-500/10 border border-amber-500/30' : 'bg-muted'}">
<code class="text-muted-foreground font-medium">{key}</code>
<code class="text-muted-foreground">=</code>
<code class="break-all">{value}</code>
@@ -1170,31 +1258,79 @@
</Tabs.Content>
<!-- Labels Tab -->
<Tabs.Content value="labels" class="space-y-4 overflow-auto">
{#if containerData.Config?.Labels && Object.keys(containerData.Config.Labels).length > 0}
<div class="space-y-1">
{#each Object.entries(containerData.Config.Labels).sort((a, b) => a[0].localeCompare(b[0])) as [key, value]}
<div class="text-xs p-2 bg-muted rounded flex items-start gap-2 group">
<div class="flex-1 min-w-0">
<code class="text-muted-foreground font-medium">{key}</code>
<code class="text-muted-foreground">=</code>
<code class="break-all">{value}</code>
</div>
<button
type="button"
onclick={() => copyLabel(key, value)}
class="shrink-0 p-1 rounded hover:bg-background/50 transition-colors opacity-0 group-hover:opacity-100 {copiedLabel === key ? '!opacity-100' : ''}"
title={copiedLabel === key ? 'Copied!' : 'Copy label'}
>
{#if copiedLabel === key}
<Check class="w-3 h-3 text-green-500" />
{:else}
<Copy class="w-3 h-3 text-muted-foreground" />
{/if}
</button>
</div>
{/each}
<Tabs.Content value="labels" class="space-y-3 overflow-auto">
{#if containerData.divergence?.labels?.length > 0}
<div class="flex items-start gap-2 text-xs p-2.5 rounded border border-amber-500/30 bg-amber-500/5 text-amber-700 dark:text-amber-300">
<Info class="w-3.5 h-3.5 shrink-0 mt-0.5" />
<div class="min-w-0">
{containerData.divergence.labels.length} label{containerData.divergence.labels.length === 1 ? '' : 's'} differ from the image:
<span class="font-mono">{containerData.divergence.labels.join(', ')}</span>.
Values set by you at create time will stay. To reset to the image's current values, Remove &amp; Deploy.
</div>
</div>
{/if}
{#if containerData.Config?.Labels && Object.keys(containerData.Config.Labels).length > 0}
{@const allLabels = Object.entries(containerData.Config.Labels).sort((a, b) => a[0].localeCompare(b[0]))}
{@const filter = labelFilter.trim().toLowerCase()}
{@const visibleLabels = filter
? allLabels.filter(([k, v]) => k.toLowerCase().includes(filter) || String(v).toLowerCase().includes(filter))
: allLabels}
<div class="flex items-center gap-2">
<Input
type="search"
placeholder="Filter labels..."
bind:value={labelFilter}
class="h-8 text-xs flex-1"
/>
<span class="text-xs text-muted-foreground shrink-0">
{visibleLabels.length === allLabels.length
? `${allLabels.length} label${allLabels.length === 1 ? '' : 's'}`
: `${visibleLabels.length} of ${allLabels.length}`}
</span>
<Button
variant="outline"
size="sm"
onclick={() => copyAllLabels(visibleLabels)}
disabled={visibleLabels.length === 0}
title={copiedAllLabels ? 'Copied!' : 'Copy visible labels as key=value lines'}
>
{#if copiedAllLabels}
<Check class="w-3 h-3 mr-1.5 text-green-500" />
Copied
{:else}
<Copy class="w-3 h-3 mr-1.5" />
Copy all
{/if}
</Button>
</div>
{#if visibleLabels.length > 0}
<div class="space-y-1">
{#each visibleLabels as [key, value]}
{@const diverges = containerData.divergence?.labels?.includes(key)}
<div class="text-xs p-2 rounded flex items-start gap-2 group {diverges ? 'bg-amber-500/10 border border-amber-500/30' : 'bg-muted'}">
<div class="flex-1 min-w-0">
<code class="text-muted-foreground font-medium">{key}</code>
<code class="text-muted-foreground">=</code>
<code class="break-all">{value}</code>
</div>
<button
type="button"
onclick={() => copyLabel(key, value)}
class="shrink-0 p-1 rounded hover:bg-background/50 transition-colors opacity-0 group-hover:opacity-100 {copiedLabel === key ? '!opacity-100' : ''}"
title={copiedLabel === key ? 'Copied!' : 'Copy label'}
>
{#if copiedLabel === key}
<Check class="w-3 h-3 text-green-500" />
{:else}
<Copy class="w-3 h-3 text-muted-foreground" />
{/if}
</button>
</div>
{/each}
</div>
{:else}
<p class="text-sm text-muted-foreground">No labels match "{labelFilter}"</p>
{/if}
{:else}
<p class="text-sm text-muted-foreground">No labels</p>
{/if}
@@ -1532,13 +1668,13 @@
</Dialog.Content>
</Dialog.Root>
<!-- Raw JSON Modal -->
<!-- Inspect (raw) modal -->
<Dialog.Root bind:open={showRawJson}>
<Dialog.Content class="max-w-4xl max-h-[90vh] sm:max-h-[80vh] flex flex-col">
<Dialog.Header class="shrink-0">
<Dialog.Title class="flex items-center gap-2">
<Code class="w-5 h-5" />
Raw JSON
Inspect
<Button
variant="outline"
size="sm"
+281 -59
View File
@@ -1,11 +1,14 @@
<script lang="ts">
import * as Select from '$lib/components/ui/select';
import * as Popover from '$lib/components/ui/popover';
import * as Command from '$lib/components/ui/command';
import { tick } from 'svelte';
import { Label } from '$lib/components/ui/label';
import { Input } from '$lib/components/ui/input';
import { Button } from '$lib/components/ui/button';
import { Checkbox } from '$lib/components/ui/checkbox';
import { TogglePill, ToggleGroup } from '$lib/components/ui/toggle-pill';
import { Plus, Trash2, Settings2, RefreshCw, Network, X, Ban, RotateCw, AlertTriangle, PauseCircle, Share2, Server, CircleOff, ChevronDown, ChevronRight, Cpu, Shield, HeartPulse, Wifi, HardDrive, Lock, Loader2, CheckCircle2, Package, Gpu, Search, CircleHelp } from 'lucide-svelte';
import { Plus, Trash2, Settings2, RefreshCw, Network, X, Ban, RotateCw, AlertTriangle, PauseCircle, Share2, Server, CircleOff, Box, ChevronDown, ChevronsUpDown, Check, ChevronRight, Cpu, Shield, HeartPulse, Wifi, HardDrive, Lock, Loader2, CheckCircle2, Package, Gpu, Search, CircleHelp } from 'lucide-svelte';
import { parseHostPort, validatePort, validateIp, formatHostPort, expandPortBindings } from '$lib/utils/port-parse';
import * as Tooltip from '$lib/components/ui/tooltip';
import { currentEnvironment } from '$lib/stores/environment';
@@ -63,6 +66,13 @@
driver: string;
}
interface ContainerItem {
id: string;
name: string;
image: string;
state: string;
}
interface NetworkEndpointConfig {
ipv4Address: string;
ipv6Address: string;
@@ -89,7 +99,6 @@
// Labels
labels: { key: string; value: string }[];
// Networks
availableNetworks: DockerNetwork[];
selectedNetworks: string[];
networkConfigs: Record<string, NetworkEndpointConfig>;
macAddress: string;
@@ -166,7 +175,6 @@
volumeMappings = $bindable(),
envVars = $bindable(),
labels = $bindable(),
availableNetworks,
selectedNetworks = $bindable(),
networkConfigs = $bindable(),
macAddress = $bindable(),
@@ -208,6 +216,113 @@
imageSummary
}: Props = $props();
// Fetch networks and containers from current environment
let availableNetworks = $state<DockerNetwork[]>([]);
let availableContainers = $state<ContainerItem[]>([]);
// Container picker (Popover + Command combobox)
let containerPickerOpen = $state(false);
let containerPickerTriggerRef = $state<HTMLButtonElement>(null!);
function closeAndFocusContainerPicker() {
containerPickerOpen = false;
tick().then(() => containerPickerTriggerRef?.focus());
}
// Networks picker (Popover + Command combobox)
let networkPickerOpen = $state(false);
let networkPickerTriggerRef = $state<HTMLButtonElement>(null!);
function closeAndFocusNetworkPicker() {
networkPickerOpen = false;
tick().then(() => networkPickerTriggerRef?.focus());
}
async function fetchNetworks() {
try {
const envParam = $currentEnvironment ? `?env=${$currentEnvironment.id}` : '';
const response = await fetch(`/api/networks${envParam}`);
if (response.ok) {
availableNetworks = await response.json();
}
} catch (err) {
console.error('Failed to fetch networks:', err);
}
}
async function fetchContainers() {
try {
const envParam = $currentEnvironment ? `?env=${$currentEnvironment.id}` : '';
const response = await fetch(`/api/containers${envParam}`);
if (response.ok) {
const containers: any[] = await response.json();
availableContainers = containers
.map(c => ({
id: c.id,
name: c.name,
image: c.image,
state: c.state
}))
.filter(c => c.name && c.name !== name);
}
} catch (err) {
console.error('Failed to fetch containers:', err);
}
}
// Fetch both on mount so the dropdowns are ready when the user opens them
fetchNetworks();
fetchContainers();
// Container network mode helpers
// `networkModeType` reduces the raw NetworkMode to a logical group for branching:
// bridge | host | none | container | custom
const networkModeType = $derived.by(() => {
if (networkMode.startsWith('container:')) return 'container';
if (['bridge', 'host', 'none'].includes(networkMode)) return networkMode;
return 'custom';
});
// Raw value from networkMode — Docker stores either a name or a 64-char container ID
const containerRefRaw = $derived(networkMode.startsWith('container:') ? networkMode.slice('container:'.length) : '');
// Resolve ID → name when possible so the trigger shows "container:redis" not "container:<sha>"
const containerRef = $derived.by(() => {
if (!containerRefRaw) return '';
const match = availableContainers.find(c => c.id === containerRefRaw || c.id.startsWith(containerRefRaw));
return match ? match.name : containerRefRaw;
});
// Network mode picker (Popover + Command combobox) — flat list of bridge/host/none/Container + custom networks
let networkModePickerOpen = $state(false);
let networkModePickerTriggerRef = $state<HTMLButtonElement>(null!);
function closeAndFocusNetworkModePicker() {
networkModePickerOpen = false;
tick().then(() => networkModePickerTriggerRef?.focus());
}
// Additional networks: custom networks NOT used as the primary mode and NOT already attached
const selectableNetworks = $derived(
availableNetworks.filter(n =>
!selectedNetworks.includes(n.name) &&
!['bridge', 'host', 'none'].includes(n.name) &&
n.name !== networkMode // exclude the primary
)
);
// Custom networks available for the primary mode picker
const customNetworks = $derived(
availableNetworks.filter(n => !['bridge', 'host', 'none'].includes(n.name))
);
// Display label for the current network mode in the trigger
const networkModeLabel = $derived.by(() => {
if (networkModeType === 'bridge') return 'Bridge';
if (networkModeType === 'host') return 'Host';
if (networkModeType === 'none') return 'None';
if (networkModeType === 'container') return containerRef ? `Container: ${containerRef}` : 'Container';
return networkMode; // custom network name
});
// Expanded network config rows
let expandedNetworks = $state<Set<string>>(new Set());
@@ -718,41 +833,121 @@
</div>
<div class="space-y-1.5">
<Label class="text-xs font-medium">Network mode</Label>
<Select.Root type="single" bind:value={networkMode}>
<Select.Trigger id="networkMode" tabindex={0} class="w-full h-9">
<span class="flex items-center">
{#if networkMode === 'bridge'}
<Share2 class="w-3.5 h-3.5 mr-2 text-emerald-500" />
{:else if networkMode === 'host'}
<Server class="w-3.5 h-3.5 mr-2 text-sky-500" />
{:else}
<CircleOff class="w-3.5 h-3.5 mr-2 text-muted-foreground" />
{/if}
{networkMode === 'bridge' ? 'Bridge' : networkMode === 'host' ? 'Host' : 'None'}
</span>
</Select.Trigger>
<Select.Content>
<Select.Item value="bridge">
{#snippet children()}
<Share2 class="w-3.5 h-3.5 mr-2 text-emerald-500" />
Bridge
<Label class="text-xs font-medium">Network</Label>
<Popover.Root bind:open={networkModePickerOpen}>
<Popover.Trigger bind:ref={networkModePickerTriggerRef}>
{#snippet child({ props })}
<Button
{...props}
variant="outline"
class="w-full justify-between font-normal"
role="combobox"
aria-expanded={networkModePickerOpen}
>
<span class="flex items-center min-w-0 flex-1">
{#if networkModeType === 'bridge'}
<Share2 class="w-3.5 h-3.5 mr-2 shrink-0 text-emerald-500" />
{:else if networkModeType === 'host'}
<Server class="w-3.5 h-3.5 mr-2 shrink-0 text-sky-500" />
{:else if networkModeType === 'none'}
<CircleOff class="w-3.5 h-3.5 mr-2 shrink-0 text-muted-foreground" />
{:else if networkModeType === 'container'}
<Box class="w-3.5 h-3.5 mr-2 shrink-0 text-violet-500" />
{:else}
<Network class="w-3.5 h-3.5 mr-2 shrink-0 text-orange-500" />
{/if}
<span class="truncate">{networkModeLabel}</span>
</span>
<ChevronsUpDown class="w-4 h-4 shrink-0 opacity-50" />
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="w-[var(--bits-popover-anchor-width)] p-0" align="start">
<Command.Root>
<Command.Input placeholder="Filter networks..." />
<Command.List class="max-h-64">
<Command.Empty>No networks found.</Command.Empty>
<Command.Group>
<Command.Item value="bridge" onSelect={() => { networkMode = 'bridge'; closeAndFocusNetworkModePicker(); }}>
<Share2 class="text-emerald-500" />
<span>Bridge</span>
</Command.Item>
<Command.Item value="host" onSelect={() => { networkMode = 'host'; closeAndFocusNetworkModePicker(); }}>
<Server class="text-sky-500" />
<span>Host</span>
</Command.Item>
<Command.Item value="none" onSelect={() => { networkMode = 'none'; closeAndFocusNetworkModePicker(); }}>
<CircleOff class="text-muted-foreground" />
<span>None</span>
</Command.Item>
<Command.Item value="container" onSelect={() => { if (!networkMode.startsWith('container:')) networkMode = 'container:'; closeAndFocusNetworkModePicker(); }}>
<Box class="text-violet-500" />
<span>Container</span>
</Command.Item>
</Command.Group>
{#if customNetworks.length > 0}
<Command.Separator />
<Command.Group heading="Custom networks">
{#each customNetworks as n (n.name)}
<Command.Item value={n.name} onSelect={() => { networkMode = n.name; closeAndFocusNetworkModePicker(); }}>
<Network class="text-orange-500" />
<span class="font-medium">{n.name}</span>
<span class="{getDriverBadgeClasses(n.driver)} ml-auto">{n.driver}</span>
</Command.Item>
{/each}
</Command.Group>
{/if}
</Command.List>
</Command.Root>
</Popover.Content>
</Popover.Root>
{#if networkModeType === 'container'}
<Popover.Root bind:open={containerPickerOpen}>
<Popover.Trigger bind:ref={containerPickerTriggerRef}>
{#snippet child({ props })}
<Button
{...props}
variant="outline"
class="w-full mt-2 justify-between font-normal"
role="combobox"
aria-expanded={containerPickerOpen}
>
<span class="truncate min-w-0 flex-1 text-left {containerRef ? '' : 'text-muted-foreground'}">
{containerRef || 'Select a container...'}
</span>
<ChevronsUpDown class="w-4 h-4 shrink-0 opacity-50" />
</Button>
{/snippet}
</Select.Item>
<Select.Item value="host">
{#snippet children()}
<Server class="w-3.5 h-3.5 mr-2 text-sky-500" />
Host
{/snippet}
</Select.Item>
<Select.Item value="none">
{#snippet children()}
<CircleOff class="w-3.5 h-3.5 mr-2 text-muted-foreground" />
None
{/snippet}
</Select.Item>
</Select.Content>
</Select.Root>
</Popover.Trigger>
<Popover.Content class="w-[var(--bits-popover-anchor-width)] p-0" align="start">
<Command.Root>
<Command.Input placeholder="Filter by name..." />
<Command.List class="max-h-64">
<Command.Empty>No containers found.</Command.Empty>
<Command.Group>
{#each availableContainers as c (c.id)}
<Command.Item
value={c.name}
onSelect={() => {
networkMode = `container:${c.name}`;
closeAndFocusContainerPicker();
}}
>
<Check class={containerRef !== c.name ? 'text-transparent' : ''} />
<span class="w-1.5 h-1.5 shrink-0 rounded-full {c.state === 'running' ? 'bg-green-500' : 'bg-muted-foreground/40'}"></span>
<span class="font-medium">{c.name}</span>
<span class="text-muted-foreground text-xs ml-auto truncate">{c.image}</span>
</Command.Item>
{/each}
</Command.Group>
</Command.List>
</Command.Root>
</Popover.Content>
</Popover.Root>
{#if !containerRef}
<p class="text-xs text-amber-600 mt-1">Select a container to share its network namespace</p>
{/if}
{/if}
</div>
</div>
@@ -767,38 +962,65 @@
</div>
</div>
<!-- Networks -->
{#if availableNetworks.length > 0}
<!-- Additional networks (hidden for host/none/container:X modes — Docker rejects extras) -->
{#if availableNetworks.length > 0 && networkModeType !== 'host' && networkModeType !== 'none' && networkModeType !== 'container'}
<div class="space-y-2">
<div class="flex justify-between items-center pb-2 border-b">
<div class="flex items-center gap-2">
<Network class="w-4 h-4 text-muted-foreground" />
<h3 class="text-sm font-semibold text-foreground">Networks</h3>
<h3 class="text-sm font-semibold text-foreground">Additional networks</h3>
</div>
</div>
<div class="space-y-2">
<Select.Root type="single" value="" onValueChange={addNetwork}>
<Select.Trigger tabindex={0} class="w-full h-9">
<span class="text-muted-foreground">Select network to add...</span>
</Select.Trigger>
<Select.Content>
{#each availableNetworks.filter(n => !selectedNetworks.includes(n.name) && !['bridge', 'host', 'none'].includes(n.name)) as network}
<Select.Item value={network.name}>
{#snippet children()}
<div class="flex items-center justify-between w-full">
<span>{network.name}</span>
<span class={getDriverBadgeClasses(network.driver)}>{network.driver}</span>
</div>
{/snippet}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
{#if selectableNetworks.length === 0}
<Button variant="outline" disabled class="w-full justify-start font-normal text-muted-foreground">
All networks already attached
</Button>
{:else}
<Popover.Root bind:open={networkPickerOpen}>
<Popover.Trigger bind:ref={networkPickerTriggerRef}>
{#snippet child({ props })}
<Button
{...props}
variant="outline"
class="w-full justify-between font-normal"
role="combobox"
aria-expanded={networkPickerOpen}
>
<span class="text-muted-foreground">Select network to add...</span>
<ChevronsUpDown class="w-4 h-4 opacity-50" />
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="w-[var(--bits-popover-anchor-width)] p-0" align="start">
<Command.Root>
<Command.Input placeholder="Filter networks..." />
<Command.List class="max-h-64">
<Command.Empty>No networks found.</Command.Empty>
<Command.Group>
{#each selectableNetworks as network (network.name)}
<Command.Item
value={network.name}
onSelect={() => {
addNetwork(network.name);
closeAndFocusNetworkPicker();
}}
>
<span class="font-medium">{network.name}</span>
<span class="{getDriverBadgeClasses(network.driver)} ml-auto">{network.driver}</span>
</Command.Item>
{/each}
</Command.Group>
</Command.List>
</Command.Root>
</Popover.Content>
</Popover.Root>
{/if}
{#if selectedNetworks.length > 0}
{#if selectedNetworks.filter(n => n !== networkMode).length > 0}
<div class="space-y-1 pt-1">
{#each selectedNetworks as networkName}
{#each selectedNetworks.filter(n => n !== networkMode) as networkName}
{@const network = availableNetworks.find(n => n.name === networkName)}
{@const isExpanded = expandedNetworks.has(networkName)}
<div class="border rounded-md">
@@ -106,12 +106,6 @@
let labels = $state<{ key: string; value: string }[]>([{ key: '', value: '' }]);
// Networks
interface DockerNetwork {
id: string;
name: string;
driver: string;
}
let availableNetworks = $state<DockerNetwork[]>([]);
let selectedNetworks = $state<string[]>([]);
let networkConfigs = $state<Record<string, { ipv4Address: string; ipv6Address: string; aliases: string }>>({});
let macAddress = $state('');
@@ -192,7 +186,6 @@
$effect(() => {
if (open) {
fetchConfigSets();
fetchNetworks();
fetchScannerSettings();
}
});
@@ -281,18 +274,6 @@
scanStatus = status;
}
async function fetchNetworks() {
try {
const envParam = $currentEnvironment ? `?env=${$currentEnvironment.id}` : '';
const response = await fetch(`/api/networks${envParam}`);
if (response.ok) {
availableNetworks = await response.json();
}
} catch (err) {
console.error('Failed to fetch networks:', err);
}
}
async function fetchConfigSets() {
try {
const response = await fetch('/api/config-sets');
@@ -457,7 +438,11 @@
restartPolicy,
restartMaxRetries: restartPolicy === 'on-failure' && restartMaxRetries !== '' ? Number(restartMaxRetries) : undefined,
networkMode,
networks: selectedNetworks.length > 0 ? selectedNetworks : undefined,
additionalNetworks: (() => {
const isSharedMode = networkMode === 'host' || networkMode === 'none' || networkMode.startsWith('container:');
if (isSharedMode) return undefined;
return selectedNetworks.length > 0 ? selectedNetworks : undefined;
})(),
networkConfigs: Object.keys(netConfigs).length > 0 ? netConfigs : undefined,
macAddress: macAddress.trim() || undefined,
startAfterCreate,
@@ -740,7 +725,6 @@
bind:volumeMappings
bind:envVars
bind:labels
{availableNetworks}
bind:selectedNetworks
bind:networkConfigs
bind:macAddress
+41 -49
View File
@@ -108,12 +108,6 @@
let labels = $state<{ key: string; value: string }[]>([{ key: '', value: '' }]);
// Networks
interface DockerNetwork {
id: string;
name: string;
driver: string;
}
let availableNetworks = $state<DockerNetwork[]>([]);
let selectedNetworks = $state<string[]>([]);
let networkConfigs = $state<Record<string, { ipv4Address: string; ipv6Address: string; aliases: string }>>({});
let macAddress = $state('');
@@ -246,18 +240,6 @@
let editTitleName = $state('');
let renamingTitle = $state(false);
async function fetchNetworks() {
try {
const envParam = currentEnvId ? `?env=${currentEnvId}` : '';
const response = await fetch(`/api/networks${envParam}`);
if (response.ok) {
availableNetworks = await response.json();
}
} catch (err) {
console.error('Failed to fetch networks:', err);
}
}
// Inline title rename functions
let titleInputRef: HTMLInputElement | null = null;
@@ -358,13 +340,13 @@
restartPolicy = data.HostConfig.RestartPolicy?.Name || 'no';
restartMaxRetries = data.HostConfig.RestartPolicy?.MaximumRetryCount ?? '';
// Normalize network mode
// 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';
if (['bridge', 'host', 'none', 'default'].includes(rawNetworkMode)) {
networkMode = rawNetworkMode === 'default' ? 'bridge' : rawNetworkMode;
} else {
networkMode = 'bridge';
}
networkMode = rawNetworkMode === 'default' ? 'bridge' : rawNetworkMode;
// Parse port mappings (include HostIp if present)
const ports = data.HostConfig.PortBindings || {};
@@ -424,9 +406,10 @@
composeStackName = '';
}
// Parse connected networks
// 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);
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 }> = {};
@@ -563,8 +546,7 @@
runtime = (data.HostConfig?.Runtime && data.HostConfig.Runtime !== 'runc')
? data.HostConfig.Runtime : '';
// Fetch available networks and auto-update settings
await fetchNetworks();
// Fetch auto-update settings
await fetchAutoUpdateSettings(name);
// Store original config for change detection
@@ -944,36 +926,47 @@
image: image.trim(),
ports: Object.keys(ports).length > 0 ? ports : null,
volumeBinds: volumeBinds.length > 0 ? volumeBinds : null,
env: env.length > 0 ? env : undefined,
// 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) : undefined,
restartMaxRetries: restartPolicy === 'on-failure' && restartMaxRetries !== '' ? Number(restartMaxRetries) : null,
networkMode,
networks: selectedNetworks.length > 0 ? selectedNetworks : undefined,
networkConfigs: Object.keys(netConfigs).length > 0 ? netConfigs : undefined,
macAddress: macAddress.trim() || undefined,
// 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),
memoryReservation: parseMemory(memoryReservation),
cpuShares: cpuShares ? parseInt(cpuShares) : undefined,
nanoCpus: parseNanoCpus(nanoCpus),
cpuQuota: cpuQuota ? parseInt(cpuQuota) : undefined,
cpuPeriod: cpuPeriod ? parseInt(cpuPeriod) : undefined,
capAdd: capAdd.length > 0 ? capAdd : undefined,
capDrop: capDrop.length > 0 ? capDrop : undefined,
devices: devices.length > 0 ? devices : undefined,
deviceRequests,
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 : undefined,
dnsSearch: dnsSearch.length > 0 ? dnsSearch : undefined,
dnsOptions: dnsOptions.length > 0 ? dnsOptions : undefined,
securityOpt: securityOptions.length > 0 ? securityOptions : undefined,
ulimits: ulimitsArray.length > 0 ? ulimitsArray : undefined
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), {
@@ -1129,7 +1122,6 @@
bind:volumeMappings
bind:envVars
bind:labels
{availableNetworks}
bind:selectedNetworks
bind:networkConfigs
bind:macAddress