1.0.32
This commit is contained in:
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 & 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 & 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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user