mirror of
https://github.com/khoaliber/dockhand.git
synced 2026-03-09 05:39:05 +00:00
Initial commit
This commit is contained in:
721
routes/networks/+page.svelte
Normal file
721
routes/networks/+page.svelte
Normal file
@@ -0,0 +1,721 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import MultiSelectFilter from '$lib/components/MultiSelectFilter.svelte';
|
||||
import { Trash2, Search, Plus, Eye, Check, XCircle, RefreshCw, Icon, AlertTriangle, X, Network, Link, Copy, CopyPlus, Share2, Server, Globe, MonitorSmartphone, Cpu, CircleOff } from 'lucide-svelte';
|
||||
import { broom } from '@lucide/lab';
|
||||
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
|
||||
import BatchOperationModal from '$lib/components/BatchOperationModal.svelte';
|
||||
import NetworkInspectModal from './NetworkInspectModal.svelte';
|
||||
import ConnectContainerModal from './ConnectContainerModal.svelte';
|
||||
import type { NetworkInfo } from '$lib/types';
|
||||
import { currentEnvironment, environments, appendEnvParam, clearStaleEnvironment } from '$lib/stores/environment';
|
||||
import { onDockerEvent, isNetworkListChange } from '$lib/stores/events';
|
||||
import CreateNetworkModal from './CreateNetworkModal.svelte';
|
||||
import { canAccess } from '$lib/stores/auth';
|
||||
import { EmptyState, NoEnvironment } from '$lib/components/ui/empty-state';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
import { DataGrid } from '$lib/components/data-grid';
|
||||
import { ipToNumber } from '$lib/utils/ip';
|
||||
|
||||
type SortField = 'name' | 'driver' | 'containers' | 'subnet' | 'gateway';
|
||||
type SortDirection = 'asc' | 'desc';
|
||||
|
||||
let networks = $state<NetworkInfo[]>([]);
|
||||
let loading = $state(true);
|
||||
let envId = $state<number | null>(null);
|
||||
|
||||
// Search and sort state - with debounce
|
||||
let searchInput = $state('');
|
||||
let searchQuery = $state('');
|
||||
let sortField = $state<SortField>('name');
|
||||
let sortDirection = $state<SortDirection>('asc');
|
||||
|
||||
// Filter state
|
||||
let selectedDrivers = $state<string[]>([]);
|
||||
let selectedScopes = $state<string[]>([]);
|
||||
|
||||
// Icon and color mappings for drivers
|
||||
const driverIconMap: Record<string, { icon: any; color: string }> = {
|
||||
bridge: { icon: Share2, color: 'text-emerald-500' },
|
||||
host: { icon: Server, color: 'text-sky-500' },
|
||||
overlay: { icon: Globe, color: 'text-violet-500' },
|
||||
macvlan: { icon: MonitorSmartphone, color: 'text-amber-500' },
|
||||
ipvlan: { icon: Cpu, color: 'text-orange-500' },
|
||||
none: { icon: CircleOff, color: 'text-muted-foreground' },
|
||||
null: { icon: CircleOff, color: 'text-muted-foreground' }
|
||||
};
|
||||
|
||||
// Icon and color mappings for scopes
|
||||
const scopeIconMap: Record<string, { icon: any; color: string }> = {
|
||||
local: { icon: Server, color: 'text-sky-500' },
|
||||
swarm: { icon: Globe, color: 'text-violet-500' },
|
||||
global: { icon: Globe, color: 'text-violet-500' }
|
||||
};
|
||||
|
||||
// Available filter options (derived from current networks) - with icons
|
||||
const driverOptions = $derived(
|
||||
[...new Set(networks.map(n => n.driver))].sort().map(d => {
|
||||
const mapping = driverIconMap[d] || { icon: Network, color: 'text-muted-foreground' };
|
||||
return { value: d, label: d, icon: mapping.icon, color: mapping.color };
|
||||
})
|
||||
);
|
||||
const scopeOptions = $derived(
|
||||
[...new Set(networks.map(n => n.scope))].sort().map(s => {
|
||||
const mapping = scopeIconMap[s] || { icon: Network, color: 'text-muted-foreground' };
|
||||
return { value: s, label: s, icon: mapping.icon, color: mapping.color };
|
||||
})
|
||||
);
|
||||
|
||||
// Modal state
|
||||
let showCreateModal = $state(false);
|
||||
let showInspectModal = $state(false);
|
||||
let showConnectModal = $state(false);
|
||||
let inspectNetworkId = $state('');
|
||||
let inspectNetworkName = $state('');
|
||||
let connectNetwork = $state<NetworkInfo | null>(null);
|
||||
|
||||
// Disconnect confirmation state
|
||||
let confirmDisconnectId = $state<string | null>(null);
|
||||
let disconnectingContainerId = $state<string | null>(null);
|
||||
|
||||
// Confirmation popover state
|
||||
let confirmDeleteId = $state<string | null>(null);
|
||||
|
||||
// Operation error state
|
||||
let deleteError = $state<{ id: string; message: string } | null>(null);
|
||||
|
||||
// Timeout tracking for cleanup
|
||||
let pendingTimeouts: ReturnType<typeof setTimeout>[] = [];
|
||||
|
||||
function clearErrorAfterDelay(id: string) {
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (deleteError?.id === id) deleteError = null;
|
||||
}, 5000);
|
||||
pendingTimeouts.push(timeoutId);
|
||||
}
|
||||
|
||||
// Prune state
|
||||
let confirmPrune = $state(false);
|
||||
let pruneStatus = $state<'idle' | 'pruning' | 'success' | 'error'>('idle');
|
||||
|
||||
// Multi-select state
|
||||
let selectedNetworks = $state<Set<string>>(new Set());
|
||||
let confirmBulkRemove = $state(false);
|
||||
|
||||
// Row highlighting state
|
||||
let highlightedRowId = $state<string | null>(null);
|
||||
|
||||
// Batch operation modal state
|
||||
let showBatchOpModal = $state(false);
|
||||
let batchOpTitle = $state('');
|
||||
let batchOpOperation = $state('');
|
||||
let batchOpItems = $state<Array<{ id: string; name: string }>>([]);
|
||||
|
||||
function bulkRemove() {
|
||||
batchOpTitle = `Removing ${selectedInFilter.length} network${selectedInFilter.length !== 1 ? 's' : ''}`;
|
||||
batchOpOperation = 'remove';
|
||||
batchOpItems = selectedInFilter.map(n => ({ id: n.id, name: n.name }));
|
||||
showBatchOpModal = true;
|
||||
}
|
||||
|
||||
function handleBatchComplete() {
|
||||
selectedNetworks = new Set();
|
||||
fetchNetworks();
|
||||
}
|
||||
|
||||
// Debounce search input
|
||||
let searchTimeout: ReturnType<typeof setTimeout>;
|
||||
$effect(() => {
|
||||
const input = searchInput; // Track dependency
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => {
|
||||
searchQuery = input;
|
||||
}, 200);
|
||||
return () => clearTimeout(searchTimeout);
|
||||
});
|
||||
|
||||
// Track if initial fetch has been done
|
||||
let initialFetchDone = $state(false);
|
||||
|
||||
// Subscribe to environment changes using $effect
|
||||
$effect(() => {
|
||||
const env = $currentEnvironment;
|
||||
const newEnvId = env?.id ?? null;
|
||||
|
||||
// Only fetch if environment actually changed or this is initial load
|
||||
if (env && (newEnvId !== envId || !initialFetchDone)) {
|
||||
envId = newEnvId;
|
||||
initialFetchDone = true;
|
||||
fetchNetworks();
|
||||
} else if (!env) {
|
||||
// No environment - clear data and stop loading
|
||||
envId = null;
|
||||
networks = [];
|
||||
loading = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Built-in Docker networks that shouldn't be removed
|
||||
const protectedNetworks = ['bridge', 'host', 'none'];
|
||||
|
||||
// Get subnet from network
|
||||
function getNetworkSubnet(net: NetworkInfo): string | undefined {
|
||||
return net.ipam?.config?.[0]?.subnet;
|
||||
}
|
||||
|
||||
// Get gateway from network
|
||||
function getNetworkGateway(net: NetworkInfo): string | undefined {
|
||||
return net.ipam?.config?.[0]?.gateway;
|
||||
}
|
||||
|
||||
// Filtered and sorted networks - use $derived.by for complex logic
|
||||
const filteredNetworks = $derived.by(() => {
|
||||
let result = networks;
|
||||
|
||||
// Filter by driver
|
||||
if (selectedDrivers.length > 0) {
|
||||
result = result.filter(net => selectedDrivers.includes(net.driver));
|
||||
}
|
||||
|
||||
// Filter by scope
|
||||
if (selectedScopes.length > 0) {
|
||||
result = result.filter(net => selectedScopes.includes(net.scope));
|
||||
}
|
||||
|
||||
// Filter by search query (includes name, driver, and container names)
|
||||
if (searchQuery.trim()) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
result = result.filter(net => {
|
||||
// Search in network name
|
||||
if (net.name.toLowerCase().includes(query)) return true;
|
||||
// Search in driver
|
||||
if (net.driver.toLowerCase().includes(query)) return true;
|
||||
// Search in container names
|
||||
const containerNames = Object.values(net.containers || {}).map(c => c.Name?.toLowerCase() || '');
|
||||
if (containerNames.some(name => name.includes(query))) return true;
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
// Sort
|
||||
result = [...result].sort((a, b) => {
|
||||
let cmp = 0;
|
||||
switch (sortField) {
|
||||
case 'name':
|
||||
cmp = a.name.localeCompare(b.name);
|
||||
break;
|
||||
case 'driver':
|
||||
cmp = a.driver.localeCompare(b.driver);
|
||||
break;
|
||||
case 'containers':
|
||||
cmp = Object.keys(a.containers || {}).length - Object.keys(b.containers || {}).length;
|
||||
break;
|
||||
case 'subnet':
|
||||
cmp = ipToNumber(getNetworkSubnet(a)) - ipToNumber(getNetworkSubnet(b));
|
||||
break;
|
||||
case 'gateway':
|
||||
cmp = ipToNumber(getNetworkGateway(a)) - ipToNumber(getNetworkGateway(b));
|
||||
break;
|
||||
}
|
||||
// Secondary sort by name for stability when primary values are equal
|
||||
if (cmp === 0 && sortField !== 'name') {
|
||||
cmp = a.name.localeCompare(b.name);
|
||||
}
|
||||
return sortDirection === 'asc' ? cmp : -cmp;
|
||||
});
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
// Selection helpers for the selection bar (must be after filteredNetworks)
|
||||
const selectableNetworks = $derived(filteredNetworks.filter(n => !protectedNetworks.includes(n.name)));
|
||||
const selectedInFilter = $derived(
|
||||
selectableNetworks.filter(n => selectedNetworks.has(n.id))
|
||||
);
|
||||
|
||||
function selectNone() {
|
||||
selectedNetworks = new Set();
|
||||
}
|
||||
|
||||
async function fetchNetworks() {
|
||||
loading = true;
|
||||
try {
|
||||
const response = await fetch(appendEnvParam('/api/networks', envId));
|
||||
if (!response.ok) {
|
||||
// Handle stale environment ID (e.g., after database reset)
|
||||
if (response.status === 404 && envId) {
|
||||
clearStaleEnvironment(envId);
|
||||
environments.refresh();
|
||||
return;
|
||||
}
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
networks = await response.json();
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch networks:', error);
|
||||
toast.error('Failed to load networks');
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeNetwork(id: string, name: string) {
|
||||
deleteError = null;
|
||||
if (protectedNetworks.includes(name)) {
|
||||
deleteError = { id, message: `Cannot remove built-in network "${name}"` };
|
||||
toast.error(`Cannot remove built-in network "${name}"`);
|
||||
clearErrorAfterDelay(id);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await fetch(appendEnvParam(`/api/networks/${id}`, envId), { method: 'DELETE' });
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
deleteError = { id, message: data.details || 'Failed to remove network' };
|
||||
toast.error(`Failed to remove ${name}`);
|
||||
clearErrorAfterDelay(id);
|
||||
return;
|
||||
}
|
||||
toast.success(`Removed ${name}`);
|
||||
await fetchNetworks();
|
||||
} catch (error) {
|
||||
console.error('Failed to remove network:', error);
|
||||
deleteError = { id, message: 'Failed to remove network' };
|
||||
toast.error(`Failed to remove ${name}`);
|
||||
clearErrorAfterDelay(id);
|
||||
}
|
||||
}
|
||||
|
||||
function getSubnet(network: NetworkInfo): string {
|
||||
const config = network.ipam?.config?.[0];
|
||||
return config?.subnet || '-';
|
||||
}
|
||||
|
||||
function getGateway(network: NetworkInfo): string {
|
||||
const config = network.ipam?.config?.[0];
|
||||
return config?.gateway || '-';
|
||||
}
|
||||
|
||||
function getContainerCount(network: NetworkInfo): number {
|
||||
return Object.keys(network.containers || {}).length;
|
||||
}
|
||||
|
||||
function getDriverClasses(driver: string): string {
|
||||
const base = 'text-xs px-1.5 py-0.5 rounded-sm text-black dark:text-white inline-block w-14 text-center shadow-sm';
|
||||
switch (driver.toLowerCase()) {
|
||||
case 'bridge':
|
||||
return `${base} bg-emerald-200 dark:bg-emerald-800`;
|
||||
case 'host':
|
||||
return `${base} bg-sky-200 dark:bg-sky-800`;
|
||||
case 'null':
|
||||
return `${base} bg-slate-200 dark:bg-slate-700`;
|
||||
case 'overlay':
|
||||
return `${base} bg-violet-200 dark:bg-violet-800`;
|
||||
case 'macvlan':
|
||||
return `${base} bg-amber-200 dark:bg-amber-800`;
|
||||
default:
|
||||
return `${base} bg-slate-200 dark:bg-slate-700`;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSort(field: SortField) {
|
||||
if (sortField === field) {
|
||||
sortDirection = sortDirection === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
sortField = field;
|
||||
sortDirection = field === 'containers' ? 'desc' : 'asc';
|
||||
}
|
||||
}
|
||||
|
||||
function inspectNetwork(network: NetworkInfo) {
|
||||
inspectNetworkId = network.id;
|
||||
inspectNetworkName = network.name;
|
||||
showInspectModal = true;
|
||||
}
|
||||
|
||||
function openConnectModal(network: NetworkInfo) {
|
||||
connectNetwork = network;
|
||||
showConnectModal = true;
|
||||
}
|
||||
|
||||
async function disconnectContainer(networkId: string, networkName: string, containerId: string, containerName: string) {
|
||||
disconnectingContainerId = containerId;
|
||||
try {
|
||||
const response = await fetch(appendEnvParam(`/api/networks/${networkId}/disconnect`, envId), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ containerId, containerName })
|
||||
});
|
||||
if (response.ok) {
|
||||
toast.success(`Disconnected ${containerName} from ${networkName}`);
|
||||
await fetchNetworks();
|
||||
} else {
|
||||
const data = await response.json();
|
||||
toast.error(data.details || 'Failed to disconnect container');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to disconnect container:', error);
|
||||
toast.error('Failed to disconnect container');
|
||||
} finally {
|
||||
disconnectingContainerId = null;
|
||||
confirmDisconnectId = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function copyNetworkId(id: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(id);
|
||||
toast.success('Network ID copied to clipboard');
|
||||
} catch {
|
||||
toast.error('Failed to copy ID');
|
||||
}
|
||||
}
|
||||
|
||||
async function duplicateNetwork(network: NetworkInfo) {
|
||||
try {
|
||||
const newName = `${network.name}-copy`;
|
||||
const body: any = {
|
||||
name: newName,
|
||||
driver: network.driver,
|
||||
internal: network.internal,
|
||||
attachable: true,
|
||||
options: network.options || {}
|
||||
};
|
||||
|
||||
// Copy IPAM config if available (but not subnet/gateway to avoid conflicts)
|
||||
if (network.ipam?.driver && network.ipam.driver !== 'default') {
|
||||
body.ipam = {
|
||||
driver: network.ipam.driver,
|
||||
options: network.ipam.options || {}
|
||||
};
|
||||
}
|
||||
|
||||
const response = await fetch(appendEnvParam('/api/networks', envId), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
toast.success(`Created ${newName}`);
|
||||
await fetchNetworks();
|
||||
} else {
|
||||
const data = await response.json();
|
||||
toast.error(data.details || 'Failed to duplicate network');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to duplicate network:', error);
|
||||
toast.error('Failed to duplicate network');
|
||||
}
|
||||
}
|
||||
|
||||
async function pruneNetworks() {
|
||||
pruneStatus = 'pruning';
|
||||
confirmPrune = false;
|
||||
try {
|
||||
const response = await fetch(appendEnvParam('/api/prune/networks', envId), {
|
||||
method: 'POST'
|
||||
});
|
||||
if (response.ok) {
|
||||
pruneStatus = 'success';
|
||||
toast.success('Unused networks pruned');
|
||||
await fetchNetworks();
|
||||
} else {
|
||||
pruneStatus = 'error';
|
||||
toast.error('Failed to prune networks');
|
||||
}
|
||||
} catch (error) {
|
||||
pruneStatus = 'error';
|
||||
toast.error('Failed to prune networks');
|
||||
}
|
||||
pendingTimeouts.push(setTimeout(() => {
|
||||
pruneStatus = 'idle';
|
||||
}, 3000));
|
||||
}
|
||||
|
||||
// Handle tab visibility changes (e.g., user switches back from another tab)
|
||||
function handleVisibilityChange() {
|
||||
if (document.visibilityState === 'visible' && envId) {
|
||||
fetchNetworks();
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
// Initial fetch is handled by $effect - no need to duplicate here
|
||||
|
||||
// Listen for tab visibility changes to refresh when user returns
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
document.addEventListener('resume', handleVisibilityChange);
|
||||
|
||||
// Subscribe to network events (SSE connection is global in layout)
|
||||
const unsubscribe = onDockerEvent((event) => {
|
||||
if (envId && isNetworkListChange(event)) {
|
||||
fetchNetworks();
|
||||
}
|
||||
});
|
||||
|
||||
const interval = setInterval(() => {
|
||||
if (envId) fetchNetworks();
|
||||
}, 30000);
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
unsubscribe();
|
||||
};
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
document.removeEventListener('resume', handleVisibilityChange);
|
||||
pendingTimeouts.forEach(id => clearTimeout(id));
|
||||
pendingTimeouts = [];
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex-1 min-h-0 flex flex-col gap-3 overflow-hidden">
|
||||
<div class="shrink-0 flex flex-wrap justify-between items-center gap-3">
|
||||
<PageHeader icon={Network} title="Networks" count={networks.length} />
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<div class="relative">
|
||||
<Search class="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search networks..."
|
||||
bind:value={searchInput}
|
||||
onkeydown={(e) => e.key === 'Escape' && (searchInput = '')}
|
||||
class="pl-8 h-8 w-48 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<!-- Driver filter -->
|
||||
<MultiSelectFilter
|
||||
bind:value={selectedDrivers}
|
||||
options={driverOptions}
|
||||
placeholder="Driver"
|
||||
pluralLabel="drivers"
|
||||
/>
|
||||
<!-- Scope filter -->
|
||||
<MultiSelectFilter
|
||||
bind:value={selectedScopes}
|
||||
options={scopeOptions}
|
||||
placeholder="Scope"
|
||||
pluralLabel="scopes"
|
||||
/>
|
||||
{#if $canAccess('networks', 'remove')}
|
||||
<ConfirmPopover
|
||||
open={confirmPrune}
|
||||
action="Prune"
|
||||
itemType="unused networks"
|
||||
title="Prune networks"
|
||||
position="left"
|
||||
onConfirm={pruneNetworks}
|
||||
onOpenChange={(open) => confirmPrune = open}
|
||||
>
|
||||
{#snippet children({ open })}
|
||||
<span class="inline-flex items-center gap-1.5 h-8 px-3 rounded-md text-sm bg-background shadow-xs border hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 {pruneStatus === 'pruning' ? 'opacity-50 pointer-events-none' : ''}">
|
||||
{#if pruneStatus === 'pruning'}
|
||||
<RefreshCw class="w-3.5 h-3.5 animate-spin" />
|
||||
{:else if pruneStatus === 'success'}
|
||||
<Check class="w-3.5 h-3.5 text-green-600" />
|
||||
{:else if pruneStatus === 'error'}
|
||||
<XCircle class="w-3.5 h-3.5 text-destructive" />
|
||||
{:else}
|
||||
<Icon iconNode={broom} class="w-3.5 h-3.5" />
|
||||
{/if}
|
||||
Prune
|
||||
</span>
|
||||
{/snippet}
|
||||
</ConfirmPopover>
|
||||
{/if}
|
||||
<Button size="sm" variant="outline" onclick={fetchNetworks}>
|
||||
<RefreshCw class="w-3.5 h-3.5 mr-1" />
|
||||
Refresh
|
||||
</Button>
|
||||
{#if $canAccess('networks', 'create')}
|
||||
<Button size="sm" variant="secondary" onclick={() => showCreateModal = true}>
|
||||
<Plus class="w-3.5 h-3.5 mr-1" />
|
||||
Create
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Selection bar -->
|
||||
{#if selectedNetworks.size > 0}
|
||||
<div class="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>{selectedInFilter.length} selected</span>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 px-2.5 py-1 rounded-full border border-border shadow-sm hover:border-foreground/30 hover:shadow transition-all"
|
||||
onclick={selectNone}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
{#if $canAccess('networks', 'remove')}
|
||||
<ConfirmPopover
|
||||
open={confirmBulkRemove}
|
||||
action="Delete"
|
||||
itemType="{selectedInFilter.length} network{selectedInFilter.length !== 1 ? 's' : ''}"
|
||||
title="Delete {selectedInFilter.length}"
|
||||
unstyled
|
||||
onConfirm={bulkRemove}
|
||||
onOpenChange={(open) => confirmBulkRemove = open}
|
||||
>
|
||||
{#snippet children({ open })}
|
||||
<span class="inline-flex items-center gap-1 px-2.5 py-1 rounded-full border border-border shadow-sm hover:text-destructive hover:border-destructive/40 hover:shadow transition-all cursor-pointer">
|
||||
<Trash2 class="w-3 h-3" />
|
||||
Delete
|
||||
</span>
|
||||
{/snippet}
|
||||
</ConfirmPopover>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !loading && ($environments.length === 0 || !$currentEnvironment)}
|
||||
<NoEnvironment />
|
||||
{:else if !loading && networks.length === 0}
|
||||
<EmptyState
|
||||
icon={Network}
|
||||
title="No networks found"
|
||||
description="Create a network to connect containers"
|
||||
/>
|
||||
{:else}
|
||||
<DataGrid
|
||||
data={filteredNetworks}
|
||||
keyField="id"
|
||||
gridId="networks"
|
||||
loading={loading}
|
||||
selectable
|
||||
bind:selectedKeys={selectedNetworks}
|
||||
selectableFilter={(n) => !protectedNetworks.includes(n.name)}
|
||||
sortState={{ field: sortField, direction: sortDirection }}
|
||||
onSortChange={(state) => { sortField = state.field as SortField; sortDirection = state.direction; }}
|
||||
highlightedKey={highlightedRowId}
|
||||
onRowClick={(network) => { highlightedRowId = highlightedRowId === network.id ? null : network.id; }}
|
||||
>
|
||||
{#snippet cell(column, network, rowState)}
|
||||
{@const containerCount = Object.keys(network.containers || {}).length}
|
||||
{@const isProtected = protectedNetworks.includes(network.name)}
|
||||
{#if column.id === 'name'}
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class="text-xs truncate" title={network.name}>{network.name}</span>
|
||||
{#if isProtected}
|
||||
<span class="text-2xs py-0 px-1.5 rounded-sm bg-muted text-muted-foreground shadow-sm shrink-0">built-in</span>
|
||||
{/if}
|
||||
{#if network.internal}
|
||||
<Badge variant="outline" class="text-xs py-0 px-1.5 shrink-0">internal</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if column.id === 'driver'}
|
||||
<span class={getDriverClasses(network.driver)}>{network.driver}</span>
|
||||
{:else if column.id === 'scope'}
|
||||
<span class="text-xs">{network.scope}</span>
|
||||
{:else if column.id === 'subnet'}
|
||||
<code class="text-xs">{getSubnet(network)}</code>
|
||||
{:else if column.id === 'gateway'}
|
||||
<code class="text-xs">{getGateway(network)}</code>
|
||||
{:else if column.id === 'containers'}
|
||||
<span class="text-xs {containerCount > 0 ? '' : 'text-muted-foreground'}">{containerCount}</span>
|
||||
{:else if column.id === 'actions'}
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
{#if deleteError?.id === network.id}
|
||||
<div class="absolute bottom-full right-0 mb-1 z-50 bg-destructive text-destructive-foreground rounded-md shadow-lg p-2 text-xs flex items-start gap-2 max-w-lg w-max">
|
||||
<AlertTriangle class="w-3 h-3 flex-shrink-0 mt-0.5" />
|
||||
<span class="break-words">{deleteError.message}</span>
|
||||
<button onclick={() => deleteError = null} class="flex-shrink-0 hover:bg-white/20 rounded p-0.5">
|
||||
<X class="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if $canAccess('networks', 'inspect')}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => inspectNetwork(network)}
|
||||
title="View details"
|
||||
class="p-1 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
|
||||
>
|
||||
<Eye class="w-3 h-3 text-muted-foreground hover:text-foreground" />
|
||||
</button>
|
||||
{/if}
|
||||
{#if !isProtected && $canAccess('networks', 'connect')}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => openConnectModal(network)}
|
||||
title="Connect container"
|
||||
class="p-1 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
|
||||
>
|
||||
<Link class="w-3 h-3 text-muted-foreground hover:text-green-600" />
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => copyNetworkId(network.id)}
|
||||
title="Copy network ID"
|
||||
class="p-1 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
|
||||
>
|
||||
<Copy class="w-3 h-3 text-muted-foreground hover:text-foreground" />
|
||||
</button>
|
||||
{#if !isProtected && $canAccess('networks', 'create')}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => duplicateNetwork(network)}
|
||||
title="Duplicate network"
|
||||
class="p-1 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
|
||||
>
|
||||
<CopyPlus class="w-3 h-3 text-muted-foreground hover:text-foreground" />
|
||||
</button>
|
||||
{/if}
|
||||
{#if !isProtected && $canAccess('networks', 'remove')}
|
||||
<ConfirmPopover
|
||||
open={confirmDeleteId === network.id}
|
||||
action="Delete"
|
||||
itemType="network"
|
||||
itemName={network.name}
|
||||
title="Remove"
|
||||
onConfirm={() => removeNetwork(network.id, network.name)}
|
||||
onOpenChange={(open) => confirmDeleteId = open ? network.id : null}
|
||||
>
|
||||
{#snippet children({ open })}
|
||||
<Trash2 class="w-3 h-3 {open ? 'text-destructive' : 'text-muted-foreground hover:text-destructive'}" />
|
||||
{/snippet}
|
||||
</ConfirmPopover>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</DataGrid>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<CreateNetworkModal
|
||||
bind:open={showCreateModal}
|
||||
onClose={() => showCreateModal = false}
|
||||
onSuccess={fetchNetworks}
|
||||
/>
|
||||
|
||||
<NetworkInspectModal
|
||||
bind:open={showInspectModal}
|
||||
networkId={inspectNetworkId}
|
||||
networkName={inspectNetworkName}
|
||||
/>
|
||||
|
||||
<ConnectContainerModal
|
||||
bind:open={showConnectModal}
|
||||
network={connectNetwork}
|
||||
{envId}
|
||||
onSuccess={fetchNetworks}
|
||||
/>
|
||||
|
||||
<BatchOperationModal
|
||||
bind:open={showBatchOpModal}
|
||||
title={batchOpTitle}
|
||||
operation={batchOpOperation}
|
||||
entityType="networks"
|
||||
items={batchOpItems}
|
||||
envId={envId ?? undefined}
|
||||
onClose={() => showBatchOpModal = false}
|
||||
onComplete={handleBatchComplete}
|
||||
/>
|
||||
172
routes/networks/ConnectContainerModal.svelte
Normal file
172
routes/networks/ConnectContainerModal.svelte
Normal file
@@ -0,0 +1,172 @@
|
||||
<script lang="ts">
|
||||
import { toast } from 'svelte-sonner';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import { appendEnvParam } from '$lib/stores/environment';
|
||||
import { Link, Loader2, Box } from 'lucide-svelte';
|
||||
import { focusFirstInput } from '$lib/utils';
|
||||
import type { NetworkInfo } from '$lib/types';
|
||||
|
||||
interface Container {
|
||||
id: string;
|
||||
name: string;
|
||||
state: string;
|
||||
networks: { [key: string]: { ipAddress: string } };
|
||||
}
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
network,
|
||||
envId,
|
||||
onSuccess
|
||||
}: {
|
||||
open: boolean;
|
||||
network: NetworkInfo | null;
|
||||
envId: number | null;
|
||||
onSuccess: () => void;
|
||||
} = $props();
|
||||
|
||||
let containers = $state<Container[]>([]);
|
||||
let selectedContainer = $state<string | undefined>(undefined);
|
||||
let loading = $state(false);
|
||||
let submitting = $state(false);
|
||||
|
||||
// Containers not already connected to this network
|
||||
const availableContainers = $derived(
|
||||
containers.filter(c => {
|
||||
if (!network) return true;
|
||||
// Check if container is already connected to this network
|
||||
return !Object.keys(c.networks || {}).includes(network.name);
|
||||
})
|
||||
);
|
||||
|
||||
const selectedContainerInfo = $derived(
|
||||
containers.find(c => c.id === selectedContainer)
|
||||
);
|
||||
|
||||
async function fetchContainers() {
|
||||
loading = true;
|
||||
try {
|
||||
const response = await fetch(appendEnvParam('/api/containers', envId));
|
||||
if (response.ok) {
|
||||
containers = await response.json();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch containers:', error);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleConnect() {
|
||||
if (!selectedContainer || !network) return;
|
||||
|
||||
submitting = true;
|
||||
try {
|
||||
const response = await fetch(appendEnvParam(`/api/networks/${network.id}/connect`, envId), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
containerId: selectedContainer,
|
||||
containerName: selectedContainerInfo?.name
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
toast.success(`Connected ${selectedContainerInfo?.name || 'container'} to ${network.name}`);
|
||||
open = false;
|
||||
selectedContainer = undefined;
|
||||
onSuccess();
|
||||
} else {
|
||||
const data = await response.json();
|
||||
toast.error(data.details || 'Failed to connect container');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to connect container:', error);
|
||||
toast.error('Failed to connect container');
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
fetchContainers();
|
||||
selectedContainer = undefined;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open onOpenChange={(isOpen) => isOpen && focusFirstInput()}>
|
||||
<Dialog.Content class="max-w-md">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title class="flex items-center gap-2">
|
||||
<Link class="w-4 h-4" />
|
||||
Connect container to {network?.name}
|
||||
</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
Select a container to connect to this network.
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="space-y-4 py-4">
|
||||
{#if loading}
|
||||
<div class="flex items-center justify-center py-8">
|
||||
<Loader2 class="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
{:else if availableContainers.length === 0}
|
||||
<div class="text-center py-8 text-muted-foreground">
|
||||
<Box class="w-8 h-8 mx-auto mb-2 opacity-50" />
|
||||
<p class="text-sm">No containers available to connect.</p>
|
||||
<p class="text-xs mt-1">All containers are already connected to this network.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-2">
|
||||
<Label for="container">Container</Label>
|
||||
<Select.Root type="single" bind:value={selectedContainer}>
|
||||
<Select.Trigger id="container" class="w-full">
|
||||
{#if selectedContainerInfo}
|
||||
<span class="flex items-center gap-2">
|
||||
<span class="w-2 h-2 rounded-full {selectedContainerInfo.state === 'running' ? 'bg-green-500' : 'bg-gray-400'}"></span>
|
||||
{selectedContainerInfo.name}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-muted-foreground">Select a container...</span>
|
||||
{/if}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each availableContainers as container}
|
||||
<Select.Item value={container.id}>
|
||||
<span class="flex items-center gap-2">
|
||||
<span class="w-2 h-2 rounded-full {container.state === 'running' ? 'bg-green-500' : 'bg-gray-400'}"></span>
|
||||
{container.name}
|
||||
<span class="text-xs text-muted-foreground ml-auto">{container.state}</span>
|
||||
</span>
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button variant="outline" onclick={() => open = false} disabled={submitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onclick={handleConnect}
|
||||
disabled={!selectedContainer || submitting || availableContainers.length === 0}
|
||||
>
|
||||
{#if submitting}
|
||||
<Loader2 class="w-4 h-4 mr-2 animate-spin" />
|
||||
{:else}
|
||||
<Link class="w-4 h-4 mr-2" />
|
||||
{/if}
|
||||
Connect
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
633
routes/networks/CreateNetworkModal.svelte
Normal file
633
routes/networks/CreateNetworkModal.svelte
Normal file
@@ -0,0 +1,633 @@
|
||||
<script lang="ts" module>
|
||||
// Static data moved outside component to prevent recreation on each mount
|
||||
const NETWORK_DRIVERS = [
|
||||
{ value: 'bridge', label: 'Bridge', description: 'Default network driver' },
|
||||
{ value: 'host', label: 'Host', description: 'Use host networking directly' },
|
||||
{ value: 'overlay', label: 'Overlay', description: 'Swarm multi-host networking' },
|
||||
{ value: 'macvlan', label: 'Macvlan', description: 'Assign MAC address to containers' },
|
||||
{ value: 'ipvlan', label: 'IPvlan', description: 'IPvlan L2/L3 networking' },
|
||||
{ value: 'none', label: 'None', description: 'Disable networking' }
|
||||
] as const;
|
||||
|
||||
const COMMON_DRIVER_OPTIONS: Record<string, { key: string; description: string }[]> = {
|
||||
bridge: [
|
||||
{ key: 'com.docker.network.bridge.name', description: 'Bridge device name' },
|
||||
{ key: 'com.docker.network.bridge.enable_ip_masquerade', description: 'Enable IP masquerading (true/false)' },
|
||||
{ key: 'com.docker.network.bridge.enable_icc', description: 'Enable inter-container communication (true/false)' },
|
||||
{ key: 'com.docker.network.bridge.host_binding_ipv4', description: 'Host binding IPv4 address' },
|
||||
{ key: 'com.docker.network.driver.mtu', description: 'MTU size' }
|
||||
],
|
||||
macvlan: [
|
||||
{ key: 'parent', description: 'Parent interface (e.g., eth0)' },
|
||||
{ key: 'macvlan_mode', description: 'Mode: bridge, private, vepa, passthru' }
|
||||
],
|
||||
ipvlan: [
|
||||
{ key: 'parent', description: 'Parent interface (e.g., eth0)' },
|
||||
{ key: 'ipvlan_mode', description: 'Mode: l2, l3, l3s' },
|
||||
{ key: 'ipvlan_flag', description: 'Flag: bridge, private, vepa' }
|
||||
],
|
||||
overlay: [
|
||||
{ key: 'encrypted', description: 'Enable encryption (true/false)' }
|
||||
]
|
||||
};
|
||||
|
||||
type KeyValue = { key: string; value: string };
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import * as Tabs from '$lib/components/ui/tabs';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { TogglePill } from '$lib/components/ui/toggle-pill';
|
||||
import { Plus, Trash2, Network, Settings, Tag, Layers, MonitorSmartphone, Share2, Cpu, Server, CircleOff, Globe, TriangleAlert } from 'lucide-svelte';
|
||||
import * as Alert from '$lib/components/ui/alert';
|
||||
import { currentEnvironment, appendEnvParam } from '$lib/stores/environment';
|
||||
import { focusFirstInput } from '$lib/utils';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose?: () => void;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
let { open = $bindable(), onClose, onSuccess }: Props = $props();
|
||||
|
||||
// Form state
|
||||
let name = $state('');
|
||||
let driver = $state('bridge');
|
||||
let internal = $state(false);
|
||||
let attachable = $state(true);
|
||||
let enableIPv6 = $state(false);
|
||||
|
||||
// IPAM configuration
|
||||
let ipamDriver = $state('default');
|
||||
let subnet = $state('');
|
||||
let gateway = $state('');
|
||||
let ipRange = $state('');
|
||||
let auxAddresses = $state<KeyValue[]>([]);
|
||||
|
||||
// Driver options
|
||||
let driverOptions = $state<KeyValue[]>([]);
|
||||
|
||||
// IPAM options
|
||||
let ipamOptions = $state<KeyValue[]>([]);
|
||||
|
||||
// Macvlan/IPvlan quick config
|
||||
let parentInterface = $state('');
|
||||
let macvlanMode = $state('bridge');
|
||||
let ipvlanMode = $state('l2');
|
||||
|
||||
// Check if driver requires special config
|
||||
let needsParentConfig = $derived(driver === 'macvlan' || driver === 'ipvlan');
|
||||
|
||||
// Labels
|
||||
let labels = $state<KeyValue[]>([]);
|
||||
|
||||
let creating = $state(false);
|
||||
let error = $state('');
|
||||
let errors = $state<{ name?: string; parentInterface?: string; subnet?: string }>({});
|
||||
|
||||
// Generic list helpers to reduce repetitive code
|
||||
function addItem(list: KeyValue[]): KeyValue[] {
|
||||
return [...list, { key: '', value: '' }];
|
||||
}
|
||||
|
||||
function removeItem(list: KeyValue[], index: number): KeyValue[] {
|
||||
return list.filter((_, i) => i !== index);
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
name = '';
|
||||
driver = 'bridge';
|
||||
internal = false;
|
||||
attachable = true;
|
||||
enableIPv6 = false;
|
||||
ipamDriver = 'default';
|
||||
subnet = '';
|
||||
gateway = '';
|
||||
ipRange = '';
|
||||
auxAddresses = [];
|
||||
driverOptions = [];
|
||||
ipamOptions = [];
|
||||
labels = [];
|
||||
parentInterface = '';
|
||||
macvlanMode = 'bridge';
|
||||
ipvlanMode = 'l2';
|
||||
error = '';
|
||||
errors = {};
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
errors = {};
|
||||
let hasErrors = false;
|
||||
|
||||
if (!name.trim()) {
|
||||
errors.name = 'Network name is required';
|
||||
hasErrors = true;
|
||||
}
|
||||
|
||||
// Validation for macvlan/ipvlan
|
||||
if (needsParentConfig) {
|
||||
if (!parentInterface.trim()) {
|
||||
errors.parentInterface = `Parent interface is required for ${driver} networks`;
|
||||
hasErrors = true;
|
||||
}
|
||||
if (!subnet.trim()) {
|
||||
errors.subnet = `Subnet is required for ${driver} networks`;
|
||||
hasErrors = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasErrors) return;
|
||||
|
||||
creating = true;
|
||||
error = '';
|
||||
|
||||
try {
|
||||
const envId = $currentEnvironment?.id;
|
||||
const payload: Record<string, unknown> = {
|
||||
name: name.trim(),
|
||||
driver,
|
||||
internal,
|
||||
attachable,
|
||||
enableIPv6
|
||||
};
|
||||
|
||||
// Build driver options - start with quick config for macvlan/ipvlan
|
||||
const allDriverOptions: Record<string, string> = {};
|
||||
|
||||
if (driver === 'macvlan' && parentInterface.trim()) {
|
||||
allDriverOptions['parent'] = parentInterface.trim();
|
||||
if (macvlanMode) allDriverOptions['macvlan_mode'] = macvlanMode;
|
||||
} else if (driver === 'ipvlan' && parentInterface.trim()) {
|
||||
allDriverOptions['parent'] = parentInterface.trim();
|
||||
if (ipvlanMode) allDriverOptions['ipvlan_mode'] = ipvlanMode;
|
||||
}
|
||||
|
||||
// Add any additional driver options (optimized single iteration)
|
||||
for (const opt of driverOptions) {
|
||||
if (opt.key.trim()) {
|
||||
allDriverOptions[opt.key] = opt.value;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(allDriverOptions).length > 0) {
|
||||
payload.options = allDriverOptions;
|
||||
}
|
||||
|
||||
// Build labels
|
||||
if (labels.length > 0) {
|
||||
const labelsObj: Record<string, string> = {};
|
||||
for (const l of labels) {
|
||||
if (l.key.trim()) {
|
||||
labelsObj[l.key] = l.value;
|
||||
}
|
||||
}
|
||||
if (Object.keys(labelsObj).length > 0) {
|
||||
payload.labels = labelsObj;
|
||||
}
|
||||
}
|
||||
|
||||
// Build IPAM config
|
||||
if (subnet || gateway || ipRange || auxAddresses.length > 0 || ipamDriver !== 'default' || ipamOptions.length > 0) {
|
||||
const ipamConfig: Record<string, unknown> = {};
|
||||
if (subnet) ipamConfig.subnet = subnet;
|
||||
if (gateway) ipamConfig.gateway = gateway;
|
||||
if (ipRange) ipamConfig.ipRange = ipRange;
|
||||
if (auxAddresses.length > 0) {
|
||||
const auxObj: Record<string, string> = {};
|
||||
for (const a of auxAddresses) {
|
||||
if (a.key.trim()) {
|
||||
auxObj[a.key] = a.value;
|
||||
}
|
||||
}
|
||||
if (Object.keys(auxObj).length > 0) {
|
||||
ipamConfig.auxAddress = auxObj;
|
||||
}
|
||||
}
|
||||
|
||||
const ipamOpts: Record<string, string> = {};
|
||||
for (const opt of ipamOptions) {
|
||||
if (opt.key.trim()) {
|
||||
ipamOpts[opt.key] = opt.value;
|
||||
}
|
||||
}
|
||||
|
||||
payload.ipam = {
|
||||
driver: ipamDriver,
|
||||
config: Object.keys(ipamConfig).length > 0 ? [ipamConfig] : [],
|
||||
options: ipamOpts
|
||||
};
|
||||
}
|
||||
|
||||
const response = await fetch(appendEnvParam('/api/networks', envId), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.details || data.error || 'Failed to create network');
|
||||
}
|
||||
|
||||
resetForm();
|
||||
open = false;
|
||||
onSuccess?.();
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'Failed to create network';
|
||||
} finally {
|
||||
creating = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
resetForm();
|
||||
onClose?.();
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open onOpenChange={(isOpen) => { if (isOpen) focusFirstInput(); else handleClose(); }}>
|
||||
<Dialog.Content class="max-w-3xl max-h-[90vh] overflow-y-auto">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title class="flex items-center gap-2">
|
||||
<Network class="w-5 h-5" />
|
||||
Create network
|
||||
</Dialog.Title>
|
||||
<Dialog.Description>Configure a new Docker network with custom settings.</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<Tabs.Root value="basic" class="mt-4">
|
||||
<Tabs.List class="grid w-full grid-cols-4">
|
||||
<Tabs.Trigger value="basic" class="flex items-center gap-1.5 text-xs">
|
||||
<Network class="w-3.5 h-3.5" />Basic
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="ipam" class="flex items-center gap-1.5 text-xs">
|
||||
<Settings class="w-3.5 h-3.5" />IPAM
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="options" class="flex items-center gap-1.5 text-xs">
|
||||
<Settings class="w-3.5 h-3.5" />Options
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="labels" class="flex items-center gap-1.5 text-xs">
|
||||
<Tag class="w-3.5 h-3.5" />Labels
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
|
||||
<div class="min-h-[400px] mt-4">
|
||||
<!-- Basic Tab -->
|
||||
<Tabs.Content value="basic" class="space-y-4 h-full overflow-y-auto">
|
||||
<div class="space-y-2">
|
||||
<Label for="name">Network name *</Label>
|
||||
<Input
|
||||
id="name"
|
||||
bind:value={name}
|
||||
placeholder="my-network"
|
||||
class={errors.name ? 'border-destructive focus-visible:ring-destructive' : ''}
|
||||
oninput={() => errors.name = undefined}
|
||||
/>
|
||||
{#if errors.name}
|
||||
<p class="text-xs text-destructive">{errors.name}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="driver">Driver</Label>
|
||||
<Select.Root type="single" bind:value={driver}>
|
||||
<Select.Trigger class="w-full h-9">
|
||||
<span class="flex items-center">
|
||||
{#if driver === 'bridge'}
|
||||
<Share2 class="w-4 h-4 mr-2 text-emerald-500" />
|
||||
{:else if driver === 'host'}
|
||||
<Server class="w-4 h-4 mr-2 text-sky-500" />
|
||||
{:else if driver === 'overlay'}
|
||||
<Globe class="w-4 h-4 mr-2 text-violet-500" />
|
||||
{:else if driver === 'macvlan'}
|
||||
<MonitorSmartphone class="w-4 h-4 mr-2 text-amber-500" />
|
||||
{:else if driver === 'ipvlan'}
|
||||
<Cpu class="w-4 h-4 mr-2 text-orange-500" />
|
||||
{:else}
|
||||
<CircleOff class="w-4 h-4 mr-2 text-muted-foreground" />
|
||||
{/if}
|
||||
{NETWORK_DRIVERS.find(d => d.value === driver)?.label || 'Select driver'}
|
||||
</span>
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each NETWORK_DRIVERS as d}
|
||||
<Select.Item value={d.value} label={d.label}>
|
||||
{#snippet children()}
|
||||
<div class="flex items-center">
|
||||
{#if d.value === 'bridge'}
|
||||
<Share2 class="w-4 h-4 mr-2 text-emerald-500" />
|
||||
{:else if d.value === 'host'}
|
||||
<Server class="w-4 h-4 mr-2 text-sky-500" />
|
||||
{:else if d.value === 'overlay'}
|
||||
<Globe class="w-4 h-4 mr-2 text-violet-500" />
|
||||
{:else if d.value === 'macvlan'}
|
||||
<MonitorSmartphone class="w-4 h-4 mr-2 text-amber-500" />
|
||||
{:else if d.value === 'ipvlan'}
|
||||
<Cpu class="w-4 h-4 mr-2 text-orange-500" />
|
||||
{:else}
|
||||
<CircleOff class="w-4 h-4 mr-2 text-muted-foreground" />
|
||||
{/if}
|
||||
<div class="flex flex-col">
|
||||
<span>{d.label}</span>
|
||||
<span class="text-xs text-muted-foreground">{d.description}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
|
||||
{#if needsParentConfig}
|
||||
<div class="bg-amber-500/10 border border-amber-500/20 rounded-md p-3 space-y-3">
|
||||
<p class="text-xs font-medium text-amber-600 dark:text-amber-400">
|
||||
{driver === 'macvlan' ? 'Macvlan' : 'IPvlan'} configuration (required)
|
||||
</p>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="space-y-1">
|
||||
<Label for="parentInterface" class="text-xs">Parent interface *</Label>
|
||||
<Input
|
||||
id="parentInterface"
|
||||
bind:value={parentInterface}
|
||||
placeholder="eth0"
|
||||
class="h-8 {errors.parentInterface ? 'border-destructive focus-visible:ring-destructive' : ''}"
|
||||
oninput={() => errors.parentInterface = undefined}
|
||||
/>
|
||||
{#if errors.parentInterface}
|
||||
<p class="text-xs text-destructive">{errors.parentInterface}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{#if driver === 'macvlan'}
|
||||
<div class="space-y-1">
|
||||
<Label for="macvlanMode" class="text-xs">Mode</Label>
|
||||
<Select.Root type="single" bind:value={macvlanMode}>
|
||||
<Select.Trigger class="h-8 text-xs">
|
||||
<Layers class="w-3 h-3 mr-1.5 text-muted-foreground" />
|
||||
<span>{macvlanMode === 'bridge' ? 'Bridge (default)' : macvlanMode === 'private' ? 'Private' : macvlanMode === 'vepa' ? 'VEPA' : 'Passthru'}</span>
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Item value="bridge" label="Bridge (default)">
|
||||
<Layers class="w-3 h-3 mr-1.5 text-muted-foreground" />Bridge (default)
|
||||
</Select.Item>
|
||||
<Select.Item value="private" label="Private">
|
||||
<Layers class="w-3 h-3 mr-1.5 text-muted-foreground" />Private
|
||||
</Select.Item>
|
||||
<Select.Item value="vepa" label="VEPA">
|
||||
<Layers class="w-3 h-3 mr-1.5 text-muted-foreground" />VEPA
|
||||
</Select.Item>
|
||||
<Select.Item value="passthru" label="Passthru">
|
||||
<Layers class="w-3 h-3 mr-1.5 text-muted-foreground" />Passthru
|
||||
</Select.Item>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-1">
|
||||
<Label for="ipvlanMode" class="text-xs">Mode</Label>
|
||||
<Select.Root type="single" bind:value={ipvlanMode}>
|
||||
<Select.Trigger class="h-8 text-xs">
|
||||
<Share2 class="w-3 h-3 mr-1.5 text-muted-foreground" />
|
||||
<span>{ipvlanMode === 'l2' ? 'L2 (default)' : ipvlanMode === 'l3' ? 'L3' : 'L3S'}</span>
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Item value="l2" label="L2 (default)">
|
||||
<Share2 class="w-3 h-3 mr-1.5 text-muted-foreground" />L2 (default)
|
||||
</Select.Item>
|
||||
<Select.Item value="l3" label="L3">
|
||||
<Share2 class="w-3 h-3 mr-1.5 text-muted-foreground" />L3
|
||||
</Select.Item>
|
||||
<Select.Item value="l3s" label="L3S">
|
||||
<Share2 class="w-3 h-3 mr-1.5 text-muted-foreground" />L3S
|
||||
</Select.Item>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="space-y-1">
|
||||
<Label for="subnetQuick" class="text-xs">Subnet *</Label>
|
||||
<Input
|
||||
id="subnetQuick"
|
||||
bind:value={subnet}
|
||||
placeholder="192.168.1.0/24"
|
||||
class="h-8 {errors.subnet ? 'border-destructive focus-visible:ring-destructive' : ''}"
|
||||
oninput={() => errors.subnet = undefined}
|
||||
/>
|
||||
{#if errors.subnet}
|
||||
<p class="text-xs text-destructive">{errors.subnet}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label for="gatewayQuick" class="text-xs">Gateway</Label>
|
||||
<Input id="gatewayQuick" bind:value={gateway} placeholder="192.168.1.1" class="h-8" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="space-y-3 pt-2">
|
||||
<div class="flex items-center gap-3">
|
||||
<TogglePill bind:checked={internal} />
|
||||
<div>
|
||||
<span class="text-sm font-normal">Internal network</span>
|
||||
<span class="text-muted-foreground text-xs block">Restrict external access to this network</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<TogglePill bind:checked={attachable} />
|
||||
<div>
|
||||
<span class="text-sm font-normal">Attachable</span>
|
||||
<span class="text-muted-foreground text-xs block">Allow manual container attachment (overlay networks)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<TogglePill bind:checked={enableIPv6} />
|
||||
<div>
|
||||
<span class="text-sm font-normal">Enable IPv6</span>
|
||||
<span class="text-muted-foreground text-xs block">Enable IPv6 networking</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<!-- IPAM Tab -->
|
||||
<Tabs.Content value="ipam" class="space-y-4 h-full overflow-y-auto">
|
||||
<div class="space-y-2">
|
||||
<Label for="ipamDriver">IPAM driver</Label>
|
||||
<Input id="ipamDriver" bind:value={ipamDriver} placeholder="default" />
|
||||
<p class="text-xs text-muted-foreground">IP Address Management driver (default: default)</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="subnet">Subnet</Label>
|
||||
<Input id="subnet" bind:value={subnet} placeholder="172.20.0.0/16" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="gateway">Gateway</Label>
|
||||
<Input id="gateway" bind:value={gateway} placeholder="172.20.0.1" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="ipRange">IP range</Label>
|
||||
<Input id="ipRange" bind:value={ipRange} placeholder="172.20.10.0/24" />
|
||||
<p class="text-xs text-muted-foreground">Allocate container IPs from a sub-range of the subnet</p>
|
||||
</div>
|
||||
|
||||
<!-- Auxiliary Addresses -->
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<Label>Auxiliary addresses</Label>
|
||||
<Button variant="outline" size="sm" onclick={() => auxAddresses = addItem(auxAddresses)}>
|
||||
<Plus class="w-3 h-3 mr-1" />Add
|
||||
</Button>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">Reserve IP addresses for network devices (e.g., host=192.168.1.1)</p>
|
||||
{#each auxAddresses as aux, i}
|
||||
<div class="flex gap-2 items-center">
|
||||
<div class="flex-1 relative">
|
||||
<span class="absolute -top-2 left-2 text-2xs text-muted-foreground bg-background px-1">Hostname</span>
|
||||
<Input bind:value={aux.key} class="h-9" />
|
||||
</div>
|
||||
<div class="flex-1 relative">
|
||||
<span class="absolute -top-2 left-2 text-2xs text-muted-foreground bg-background px-1">IP</span>
|
||||
<Input bind:value={aux.value} class="h-9" />
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onclick={() => auxAddresses = removeItem(auxAddresses, i)}>
|
||||
<Trash2 class="w-4 h-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- IPAM Options -->
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<Label>IPAM options</Label>
|
||||
<Button variant="outline" size="sm" onclick={() => ipamOptions = addItem(ipamOptions)}>
|
||||
<Plus class="w-3 h-3 mr-1" />Add
|
||||
</Button>
|
||||
</div>
|
||||
{#each ipamOptions as opt, i}
|
||||
<div class="flex gap-2 items-center">
|
||||
<div class="flex-1 relative">
|
||||
<span class="absolute -top-2 left-2 text-2xs text-muted-foreground bg-background px-1">Key</span>
|
||||
<Input bind:value={opt.key} class="h-9" />
|
||||
</div>
|
||||
<div class="flex-1 relative">
|
||||
<span class="absolute -top-2 left-2 text-2xs text-muted-foreground bg-background px-1">Value</span>
|
||||
<Input bind:value={opt.value} class="h-9" />
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onclick={() => ipamOptions = removeItem(ipamOptions, i)}>
|
||||
<Trash2 class="w-4 h-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<!-- Options Tab -->
|
||||
<Tabs.Content value="options" class="space-y-4 h-full overflow-y-auto">
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<Label>Driver options</Label>
|
||||
<Button variant="outline" size="sm" onclick={() => driverOptions = addItem(driverOptions)}>
|
||||
<Plus class="w-3 h-3 mr-1" />Add
|
||||
</Button>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">Set driver-specific options (-o key=value)</p>
|
||||
|
||||
{#if COMMON_DRIVER_OPTIONS[driver]?.length > 0}
|
||||
<div class="bg-muted/50 rounded-md p-3 text-xs space-y-1">
|
||||
<p class="font-medium">Common options for {driver} driver:</p>
|
||||
{#each COMMON_DRIVER_OPTIONS[driver] as opt}
|
||||
<p><code class="bg-muted px-1 rounded">{opt.key}</code> - {opt.description}</p>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#each driverOptions as opt, i}
|
||||
<div class="flex gap-2 items-center">
|
||||
<div class="flex-1 relative">
|
||||
<span class="absolute -top-2 left-2 text-2xs text-muted-foreground bg-background px-1">Key</span>
|
||||
<Input bind:value={opt.key} class="h-9" />
|
||||
</div>
|
||||
<div class="flex-1 relative">
|
||||
<span class="absolute -top-2 left-2 text-2xs text-muted-foreground bg-background px-1">Value</span>
|
||||
<Input bind:value={opt.value} class="h-9" />
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onclick={() => driverOptions = removeItem(driverOptions, i)}>
|
||||
<Trash2 class="w-4 h-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<!-- Labels Tab -->
|
||||
<Tabs.Content value="labels" class="space-y-4 h-full overflow-y-auto">
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<Label>Labels</Label>
|
||||
<Button variant="outline" size="sm" onclick={() => labels = addItem(labels)}>
|
||||
<Plus class="w-3 h-3 mr-1" />Add
|
||||
</Button>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">Set metadata labels on the network</p>
|
||||
|
||||
{#each labels as label, i}
|
||||
<div class="flex gap-2 items-center">
|
||||
<div class="flex-1 relative">
|
||||
<span class="absolute -top-2 left-2 text-2xs text-muted-foreground bg-background px-1">Key</span>
|
||||
<Input bind:value={label.key} class="h-9" />
|
||||
</div>
|
||||
<div class="flex-1 relative">
|
||||
<span class="absolute -top-2 left-2 text-2xs text-muted-foreground bg-background px-1">Value</span>
|
||||
<Input bind:value={label.value} class="h-9" />
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onclick={() => labels = removeItem(labels, i)}>
|
||||
<Trash2 class="w-4 h-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
{/each}
|
||||
{#if labels.length === 0}
|
||||
<p class="text-xs text-muted-foreground italic">No labels configured</p>
|
||||
{/if}
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
</div>
|
||||
</Tabs.Root>
|
||||
|
||||
{#if error}
|
||||
<Alert.Root variant="destructive" class="mt-4">
|
||||
<TriangleAlert class="h-4 w-4" />
|
||||
<Alert.Description>{error}</Alert.Description>
|
||||
</Alert.Root>
|
||||
{/if}
|
||||
|
||||
{#if errors.name || errors.parentInterface || errors.subnet}
|
||||
<Alert.Root variant="destructive" class="mt-4">
|
||||
<TriangleAlert class="h-4 w-4" />
|
||||
<Alert.Description>Please fix the validation errors above</Alert.Description>
|
||||
</Alert.Root>
|
||||
{/if}
|
||||
|
||||
<Dialog.Footer class="mt-6">
|
||||
<Button variant="outline" onclick={handleClose} disabled={creating}>Cancel</Button>
|
||||
<Button onclick={handleSubmit} disabled={creating}>
|
||||
{#if creating}Creating...{:else}Create network{/if}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
220
routes/networks/NetworkInspectModal.svelte
Normal file
220
routes/networks/NetworkInspectModal.svelte
Normal file
@@ -0,0 +1,220 @@
|
||||
<script lang="ts">
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import { Loader2, Network } from 'lucide-svelte';
|
||||
import { currentEnvironment, appendEnvParam } from '$lib/stores/environment';
|
||||
import ContainerTile from '../containers/ContainerTile.svelte';
|
||||
import ContainerInspectModal from '../containers/ContainerInspectModal.svelte';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
networkId: string;
|
||||
networkName?: string;
|
||||
}
|
||||
|
||||
let { open = $bindable(), networkId, networkName }: Props = $props();
|
||||
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
let networkData = $state<any>(null);
|
||||
|
||||
// Container inspect modal state
|
||||
let showContainerInspect = $state(false);
|
||||
let inspectContainerId = $state('');
|
||||
let inspectContainerName = $state('');
|
||||
|
||||
function openContainerInspect(containerId: string, containerName: string) {
|
||||
inspectContainerId = containerId;
|
||||
inspectContainerName = containerName;
|
||||
showContainerInspect = true;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (open && networkId) {
|
||||
fetchNetworkInspect();
|
||||
}
|
||||
});
|
||||
|
||||
async function fetchNetworkInspect() {
|
||||
loading = true;
|
||||
error = '';
|
||||
try {
|
||||
const envId = $currentEnvironment?.id ?? null;
|
||||
const response = await fetch(appendEnvParam(`/api/networks/${networkId}/inspect`, envId));
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch network details');
|
||||
}
|
||||
networkData = await response.json();
|
||||
} catch (err: any) {
|
||||
error = err.message || 'Failed to load network details';
|
||||
console.error('Failed to fetch network inspect:', err);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
if (!dateString) return 'N/A';
|
||||
return new Date(dateString).toLocaleString();
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content class="max-w-4xl h-[90vh] flex flex-col">
|
||||
<Dialog.Header class="shrink-0">
|
||||
<Dialog.Title class="flex items-center gap-2">
|
||||
<Network class="w-5 h-5" />
|
||||
Network details: <span class="text-muted-foreground font-normal">{networkName || networkId.slice(0, 12)}</span>
|
||||
</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="flex-1 overflow-auto space-y-4 min-h-0">
|
||||
{#if loading}
|
||||
<div class="flex items-center justify-center py-8">
|
||||
<Loader2 class="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="text-sm text-red-600 dark:text-red-400 p-3 bg-red-50 dark:bg-red-950 rounded">
|
||||
{error}
|
||||
</div>
|
||||
{:else if networkData}
|
||||
<!-- Basic Info -->
|
||||
<div class="space-y-3">
|
||||
<h3 class="text-sm font-semibold">Basic information</h3>
|
||||
<div class="grid grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<p class="text-muted-foreground">Name</p>
|
||||
<p class="font-medium">{networkData.Name}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-muted-foreground">ID</p>
|
||||
<code class="text-xs">{networkData.Id?.slice(0, 12)}</code>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-muted-foreground">Driver</p>
|
||||
<Badge variant="outline">{networkData.Driver}</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-muted-foreground">Scope</p>
|
||||
<Badge variant="secondary">{networkData.Scope}</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-muted-foreground">Created</p>
|
||||
<p>{formatDate(networkData.Created)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-muted-foreground">Internal</p>
|
||||
<Badge variant={networkData.Internal ? 'destructive' : 'secondary'}>
|
||||
{networkData.Internal ? 'Yes' : 'No'}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- IPAM Configuration -->
|
||||
{#if networkData.IPAM}
|
||||
<div class="space-y-3">
|
||||
<h3 class="text-sm font-semibold">IPAM configuration</h3>
|
||||
<div class="space-y-2">
|
||||
<div class="text-sm">
|
||||
<p class="text-muted-foreground">Driver</p>
|
||||
<p>{networkData.IPAM.Driver || 'default'}</p>
|
||||
</div>
|
||||
{#if networkData.IPAM.Config && networkData.IPAM.Config.length > 0}
|
||||
<div class="space-y-2">
|
||||
<p class="text-muted-foreground text-sm">Subnets</p>
|
||||
{#each networkData.IPAM.Config as config}
|
||||
<div class="p-2 bg-muted rounded text-sm space-y-1">
|
||||
{#if config.Subnet}
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground">Subnet:</span>
|
||||
<code>{config.Subnet}</code>
|
||||
</div>
|
||||
{/if}
|
||||
{#if config.Gateway}
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground">Gateway:</span>
|
||||
<code>{config.Gateway}</code>
|
||||
</div>
|
||||
{/if}
|
||||
{#if config.IPRange}
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground">IP Range:</span>
|
||||
<code>{config.IPRange}</code>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Connected Containers -->
|
||||
{#if networkData.Containers && Object.keys(networkData.Containers).length > 0}
|
||||
<div class="space-y-3">
|
||||
<h3 class="text-sm font-semibold">Connected containers ({Object.keys(networkData.Containers).length})</h3>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
|
||||
{#each Object.entries(networkData.Containers) as [id, container]}
|
||||
<ContainerTile
|
||||
containerId={id}
|
||||
containerName={container.Name}
|
||||
ipv4Address={container.IPv4Address}
|
||||
ipv6Address={container.IPv6Address}
|
||||
macAddress={container.MacAddress}
|
||||
onclick={() => openContainerInspect(id, container.Name)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-sm text-muted-foreground text-center py-4">
|
||||
No containers connected to this network
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Options -->
|
||||
{#if networkData.Options && Object.keys(networkData.Options).length > 0}
|
||||
<div class="space-y-3">
|
||||
<h3 class="text-sm font-semibold">Driver options</h3>
|
||||
<div class="space-y-1">
|
||||
{#each Object.entries(networkData.Options) as [key, value]}
|
||||
<div class="flex justify-between text-sm p-2 bg-muted rounded">
|
||||
<code class="text-muted-foreground">{key}</code>
|
||||
<code>{value}</code>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Labels -->
|
||||
{#if networkData.Labels && Object.keys(networkData.Labels).length > 0}
|
||||
<div class="space-y-3">
|
||||
<h3 class="text-sm font-semibold">Labels</h3>
|
||||
<div class="space-y-1">
|
||||
{#each Object.entries(networkData.Labels) as [key, value]}
|
||||
<div class="flex justify-between text-sm p-2 bg-muted rounded">
|
||||
<code class="text-muted-foreground">{key}</code>
|
||||
<code>{value}</code>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Dialog.Footer class="shrink-0">
|
||||
<Button variant="outline" onclick={() => (open = false)}>Close</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<ContainerInspectModal
|
||||
bind:open={showContainerInspect}
|
||||
containerId={inspectContainerId}
|
||||
containerName={inspectContainerName}
|
||||
/>
|
||||
Reference in New Issue
Block a user