mirror of
https://github.com/khoaliber/dockhand.git
synced 2026-03-05 05:39:04 +00:00
722 lines
24 KiB
Svelte
722 lines
24 KiB
Svelte
<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}
|
|
/>
|