Files
dockhand/routes/stacks/ComposeGraphViewer.svelte
Jarek Krochmalski 62e3c6439e Initial commit
2025-12-28 21:16:03 +01:00

3019 lines
99 KiB
Svelte

<script lang="ts">
import { onMount, onDestroy, untrack } from 'svelte';
import cytoscape from 'cytoscape';
import yaml from 'js-yaml';
import {
Box, Database, Network, HardDrive, Lock, FileText,
ZoomIn, ZoomOut, Maximize2, RotateCcw, Plus, Link, Trash2, X,
ChevronDown, Sun, Moon, Save, LayoutGrid, GitBranch,
Circle, Target, Sparkles, Lightbulb,
Share2, Server, Globe, MonitorSmartphone, Cpu, CircleOff
} from 'lucide-svelte';
import * as Select from '$lib/components/ui/select';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import * as Dialog from '$lib/components/ui/dialog';
interface Props {
composeContent: string;
class?: string;
onContentChange?: (content: string) => void;
}
let { composeContent, class: className = '', onContentChange }: Props = $props();
let containerEl: HTMLDivElement | null = $state(null);
let cy: cytoscape.Core | null = null;
let graphInitialized = $state(false);
let parseError = $state<string | null>(null);
let selectedNode = $state<any>(null);
let selectedEdge = $state<any>(null);
// Theme state
let graphTheme = $state<'light' | 'dark'>('light');
// Layout state
type LayoutType = 'breadthfirst' | 'grid' | 'circle' | 'concentric' | 'cose';
let currentLayout = $state<LayoutType>('breadthfirst');
let showLayoutMenu = $state(false);
const layoutOptions: { value: LayoutType; label: string; icon: string }[] = [
{ value: 'breadthfirst', label: 'Tree', icon: 'tree' },
{ value: 'grid', label: 'Grid', icon: 'grid' },
{ value: 'circle', label: 'Circle', icon: 'circle' },
{ value: 'concentric', label: 'Radial', icon: 'radial' },
{ value: 'cose', label: 'Force', icon: 'force' }
];
// Connection mode state (for service dependencies)
let connectionMode = $state(false);
let connectionSource = $state<string | null>(null);
// Mount mode state (for volume/config/secret to service connections)
let mountMode = $state(false);
let mountSource = $state<{ name: string; type: 'volume' | 'network' | 'config' | 'secret' } | null>(null);
// Add element dialog state
let showAddDialog = $state(false);
let addElementType = $state<'service' | 'network' | 'volume' | 'config' | 'secret'>('service');
let newElementName = $state('');
let newServiceImage = $state('');
let newServicePorts = $state('');
// Add menu dropdown
let showAddMenu = $state(false);
// Store the parsed compose object for modifications
let composeData = $state<ComposeFile | null>(null);
// Service edit state
let editServiceImage = $state('');
let editServiceCommand = $state('');
let editServiceRestart = $state('no');
let editServicePorts = $state<{ host: string; container: string; protocol: string }[]>([]);
let editServiceVolumes = $state<{ host: string; container: string; mode: string }[]>([]);
let editServiceEnvVars = $state<{ key: string; value: string }[]>([]);
let editServiceLabels = $state<{ key: string; value: string }[]>([]);
let editServiceNetworks = $state<string[]>([]);
let serviceEditDirty = $state(false);
// Network edit state
let editNetworkDriver = $state('bridge');
let editNetworkInternal = $state(false);
let editNetworkExternal = $state(false);
let editNetworkAttachable = $state(false);
let editNetworkSubnet = $state('');
let editNetworkGateway = $state('');
let editNetworkLabels = $state<{ key: string; value: string }[]>([]);
let editNetworkOptions = $state<{ key: string; value: string }[]>([]);
let networkEditDirty = $state(false);
// Volume edit state
let editVolumeDriver = $state('local');
let editVolumeExternal = $state(false);
let editVolumeLabels = $state<{ key: string; value: string }[]>([]);
let editVolumeOptions = $state<{ key: string; value: string }[]>([]);
let volumeEditDirty = $state(false);
// Config edit state
let editConfigFile = $state('');
let editConfigContent = $state('');
let editConfigEnvironment = $state('');
let editConfigExternal = $state(false);
let editConfigName = $state('');
let configEditDirty = $state(false);
// Secret edit state
let editSecretFile = $state('');
let editSecretEnvironment = $state('');
let editSecretExternal = $state(false);
let editSecretName = $state('');
let secretEditDirty = $state(false);
interface ComposeFile {
version?: string;
name?: string;
services?: Record<string, any>;
networks?: Record<string, any>;
volumes?: Record<string, any>;
configs?: Record<string, any>;
secrets?: Record<string, any>;
}
function parseCompose(content: string): ComposeFile | null {
try {
const parsed = yaml.load(content) as ComposeFile;
parseError = null;
return parsed;
} catch (e: any) {
parseError = e.message;
// Try to parse partially - extract what we can from the YAML
// by attempting to parse line by line and building a partial result
return tryPartialParse(content, e);
}
}
function tryPartialParse(content: string, originalError: any): ComposeFile | null {
// Try to extract valid YAML sections before the error point
const lines = content.split('\n');
const errorMatch = originalError.message.match(/at line (\d+)/);
const errorLine = errorMatch ? parseInt(errorMatch[1]) : lines.length;
// Try parsing progressively smaller chunks until we find valid YAML
for (let i = errorLine - 1; i > 0; i--) {
const partialContent = lines.slice(0, i).join('\n');
try {
const partial = yaml.load(partialContent) as ComposeFile;
if (partial && (partial.services || partial.networks || partial.volumes)) {
// We found a valid partial - update error message
parseError = `${originalError.message} (showing partial graph)`;
return partial;
}
} catch {
// Continue trying with fewer lines
}
}
// If nothing worked, try to parse with js-yaml's lenient options
try {
const lenient = yaml.load(content, { json: true }) as ComposeFile;
if (lenient && typeof lenient === 'object') {
parseError = `${originalError.message} (showing partial graph)`;
return lenient;
}
} catch {
// Still failed
}
// Return an empty compose file so we at least show an empty graph with the error
return { services: {}, networks: {}, volumes: {} };
}
function generateYaml(compose: ComposeFile): string {
// Clean up empty sections
const cleanCompose: ComposeFile = { ...compose };
if (cleanCompose.services && Object.keys(cleanCompose.services).length === 0) {
delete cleanCompose.services;
}
if (cleanCompose.networks && Object.keys(cleanCompose.networks).length === 0) {
delete cleanCompose.networks;
}
if (cleanCompose.volumes && Object.keys(cleanCompose.volumes).length === 0) {
delete cleanCompose.volumes;
}
if (cleanCompose.configs && Object.keys(cleanCompose.configs).length === 0) {
delete cleanCompose.configs;
}
if (cleanCompose.secrets && Object.keys(cleanCompose.secrets).length === 0) {
delete cleanCompose.secrets;
}
return yaml.dump(cleanCompose, {
indent: 2,
lineWidth: -1,
noRefs: true,
sortKeys: false
});
}
function emitChange() {
if (composeData && onContentChange) {
const newYaml = generateYaml(composeData);
onContentChange(newYaml);
}
}
function buildGraphElements(compose: ComposeFile) {
const elements: cytoscape.ElementDefinition[] = [];
const services = compose.services || {};
const networks = compose.networks || {};
const volumes = compose.volumes || {};
const configs = compose.configs || {};
const secrets = compose.secrets || {};
// Add service nodes
Object.entries(services).forEach(([name, config]) => {
const ports = config.ports || [];
const envVars = config.environment || {};
const envCount = Array.isArray(envVars) ? envVars.length : Object.keys(envVars).length;
const dependsOn = config.depends_on
? (Array.isArray(config.depends_on) ? config.depends_on : Object.keys(config.depends_on))
: [];
const imageStr = config.image || (config.build ? 'build' : 'custom');
// Truncate long image names
const shortImage = imageStr.length > 25 ? imageStr.substring(0, 22) + '...' : imageStr;
elements.push({
data: {
id: `service-${name}`,
label: name,
caption: shortImage,
type: 'service',
image: config.image || config.build || 'custom',
ports: ports.map((p: any) => typeof p === 'string' ? p : `${p.published}:${p.target}`),
envCount,
replicas: config.deploy?.replicas || 1,
restart: config.restart || 'no',
healthcheck: !!config.healthcheck,
dependsOn,
config: config
}
});
// Add depends_on edges (with error detection for missing services)
dependsOn.forEach((dep: string) => {
const depExists = services[dep] !== undefined;
elements.push({
data: {
id: `dep-${name}-${dep}`,
source: `service-${dep}`,
target: `service-${name}`,
type: 'dependency',
label: 'depends on',
isError: !depExists,
missingTarget: !depExists ? dep : undefined
}
});
});
// Add links edges (legacy)
if (config.links) {
config.links.forEach((link: string) => {
const linkName = link.split(':')[0];
elements.push({
data: {
id: `link-${name}-${linkName}`,
source: `service-${linkName}`,
target: `service-${name}`,
type: 'link',
label: 'links'
}
});
});
}
});
// Add ghost nodes for missing dependencies
const missingServices = new Set<string>();
Object.entries(services).forEach(([name, config]) => {
const dependsOn = config.depends_on
? (Array.isArray(config.depends_on) ? config.depends_on : Object.keys(config.depends_on))
: [];
dependsOn.forEach((dep: string) => {
if (!services[dep] && !missingServices.has(dep)) {
missingServices.add(dep);
elements.push({
data: {
id: `service-${dep}`,
label: dep,
caption: 'missing',
type: 'service',
isMissing: true,
image: '',
ports: [],
envCount: 0,
replicas: 0,
restart: 'no',
healthcheck: false,
dependsOn: [],
config: {}
}
});
}
});
});
// Add network nodes
Object.entries(networks).forEach(([name, config]) => {
const driver = (config as any)?.driver || 'bridge';
elements.push({
data: {
id: `network-${name}`,
label: name,
caption: driver,
type: 'network',
driver: driver,
external: (config as any)?.external || false,
config: config
}
});
});
// Connect services to networks
Object.entries(services).forEach(([serviceName, config]) => {
const serviceNetworks = config.networks;
if (serviceNetworks) {
const netNames = Array.isArray(serviceNetworks)
? serviceNetworks
: Object.keys(serviceNetworks);
netNames.forEach((netName: string) => {
if (networks[netName] || netName === 'default') {
const targetId = networks[netName] ? `network-${netName}` : 'network-default';
if (netName === 'default' && !networks['default']) {
const defaultExists = elements.find(e => e.data.id === 'network-default');
if (!defaultExists) {
elements.push({
data: {
id: 'network-default',
label: 'default',
type: 'network',
driver: 'bridge',
external: false
}
});
}
}
elements.push({
data: {
id: `net-${serviceName}-${netName}`,
source: `service-${serviceName}`,
target: targetId,
type: 'network-connection'
}
});
}
});
}
});
// Add volume nodes
Object.entries(volumes).forEach(([name, config]) => {
const driver = (config as any)?.driver || 'local';
elements.push({
data: {
id: `volume-${name}`,
label: name,
caption: driver,
type: 'volume',
driver: driver,
external: (config as any)?.external || false,
config: config
}
});
});
// Connect services to volumes
Object.entries(services).forEach(([serviceName, config]) => {
if (config.volumes) {
config.volumes.forEach((vol: any, idx: number) => {
const volName = typeof vol === 'string' ? vol.split(':')[0] : vol.source;
if (volumes[volName]) {
elements.push({
data: {
id: `vol-${serviceName}-${volName}-${idx}`,
source: `service-${serviceName}`,
target: `volume-${volName}`,
type: 'volume-mount'
}
});
}
});
}
});
// Add config nodes
Object.entries(configs).forEach(([name, config]) => {
elements.push({
data: {
id: `config-${name}`,
label: name,
caption: 'config',
type: 'config',
external: (config as any)?.external || false,
config: config
}
});
});
// Connect services to configs
Object.entries(services).forEach(([serviceName, config]) => {
if (config.configs) {
config.configs.forEach((cfg: any, idx: number) => {
const cfgName = typeof cfg === 'string' ? cfg : cfg.source;
if (configs[cfgName]) {
elements.push({
data: {
id: `cfg-${serviceName}-${cfgName}-${idx}`,
source: `config-${cfgName}`,
target: `service-${serviceName}`,
type: 'config-mount'
}
});
}
});
}
});
// Add secret nodes
Object.entries(secrets).forEach(([name, config]) => {
elements.push({
data: {
id: `secret-${name}`,
label: name,
caption: 'secret',
type: 'secret',
external: (config as any)?.external || false,
config: config
}
});
});
// Connect services to secrets
Object.entries(services).forEach(([serviceName, config]) => {
if (config.secrets) {
config.secrets.forEach((sec: any, idx: number) => {
const secName = typeof sec === 'string' ? sec : sec.source;
if (secrets[secName]) {
elements.push({
data: {
id: `sec-${serviceName}-${secName}-${idx}`,
source: `secret-${secName}`,
target: `service-${serviceName}`,
type: 'secret-mount'
}
});
}
});
}
});
return elements;
}
// SVG icons as data URLs for nodes
function getSvgIcon(type: string, color: string): string {
const icons: Record<string, string> = {
service: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="${color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/></svg>`,
network: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="${color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="16" y="16" width="6" height="6" rx="1"/><rect x="2" y="16" width="6" height="6" rx="1"/><rect x="9" y="2" width="6" height="6" rx="1"/><path d="M5 16v-3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v3"/><path d="M12 12V8"/></svg>`,
volume: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="${color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" x2="2" y1="12" y2="12"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/><line x1="6" x2="6.01" y1="16" y2="16"/><line x1="10" x2="10.01" y1="16" y2="16"/></svg>`,
config: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="${color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M10 9H8"/><path d="M16 13H8"/><path d="M16 17H8"/></svg>`,
secret: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="${color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>`
};
const svg = icons[type] || icons.service;
return `data:image/svg+xml,${encodeURIComponent(svg)}`;
}
function createGraph(useExistingData = false, skipLayout = false) {
if (!containerEl) return;
// Use existing composeData if requested (for local edits), otherwise parse from prop
if (!useExistingData || !composeData) {
composeData = parseCompose(composeContent);
}
// Even if parsing failed, we get at least an empty structure to render
if (!composeData) {
composeData = { services: {}, networks: {}, volumes: {} };
}
const elements = buildGraphElements(composeData);
// If skipping layout, store current positions before destroying
let savedPositions: Map<string, { x: number; y: number }> | null = null;
if (skipLayout && cy) {
savedPositions = new Map();
cy.nodes().forEach((node) => {
const pos = node.position();
savedPositions!.set(node.id(), { x: pos.x, y: pos.y });
});
}
if (cy) {
cy.destroy();
}
// Theme-based colors
const isDark = graphTheme === 'dark';
const colors = {
service: { bg: isDark ? '#3b82f6' : '#dbeafe', border: isDark ? '#2563eb' : '#93c5fd', text: isDark ? '#ffffff' : '#1e3a5f', icon: isDark ? '#ffffff' : '#2563eb' },
network: { bg: isDark ? '#8b5cf6' : '#ede9fe', border: isDark ? '#7c3aed' : '#c4b5fd', text: isDark ? '#ffffff' : '#3b1e5f', icon: isDark ? '#ffffff' : '#7c3aed' },
volume: { bg: isDark ? '#10b981' : '#dcfce7', border: isDark ? '#059669' : '#86efac', text: isDark ? '#ffffff' : '#14532d', icon: isDark ? '#ffffff' : '#059669' },
config: { bg: isDark ? '#f59e0b' : '#fef3c7', border: isDark ? '#d97706' : '#fde68a', text: isDark ? '#1f2937' : '#713f12', icon: isDark ? '#1f2937' : '#d97706' },
secret: { bg: isDark ? '#ef4444' : '#fee2e2', border: isDark ? '#dc2626' : '#fca5a5', text: isDark ? '#ffffff' : '#7f1d1d', icon: isDark ? '#ffffff' : '#dc2626' },
edge: isDark ? '#64748b' : '#94a3b8',
selected: isDark ? '#fbbf24' : '#18181b',
caption: isDark ? 'rgba(255,255,255,0.7)' : 'rgba(0,0,0,0.5)'
};
cy = cytoscape({
container: containerEl,
elements,
style: [
// Service nodes
{
selector: 'node[type="service"]',
style: {
'background-color': colors.service.bg,
'border-color': colors.service.border,
'border-width': 2,
'label': (ele: any) => `${ele.data('label')}\n${ele.data('caption') || ''}`,
'color': colors.service.text,
'text-valign': 'center',
'text-halign': 'center',
'font-size': '10px',
'font-weight': '600',
'width': 150,
'height': 55,
'shape': 'roundrectangle',
'text-wrap': 'wrap',
'text-max-width': '115px',
'text-overflow-wrap': 'anywhere',
'line-height': 1.2,
'background-image': getSvgIcon('service', colors.service.icon),
'background-width': '16px',
'background-height': '16px',
'background-position-x': '8px',
'background-position-y': '50%',
'background-clip': 'none',
'text-margin-x': 10
}
},
// Missing service nodes (ghost nodes for failed dependencies)
{
selector: 'node[type="service"][?isMissing]',
style: {
'background-color': isDark ? '#7f1d1d' : '#fecaca',
'border-color': '#ef4444',
'border-width': 3,
'border-style': 'dashed',
'opacity': 0.85,
'label': (ele: any) => `${ele.data('label')}\nmissing`,
'color': isDark ? '#fca5a5' : '#7f1d1d',
'text-valign': 'center',
'text-halign': 'center',
'font-size': '10px',
'font-weight': '600',
'width': 150,
'height': 55,
'shape': 'roundrectangle',
'text-wrap': 'wrap',
'text-max-width': '115px',
'text-overflow-wrap': 'anywhere',
'line-height': 1.2,
'background-image': getSvgIcon('service', '#ef4444'),
'background-width': '16px',
'background-height': '16px',
'background-position-x': '8px',
'background-position-y': '50%',
'background-clip': 'none',
'text-margin-x': 10
}
},
// Network nodes
{
selector: 'node[type="network"]',
style: {
'background-color': colors.network.bg,
'border-color': colors.network.border,
'border-width': 2,
'label': (ele: any) => `${ele.data('label')}\nnetwork: ${ele.data('caption') || 'bridge'}`,
'color': colors.network.text,
'text-valign': 'center',
'text-halign': 'center',
'font-size': '9px',
'font-weight': '600',
'width': 120,
'height': 46,
'shape': 'roundrectangle',
'text-wrap': 'wrap',
'text-max-width': '90px',
'text-overflow-wrap': 'anywhere',
'line-height': 1.2,
'background-image': getSvgIcon('network', colors.network.icon),
'background-width': '14px',
'background-height': '14px',
'background-position-x': '6px',
'background-position-y': '50%',
'background-clip': 'none',
'text-margin-x': 8
}
},
// Volume nodes
{
selector: 'node[type="volume"]',
style: {
'background-color': colors.volume.bg,
'border-color': colors.volume.border,
'border-width': 2,
'label': (ele: any) => `${ele.data('label')}\nvolume: ${ele.data('caption') || 'local'}`,
'color': colors.volume.text,
'text-valign': 'center',
'text-halign': 'center',
'font-size': '9px',
'font-weight': '600',
'width': 120,
'height': 46,
'shape': 'roundrectangle',
'text-wrap': 'wrap',
'text-max-width': '90px',
'text-overflow-wrap': 'anywhere',
'line-height': 1.2,
'background-image': getSvgIcon('volume', colors.volume.icon),
'background-width': '14px',
'background-height': '14px',
'background-position-x': '6px',
'background-position-y': '50%',
'background-clip': 'none',
'text-margin-x': 8
}
},
// Config nodes
{
selector: 'node[type="config"]',
style: {
'background-color': colors.config.bg,
'border-color': colors.config.border,
'border-width': 2,
'label': (ele: any) => `${ele.data('label')}\nconfig`,
'color': colors.config.text,
'text-valign': 'center',
'text-halign': 'center',
'font-size': '9px',
'font-weight': '600',
'width': 100,
'height': 42,
'shape': 'roundrectangle',
'text-wrap': 'wrap',
'text-max-width': '75px',
'text-overflow-wrap': 'anywhere',
'line-height': 1.2,
'background-image': getSvgIcon('config', colors.config.icon),
'background-width': '14px',
'background-height': '14px',
'background-position-x': '6px',
'background-position-y': '50%',
'background-clip': 'none',
'text-margin-x': 8
}
},
// Secret nodes
{
selector: 'node[type="secret"]',
style: {
'background-color': colors.secret.bg,
'border-color': colors.secret.border,
'border-width': 2,
'label': (ele: any) => `${ele.data('label')}\nsecret`,
'color': colors.secret.text,
'text-valign': 'center',
'text-halign': 'center',
'font-size': '9px',
'font-weight': '600',
'width': 100,
'height': 42,
'shape': 'roundrectangle',
'text-wrap': 'wrap',
'text-max-width': '75px',
'text-overflow-wrap': 'anywhere',
'line-height': 1.2,
'background-image': getSvgIcon('secret', colors.secret.icon),
'background-width': '14px',
'background-height': '14px',
'background-position-x': '6px',
'background-position-y': '50%',
'background-clip': 'none',
'text-margin-x': 8
}
},
// Dependency edges
{
selector: 'edge[type="dependency"]',
style: {
'width': 2,
'line-color': '#94a3b8',
'target-arrow-color': '#94a3b8',
'target-arrow-shape': 'triangle',
'curve-style': 'bezier',
'arrow-scale': 1.2
}
},
// Error dependency edges (missing target service)
{
selector: 'edge[type="dependency"][?isError]',
style: {
'width': 2.5,
'line-color': '#ef4444',
'target-arrow-color': '#ef4444',
'target-arrow-shape': 'triangle',
'curve-style': 'bezier',
'line-style': 'dashed',
'arrow-scale': 1.3
}
},
// Link edges
{
selector: 'edge[type="link"]',
style: {
'width': 2,
'line-color': '#64748b',
'target-arrow-color': '#64748b',
'target-arrow-shape': 'triangle',
'curve-style': 'bezier',
'line-style': 'dashed'
}
},
// Network connection edges
{
selector: 'edge[type="network-connection"]',
style: {
'width': 1.5,
'line-color': '#a78bfa',
'curve-style': 'bezier',
'line-style': 'dotted'
}
},
// Volume mount edges
{
selector: 'edge[type="volume-mount"]',
style: {
'width': 1.5,
'line-color': '#34d399',
'curve-style': 'bezier',
'line-style': 'dotted'
}
},
// Config mount edges
{
selector: 'edge[type="config-mount"]',
style: {
'width': 1.5,
'line-color': '#fbbf24',
'target-arrow-color': '#fbbf24',
'target-arrow-shape': 'triangle',
'curve-style': 'bezier',
'arrow-scale': 0.8
}
},
// Secret mount edges
{
selector: 'edge[type="secret-mount"]',
style: {
'width': 1.5,
'line-color': '#f87171',
'target-arrow-color': '#f87171',
'target-arrow-shape': 'triangle',
'curve-style': 'bezier',
'arrow-scale': 0.8
}
},
// Selected node
{
selector: 'node:selected',
style: {
'border-width': 3,
'border-color': '#18181b',
'overlay-color': '#18181b',
'overlay-padding': 3,
'overlay-opacity': 0.15
}
},
// Selected edge
{
selector: 'edge:selected',
style: {
'width': 3,
'line-color': '#f59e0b',
'target-arrow-color': '#f59e0b'
}
},
// Connection mode - highlight services
{
selector: 'node.connection-source',
style: {
'border-width': 4,
'border-color': '#22c55e',
'overlay-color': '#22c55e',
'overlay-padding': 5,
'overlay-opacity': 0.3
}
},
{
selector: 'node.connection-target',
style: {
'border-color': '#3b82f6',
'border-width': 3,
'overlay-color': '#3b82f6',
'overlay-padding': 3,
'overlay-opacity': 0.2
}
},
// Mount mode - highlight source (volume/network/config/secret)
{
selector: 'node.mount-source',
style: {
'border-width': 4,
'border-color': '#10b981',
'overlay-color': '#10b981',
'overlay-padding': 5,
'overlay-opacity': 0.3
}
},
{
selector: 'node.mount-target',
style: {
'border-color': '#10b981',
'border-width': 3,
'overlay-color': '#10b981',
'overlay-padding': 3,
'overlay-opacity': 0.2
}
}
],
layout: skipLayout && savedPositions ? { name: 'preset' } : {
name: 'breadthfirst',
directed: true,
padding: 50,
spacingFactor: 1.5,
avoidOverlap: true,
nodeDimensionsIncludeLabels: true
},
wheelSensitivity: 0.3,
minZoom: 0.3,
maxZoom: 3
});
// Restore saved positions if skipping layout
if (skipLayout && savedPositions) {
cy.nodes().forEach((node) => {
const savedPos = savedPositions!.get(node.id());
if (savedPos) {
node.position(savedPos);
}
});
}
// Handle node selection
cy.on('tap', 'node', (evt) => {
const nodeData = evt.target.data();
console.log('Node tapped:', nodeData);
if (connectionMode && nodeData.type === 'service') {
handleConnectionClick(nodeData);
} else if (mountMode) {
handleMountClick(nodeData);
} else {
selectedNode = nodeData;
selectedEdge = null;
console.log('selectedNode set to:', selectedNode);
}
});
// Handle edge selection
cy.on('tap', 'edge', (evt) => {
selectedEdge = evt.target.data();
selectedNode = null;
});
cy.on('tap', (evt) => {
if (evt.target === cy) {
selectedNode = null;
selectedEdge = null;
if (connectionMode) {
cancelConnectionMode();
}
if (mountMode) {
cancelMountMode();
}
}
});
graphInitialized = true;
// Ensure the graph renders correctly after container is sized
setTimeout(() => {
if (cy) {
cy.resize();
cy.fit(undefined, 50);
}
}, 100);
}
function handleConnectionClick(nodeData: any) {
if (!cy || !composeData) return;
const serviceName = nodeData.label;
if (!connectionSource) {
// First click - set source
connectionSource = serviceName;
cy.$(`#service-${serviceName}`).addClass('connection-source');
// Highlight potential targets
cy.$('node[type="service"]').forEach((node) => {
if (node.data('label') !== serviceName) {
node.addClass('connection-target');
}
});
} else {
// Second click - create dependency
const targetService = serviceName;
if (connectionSource !== targetService) {
addDependency(connectionSource, targetService);
}
cancelConnectionMode();
}
}
function cancelConnectionMode() {
connectionMode = false;
connectionSource = null;
if (cy) {
cy.$('.connection-source').removeClass('connection-source');
cy.$('.connection-target').removeClass('connection-target');
}
}
function toggleConnectionMode() {
if (connectionMode) {
cancelConnectionMode();
} else {
// Cancel mount mode if active
if (mountMode) cancelMountMode();
connectionMode = true;
selectedNode = null;
selectedEdge = null;
}
}
function handleMountClick(nodeData: any) {
if (!cy || !composeData) return;
const nodeType = nodeData.type;
const nodeName = nodeData.label;
if (!mountSource) {
// First click - must be a volume, network, config, or secret
if (['volume', 'network', 'config', 'secret'].includes(nodeType)) {
mountSource = { name: nodeName, type: nodeType };
cy.$(`#${nodeType}-${nodeName}`).addClass('mount-source');
// Highlight services as potential targets
cy.$('node[type="service"]').forEach((node) => {
node.addClass('mount-target');
});
}
} else {
// Second click - must be a service
if (nodeType === 'service') {
addMount(mountSource.name, mountSource.type, nodeName);
}
cancelMountMode();
}
}
function cancelMountMode() {
mountMode = false;
mountSource = null;
if (cy) {
cy.$('.mount-source').removeClass('mount-source');
cy.$('.mount-target').removeClass('mount-target');
}
}
function toggleMountMode() {
if (mountMode) {
cancelMountMode();
} else {
// Cancel connection mode if active
if (connectionMode) cancelConnectionMode();
mountMode = true;
selectedNode = null;
selectedEdge = null;
}
}
function addMount(sourceName: string, sourceType: 'volume' | 'network' | 'config' | 'secret', targetService: string) {
if (!composeData?.services?.[targetService]) return;
const service = composeData.services[targetService];
if (sourceType === 'volume') {
if (!service.volumes) service.volumes = [];
const mountPath = `/${sourceName}`;
const volumeMount = `${sourceName}:${mountPath}`;
if (!service.volumes.includes(volumeMount) && !service.volumes.some((v: any) =>
typeof v === 'string' ? v.startsWith(`${sourceName}:`) : v.source === sourceName
)) {
service.volumes.push(volumeMount);
}
} else if (sourceType === 'network') {
if (!service.networks) service.networks = [];
if (Array.isArray(service.networks)) {
if (!service.networks.includes(sourceName)) {
service.networks.push(sourceName);
}
} else {
if (!service.networks[sourceName]) {
service.networks[sourceName] = {};
}
}
} else if (sourceType === 'config') {
if (!service.configs) service.configs = [];
if (!service.configs.includes(sourceName) && !service.configs.some((c: any) =>
typeof c === 'string' ? c === sourceName : c.source === sourceName
)) {
service.configs.push(sourceName);
}
} else if (sourceType === 'secret') {
if (!service.secrets) service.secrets = [];
if (!service.secrets.includes(sourceName) && !service.secrets.some((s: any) =>
typeof s === 'string' ? s === sourceName : s.source === sourceName
)) {
service.secrets.push(sourceName);
}
}
composeData = { ...composeData };
emitChange();
createGraph(true, true);
}
function addDependency(sourceService: string, targetService: string) {
if (!composeData?.services) return;
// Add depends_on to target service
if (!composeData.services[targetService]) return;
if (!composeData.services[targetService].depends_on) {
composeData.services[targetService].depends_on = [];
}
const deps = composeData.services[targetService].depends_on;
if (Array.isArray(deps) && !deps.includes(sourceService)) {
deps.push(sourceService);
// Force reactivity by reassigning composeData
composeData = { ...composeData };
emitChange();
createGraph(true, true); // Refresh graph using local data
}
}
function removeDependency(sourceService: string, targetService: string) {
if (!composeData?.services?.[targetService]?.depends_on) return;
const deps = composeData.services[targetService].depends_on;
if (Array.isArray(deps)) {
const idx = deps.indexOf(sourceService);
if (idx > -1) {
deps.splice(idx, 1);
if (deps.length === 0) {
delete composeData.services[targetService].depends_on;
}
// Force reactivity by reassigning composeData
composeData = { ...composeData };
emitChange();
createGraph(true, true);
selectedEdge = null;
}
}
}
function openAddDialog(type: 'service' | 'network' | 'volume' | 'config' | 'secret') {
addElementType = type;
newElementName = '';
newServiceImage = '';
newServicePorts = '';
showAddDialog = true;
showAddMenu = false;
}
function addElement() {
if (!composeData || !newElementName.trim()) return;
const name = newElementName.trim().toLowerCase().replace(/\s+/g, '-');
switch (addElementType) {
case 'service':
if (!composeData.services) composeData.services = {};
composeData.services[name] = {
image: newServiceImage || 'alpine:latest'
};
if (newServicePorts.trim()) {
composeData.services[name].ports = newServicePorts.split(',').map(p => p.trim());
}
break;
case 'network':
if (!composeData.networks) composeData.networks = {};
composeData.networks[name] = {
driver: 'bridge'
};
break;
case 'volume':
if (!composeData.volumes) composeData.volumes = {};
composeData.volumes[name] = {};
break;
case 'config':
if (!composeData.configs) composeData.configs = {};
composeData.configs[name] = {
file: `./${name}.conf`
};
break;
case 'secret':
if (!composeData.secrets) composeData.secrets = {};
composeData.secrets[name] = {
file: `./${name}.secret`
};
break;
}
// Force reactivity by reassigning composeData
composeData = { ...composeData };
showAddDialog = false;
emitChange();
createGraph(true, true);
}
function deleteSelectedNode() {
if (!selectedNode || !composeData) return;
const { type, label } = selectedNode;
switch (type) {
case 'service':
if (composeData.services) {
delete composeData.services[label];
// Also remove this service from other services' depends_on
Object.values(composeData.services).forEach((svc: any) => {
if (svc.depends_on && Array.isArray(svc.depends_on)) {
const idx = svc.depends_on.indexOf(label);
if (idx > -1) svc.depends_on.splice(idx, 1);
if (svc.depends_on.length === 0) delete svc.depends_on;
}
});
}
break;
case 'network':
if (composeData.networks) delete composeData.networks[label];
break;
case 'volume':
if (composeData.volumes) delete composeData.volumes[label];
break;
case 'config':
if (composeData.configs) delete composeData.configs[label];
break;
case 'secret':
if (composeData.secrets) delete composeData.secrets[label];
break;
}
selectedNode = null;
// Force reactivity by reassigning composeData
composeData = { ...composeData };
emitChange();
createGraph(true, true);
}
function deleteSelectedEdge() {
if (!selectedEdge || !composeData) return;
const { type, source, target } = selectedEdge;
if (type === 'dependency') {
// Extract service names from node IDs
const sourceService = source.replace('service-', '');
const targetService = target.replace('service-', '');
removeDependency(sourceService, targetService);
} else if (type === 'network-connection') {
// Remove network from service's networks list
// Edge: source=service-X, target=network-Y
const serviceName = source.replace('service-', '');
const networkName = target.replace('network-', '');
removeNetworkFromService(serviceName, networkName);
} else if (type === 'volume-mount') {
// Remove volume from service's volumes list
// Edge: source=service-X, target=volume-Y
const serviceName = source.replace('service-', '');
const volumeName = target.replace('volume-', '');
removeVolumeFromService(serviceName, volumeName);
} else if (type === 'config-mount') {
// Remove config from service's configs list
// Edge: source=config-X, target=service-Y
const serviceName = target.replace('service-', '');
const configName = source.replace('config-', '');
removeConfigFromService(serviceName, configName);
} else if (type === 'secret-mount') {
// Remove secret from service's secrets list
// Edge: source=secret-X, target=service-Y
const serviceName = target.replace('service-', '');
const secretName = source.replace('secret-', '');
removeSecretFromService(serviceName, secretName);
}
}
function removeNetworkFromService(serviceName: string, networkName: string) {
if (!composeData?.services?.[serviceName]) return;
const service = composeData.services[serviceName];
if (service.networks) {
if (Array.isArray(service.networks)) {
const idx = service.networks.indexOf(networkName);
if (idx > -1) service.networks.splice(idx, 1);
if (service.networks.length === 0) delete service.networks;
} else if (typeof service.networks === 'object') {
delete service.networks[networkName];
if (Object.keys(service.networks).length === 0) delete service.networks;
}
}
composeData = { ...composeData };
emitChange();
createGraph(true, true);
selectedEdge = null;
}
function removeVolumeFromService(serviceName: string, volumeName: string) {
if (!composeData?.services?.[serviceName]) return;
const service = composeData.services[serviceName];
if (service.volumes && Array.isArray(service.volumes)) {
// Volume mounts can be strings like "volumeName:/path" or objects
service.volumes = service.volumes.filter((v: any) => {
if (typeof v === 'string') {
return !v.startsWith(volumeName + ':') && v !== volumeName;
} else if (typeof v === 'object' && v.source) {
return v.source !== volumeName;
}
return true;
});
if (service.volumes.length === 0) delete service.volumes;
}
composeData = { ...composeData };
emitChange();
createGraph(true, true);
selectedEdge = null;
}
function removeConfigFromService(serviceName: string, configName: string) {
if (!composeData?.services?.[serviceName]) return;
const service = composeData.services[serviceName];
if (service.configs && Array.isArray(service.configs)) {
service.configs = service.configs.filter((c: any) => {
if (typeof c === 'string') return c !== configName;
if (typeof c === 'object' && c.source) return c.source !== configName;
return true;
});
if (service.configs.length === 0) delete service.configs;
}
composeData = { ...composeData };
emitChange();
createGraph(true, true);
selectedEdge = null;
}
function removeSecretFromService(serviceName: string, secretName: string) {
if (!composeData?.services?.[serviceName]) return;
const service = composeData.services[serviceName];
if (service.secrets && Array.isArray(service.secrets)) {
service.secrets = service.secrets.filter((s: any) => {
if (typeof s === 'string') return s !== secretName;
if (typeof s === 'object' && s.source) return s.source !== secretName;
return true;
});
if (service.secrets.length === 0) delete service.secrets;
}
composeData = { ...composeData };
emitChange();
createGraph(true, true);
selectedEdge = null;
}
function zoomIn() {
if (cy) cy.zoom(cy.zoom() * 1.2);
}
function zoomOut() {
if (cy) cy.zoom(cy.zoom() / 1.2);
}
function fitToScreen() {
if (cy) cy.fit(undefined, 50);
}
// Exported function to handle container resize
export function resize() {
if (cy && containerEl) {
// Cytoscape caches container dimensions aggressively
// We need to unmount and remount to the container
cy!.unmount();
// Wait for DOM to update
requestAnimationFrame(() => {
if (cy && containerEl) {
cy!.mount(containerEl);
cy!.resize();
cy!.fit(undefined, 50);
}
});
}
}
function getLayoutConfig(layoutName: LayoutType): cytoscape.LayoutOptions {
const baseConfig = {
padding: 50,
avoidOverlap: true,
nodeDimensionsIncludeLabels: true
};
switch (layoutName) {
case 'breadthfirst':
return {
...baseConfig,
name: 'breadthfirst',
directed: true,
spacingFactor: 1.5
};
case 'grid':
return {
...baseConfig,
name: 'grid',
rows: undefined,
cols: undefined
};
case 'circle':
return {
...baseConfig,
name: 'circle',
spacingFactor: 1.2
};
case 'concentric':
return {
...baseConfig,
name: 'concentric',
minNodeSpacing: 50,
concentric: (node: any) => {
// Services at center, resources around
return node.data('type') === 'service' ? 2 : 1;
},
levelWidth: () => 1
};
case 'cose':
return {
...baseConfig,
name: 'cose',
idealEdgeLength: () => 100,
nodeOverlap: 20,
animate: true,
animationDuration: 500
};
default:
return { ...baseConfig, name: layoutName };
}
}
function applyLayout(layoutName: LayoutType) {
if (!cy) return;
currentLayout = layoutName;
showLayoutMenu = false;
cy.layout(getLayoutConfig(layoutName)).run();
cy.fit(undefined, 50);
}
function resetLayout() {
if (cy) {
cy.layout(getLayoutConfig(currentLayout)).run();
cy.fit(undefined, 50);
}
}
onMount(() => {
// Follow app theme from localStorage
const appTheme = localStorage.getItem('theme');
if (appTheme === 'dark' || appTheme === 'light') {
graphTheme = appTheme;
} else {
// Fallback to system preference
graphTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
});
// Create graph when container element becomes available
$effect(() => {
if (containerEl && composeContent && !graphInitialized) {
createGraph();
}
});
onDestroy(() => {
if (cy) {
cy.destroy();
cy = null;
}
});
function toggleGraphTheme() {
graphTheme = graphTheme === 'light' ? 'dark' : 'light';
createGraph(true); // Recreate graph with new theme, preserve local edits
}
// Recreate graph when content changes externally
let lastContent = composeContent;
$effect(() => {
if (containerEl && composeContent && composeContent !== lastContent) {
lastContent = composeContent;
createGraph();
}
});
function getNodeIcon(type: string) {
switch (type) {
case 'service': return Box;
case 'network': return Network;
case 'volume': return HardDrive;
case 'config': return FileText;
case 'secret': return Lock;
default: return Database;
}
}
function getNodeColor(type: string) {
switch (type) {
case 'service': return 'bg-blue-500';
case 'network': return 'bg-violet-500';
case 'volume': return 'bg-emerald-500';
case 'config': return 'bg-amber-500';
case 'secret': return 'bg-red-500';
default: return 'bg-slate-500';
}
}
function getElementTypeLabel(type: string) {
switch (type) {
case 'service': return 'Service';
case 'network': return 'Network';
case 'volume': return 'Volume';
case 'config': return 'Config';
case 'secret': return 'Secret';
default: return type;
}
}
// Initialize edit state when a service is selected
function initServiceEdit(nodeData: any) {
if (!composeData?.services || nodeData.type !== 'service') return;
const serviceName = nodeData.label;
const config = composeData.services[serviceName];
if (!config) return;
editServiceImage = config.image || '';
editServiceCommand = Array.isArray(config.command)
? config.command.join(' ')
: (config.command || '');
editServiceRestart = config.restart || 'no';
// Parse ports
const ports = config.ports || [];
editServicePorts = ports.map((p: any) => {
if (typeof p === 'string') {
const parts = p.split(':');
const lastPart = parts[parts.length - 1];
const protocolMatch = lastPart.match(/\/(\w+)$/);
const protocol = protocolMatch ? protocolMatch[1] : 'tcp';
const containerPort = lastPart.replace(/\/\w+$/, '');
return {
host: parts.length > 1 ? parts[0] : '',
container: containerPort,
protocol
};
}
return {
host: String(p.published || ''),
container: String(p.target || ''),
protocol: p.protocol || 'tcp'
};
});
if (editServicePorts.length === 0) {
editServicePorts = [{ host: '', container: '', protocol: 'tcp' }];
}
// Parse volumes
const volumes = config.volumes || [];
editServiceVolumes = volumes.map((v: any) => {
if (typeof v === 'string') {
const parts = v.split(':');
return {
host: parts[0] || '',
container: parts[1] || '',
mode: parts[2] || 'rw'
};
}
return {
host: v.source || '',
container: v.target || '',
mode: v.read_only ? 'ro' : 'rw'
};
});
if (editServiceVolumes.length === 0) {
editServiceVolumes = [{ host: '', container: '', mode: 'rw' }];
}
// Parse environment variables
const env = config.environment || {};
if (Array.isArray(env)) {
editServiceEnvVars = env.map((e: string) => {
const [key, ...valueParts] = e.split('=');
return { key, value: valueParts.join('=') };
});
} else {
editServiceEnvVars = Object.entries(env).map(([key, value]) => ({
key,
value: String(value)
}));
}
if (editServiceEnvVars.length === 0) {
editServiceEnvVars = [{ key: '', value: '' }];
}
// Parse labels
const labels = config.labels || {};
if (Array.isArray(labels)) {
editServiceLabels = labels.map((l: string) => {
const [key, ...valueParts] = l.split('=');
return { key, value: valueParts.join('=') };
});
} else {
editServiceLabels = Object.entries(labels).map(([key, value]) => ({
key,
value: String(value)
}));
}
if (editServiceLabels.length === 0) {
editServiceLabels = [{ key: '', value: '' }];
}
// Parse networks
const networks = config.networks;
if (networks) {
editServiceNetworks = Array.isArray(networks) ? networks : Object.keys(networks);
} else {
editServiceNetworks = [];
}
serviceEditDirty = false;
}
// Save service edits back to compose data
function saveServiceEdit() {
if (!composeData?.services || !selectedNode || selectedNode.type !== 'service') return;
const serviceName = selectedNode.label;
if (!composeData.services[serviceName]) return;
const config = composeData.services[serviceName];
// Update image
if (editServiceImage.trim()) {
config.image = editServiceImage.trim();
}
// Update command
if (editServiceCommand.trim()) {
config.command = editServiceCommand.trim();
} else {
delete config.command;
}
// Update restart policy
config.restart = editServiceRestart;
// Update ports
const validPorts = editServicePorts.filter(p => p.container);
if (validPorts.length > 0) {
config.ports = validPorts.map(p => {
if (p.host) {
return p.protocol !== 'tcp'
? `${p.host}:${p.container}/${p.protocol}`
: `${p.host}:${p.container}`;
}
return p.protocol !== 'tcp' ? `${p.container}/${p.protocol}` : p.container;
});
} else {
delete config.ports;
}
// Update volumes
const validVolumes = editServiceVolumes.filter(v => v.host && v.container);
if (validVolumes.length > 0) {
config.volumes = validVolumes.map(v =>
v.mode === 'ro' ? `${v.host}:${v.container}:ro` : `${v.host}:${v.container}`
);
} else {
delete config.volumes;
}
// Update environment variables
const validEnvVars = editServiceEnvVars.filter(e => e.key);
if (validEnvVars.length > 0) {
config.environment = {};
validEnvVars.forEach(e => {
config.environment[e.key] = e.value;
});
} else {
delete config.environment;
}
// Update labels
const validLabels = editServiceLabels.filter(l => l.key);
if (validLabels.length > 0) {
config.labels = {};
validLabels.forEach(l => {
config.labels[l.key] = l.value;
});
} else {
delete config.labels;
}
serviceEditDirty = false;
// Force reactivity by reassigning composeData
composeData = { ...composeData };
emitChange();
createGraph(true, true);
}
// Add/remove functions for service edit arrays
function addServicePort() {
editServicePorts = [...editServicePorts, { host: '', container: '', protocol: 'tcp' }];
serviceEditDirty = true;
}
function removeServicePort(index: number) {
editServicePorts = editServicePorts.filter((_, i) => i !== index);
serviceEditDirty = true;
}
function addServiceVolume() {
editServiceVolumes = [...editServiceVolumes, { host: '', container: '', mode: 'rw' }];
serviceEditDirty = true;
}
function removeServiceVolume(index: number) {
editServiceVolumes = editServiceVolumes.filter((_, i) => i !== index);
serviceEditDirty = true;
}
function addServiceEnvVar() {
editServiceEnvVars = [...editServiceEnvVars, { key: '', value: '' }];
serviceEditDirty = true;
}
function removeServiceEnvVar(index: number) {
editServiceEnvVars = editServiceEnvVars.filter((_, i) => i !== index);
serviceEditDirty = true;
}
function addServiceLabel() {
editServiceLabels = [...editServiceLabels, { key: '', value: '' }];
serviceEditDirty = true;
}
function removeServiceLabel(index: number) {
editServiceLabels = editServiceLabels.filter((_, i) => i !== index);
serviceEditDirty = true;
}
function markServiceDirty() {
serviceEditDirty = true;
}
// Effect to initialize edit state when service is selected
// Use untrack to prevent tracking composeData inside init functions
let lastSelectedNodeId: string | null = null;
$effect(() => {
const nodeId = selectedNode?.id ?? null;
if (nodeId !== lastSelectedNodeId) {
lastSelectedNodeId = nodeId;
if (selectedNode) {
untrack(() => {
switch (selectedNode.type) {
case 'service':
initServiceEdit(selectedNode);
break;
case 'network':
initNetworkEdit(selectedNode);
break;
case 'volume':
initVolumeEdit(selectedNode);
break;
case 'config':
initConfigEdit(selectedNode);
break;
case 'secret':
initSecretEdit(selectedNode);
break;
}
});
}
}
});
// Initialize network edit state
function initNetworkEdit(nodeData: any) {
if (!composeData?.networks || nodeData.type !== 'network') return;
const networkName = nodeData.label;
const config = composeData.networks[networkName] || {};
editNetworkDriver = config.driver || 'bridge';
editNetworkInternal = config.internal || false;
editNetworkExternal = config.external || false;
editNetworkAttachable = config.attachable || false;
// Parse IPAM config
const ipamConfig = config.ipam?.config?.[0] || {};
editNetworkSubnet = ipamConfig.subnet || '';
editNetworkGateway = ipamConfig.gateway || '';
// Parse labels
const labels = config.labels || {};
if (typeof labels === 'object' && !Array.isArray(labels)) {
editNetworkLabels = Object.entries(labels).map(([key, value]) => ({
key,
value: String(value)
}));
} else {
editNetworkLabels = [];
}
if (editNetworkLabels.length === 0) {
editNetworkLabels = [{ key: '', value: '' }];
}
// Parse driver options
const options = config.driver_opts || {};
if (typeof options === 'object' && !Array.isArray(options)) {
editNetworkOptions = Object.entries(options).map(([key, value]) => ({
key,
value: String(value)
}));
} else {
editNetworkOptions = [];
}
if (editNetworkOptions.length === 0) {
editNetworkOptions = [{ key: '', value: '' }];
}
networkEditDirty = false;
}
// Save network edits
function saveNetworkEdit() {
if (!composeData?.networks || !selectedNode || selectedNode.type !== 'network') return;
const networkName = selectedNode.label;
if (!composeData.networks[networkName]) {
composeData.networks[networkName] = {};
}
const config = composeData.networks[networkName];
// Update driver
if (editNetworkDriver !== 'bridge') {
config.driver = editNetworkDriver;
} else {
delete config.driver;
}
// Update boolean flags
if (editNetworkInternal) {
config.internal = true;
} else {
delete config.internal;
}
if (editNetworkExternal) {
config.external = true;
} else {
delete config.external;
}
if (editNetworkAttachable) {
config.attachable = true;
} else {
delete config.attachable;
}
// Update IPAM config
if (editNetworkSubnet || editNetworkGateway) {
config.ipam = {
config: [{
...(editNetworkSubnet ? { subnet: editNetworkSubnet } : {}),
...(editNetworkGateway ? { gateway: editNetworkGateway } : {})
}]
};
} else {
delete config.ipam;
}
// Update labels
const validLabels = editNetworkLabels.filter(l => l.key);
if (validLabels.length > 0) {
config.labels = {};
validLabels.forEach(l => {
config.labels[l.key] = l.value;
});
} else {
delete config.labels;
}
// Update driver options
const validOptions = editNetworkOptions.filter(o => o.key);
if (validOptions.length > 0) {
config.driver_opts = {};
validOptions.forEach(o => {
config.driver_opts[o.key] = o.value;
});
} else {
delete config.driver_opts;
}
networkEditDirty = false;
// Force reactivity by reassigning composeData
composeData = { ...composeData };
emitChange();
createGraph(true, true);
}
// Network edit helpers
function addNetworkLabel() {
editNetworkLabels = [...editNetworkLabels, { key: '', value: '' }];
networkEditDirty = true;
}
function removeNetworkLabel(index: number) {
editNetworkLabels = editNetworkLabels.filter((_, i) => i !== index);
networkEditDirty = true;
}
function addNetworkOption() {
editNetworkOptions = [...editNetworkOptions, { key: '', value: '' }];
networkEditDirty = true;
}
function removeNetworkOption(index: number) {
editNetworkOptions = editNetworkOptions.filter((_, i) => i !== index);
networkEditDirty = true;
}
function markNetworkDirty() {
networkEditDirty = true;
}
// Initialize volume edit state
function initVolumeEdit(nodeData: any) {
if (!composeData?.volumes || nodeData.type !== 'volume') return;
const volumeName = nodeData.label;
const config = composeData.volumes[volumeName] || {};
editVolumeDriver = config.driver || 'local';
editVolumeExternal = config.external || false;
// Parse labels
const labels = config.labels || {};
if (typeof labels === 'object' && !Array.isArray(labels)) {
editVolumeLabels = Object.entries(labels).map(([key, value]) => ({
key,
value: String(value)
}));
} else {
editVolumeLabels = [];
}
if (editVolumeLabels.length === 0) {
editVolumeLabels = [{ key: '', value: '' }];
}
// Parse driver options
const options = config.driver_opts || {};
if (typeof options === 'object' && !Array.isArray(options)) {
editVolumeOptions = Object.entries(options).map(([key, value]) => ({
key,
value: String(value)
}));
} else {
editVolumeOptions = [];
}
if (editVolumeOptions.length === 0) {
editVolumeOptions = [{ key: '', value: '' }];
}
volumeEditDirty = false;
}
// Save volume edits
function saveVolumeEdit() {
if (!composeData?.volumes || !selectedNode || selectedNode.type !== 'volume') return;
const volumeName = selectedNode.label;
if (!composeData.volumes[volumeName]) {
composeData.volumes[volumeName] = {};
}
const config = composeData.volumes[volumeName];
// Update driver
if (editVolumeDriver !== 'local') {
config.driver = editVolumeDriver;
} else {
delete config.driver;
}
// Update external
if (editVolumeExternal) {
config.external = true;
} else {
delete config.external;
}
// Update labels
const validLabels = editVolumeLabels.filter(l => l.key);
if (validLabels.length > 0) {
config.labels = {};
validLabels.forEach(l => {
config.labels[l.key] = l.value;
});
} else {
delete config.labels;
}
// Update driver options
const validOptions = editVolumeOptions.filter(o => o.key);
if (validOptions.length > 0) {
config.driver_opts = {};
validOptions.forEach(o => {
config.driver_opts[o.key] = o.value;
});
} else {
delete config.driver_opts;
}
volumeEditDirty = false;
// Force reactivity by reassigning composeData
composeData = { ...composeData };
emitChange();
createGraph(true, true);
}
// Volume edit helpers
function addVolumeLabel() {
editVolumeLabels = [...editVolumeLabels, { key: '', value: '' }];
volumeEditDirty = true;
}
function removeVolumeLabel(index: number) {
editVolumeLabels = editVolumeLabels.filter((_, i) => i !== index);
volumeEditDirty = true;
}
function addVolumeOption() {
editVolumeOptions = [...editVolumeOptions, { key: '', value: '' }];
volumeEditDirty = true;
}
function removeVolumeOption(index: number) {
editVolumeOptions = editVolumeOptions.filter((_, i) => i !== index);
volumeEditDirty = true;
}
function markVolumeDirty() {
volumeEditDirty = true;
}
// Initialize config edit state
function initConfigEdit(nodeData: any) {
if (!composeData?.configs || nodeData.type !== 'config') return;
const configName = nodeData.label;
const config = composeData.configs[configName] || {};
editConfigFile = config.file || '';
editConfigContent = config.content || '';
editConfigEnvironment = config.environment || '';
editConfigExternal = config.external || false;
editConfigName = config.name || '';
configEditDirty = false;
}
// Save config edits
function saveConfigEdit() {
if (!composeData?.configs || !selectedNode || selectedNode.type !== 'config') return;
const configName = selectedNode.label;
if (!composeData.configs[configName]) {
composeData.configs[configName] = {};
}
const config = composeData.configs[configName];
// Clear all properties first
delete config.file;
delete config.content;
delete config.environment;
delete config.external;
delete config.name;
// Set based on what's provided
if (editConfigExternal) {
config.external = true;
if (editConfigName) config.name = editConfigName;
} else if (editConfigFile) {
config.file = editConfigFile;
} else if (editConfigContent) {
config.content = editConfigContent;
} else if (editConfigEnvironment) {
config.environment = editConfigEnvironment;
}
configEditDirty = false;
// Force reactivity by reassigning composeData
composeData = { ...composeData };
emitChange();
createGraph(true, true);
}
function markConfigDirty() {
configEditDirty = true;
}
// Initialize secret edit state
function initSecretEdit(nodeData: any) {
if (!composeData?.secrets || nodeData.type !== 'secret') return;
const secretName = nodeData.label;
const config = composeData.secrets[secretName] || {};
editSecretFile = config.file || '';
editSecretEnvironment = config.environment || '';
editSecretExternal = config.external || false;
editSecretName = config.name || '';
secretEditDirty = false;
}
// Save secret edits
function saveSecretEdit() {
if (!composeData?.secrets || !selectedNode || selectedNode.type !== 'secret') return;
const secretName = selectedNode.label;
if (!composeData.secrets[secretName]) {
composeData.secrets[secretName] = {};
}
const config = composeData.secrets[secretName];
// Clear all properties first
delete config.file;
delete config.environment;
delete config.external;
delete config.name;
// Set based on what's provided
if (editSecretExternal) {
config.external = true;
if (editSecretName) config.name = editSecretName;
} else if (editSecretFile) {
config.file = editSecretFile;
} else if (editSecretEnvironment) {
config.environment = editSecretEnvironment;
}
secretEditDirty = false;
// Force reactivity by reassigning composeData
composeData = { ...composeData };
emitChange();
createGraph(true, true);
}
function markSecretDirty() {
secretEditDirty = true;
}
</script>
<div class="flex flex-col h-full {className}">
<!-- Toolbar -->
<div class="flex items-center justify-between px-2 py-1.5 border-b border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800 min-h-[40px]">
<div class="flex items-center gap-2 flex-wrap">
<!-- Add element dropdown -->
<div class="relative">
<Button
variant="outline"
size="sm"
class="h-6 px-2 text-xs gap-1"
onclick={() => showAddMenu = !showAddMenu}
>
<Plus class="w-3 h-3" />
Add
<ChevronDown class="w-2.5 h-2.5" />
</Button>
{#if showAddMenu}
<div class="absolute top-full left-0 mt-1 bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-md shadow-lg z-50 py-1 min-w-[140px]">
<button
class="w-full px-2.5 py-1.5 text-left text-xs text-zinc-700 dark:text-zinc-200 hover:bg-zinc-100 dark:hover:bg-zinc-700 flex items-center gap-2 transition-colors"
onclick={() => openAddDialog('service')}
>
<Box class="w-3.5 h-3.5 text-blue-500" />
Service
</button>
<button
class="w-full px-2.5 py-1.5 text-left text-xs text-zinc-700 dark:text-zinc-200 hover:bg-zinc-100 dark:hover:bg-zinc-700 flex items-center gap-2 transition-colors"
onclick={() => openAddDialog('network')}
>
<Network class="w-3.5 h-3.5 text-violet-500" />
Network
</button>
<button
class="w-full px-2.5 py-1.5 text-left text-xs text-zinc-700 dark:text-zinc-200 hover:bg-zinc-100 dark:hover:bg-zinc-700 flex items-center gap-2 transition-colors"
onclick={() => openAddDialog('volume')}
>
<HardDrive class="w-3.5 h-3.5 text-emerald-500" />
Volume
</button>
<button
class="w-full px-2.5 py-1.5 text-left text-xs text-zinc-700 dark:text-zinc-200 hover:bg-zinc-100 dark:hover:bg-zinc-700 flex items-center gap-2 transition-colors"
onclick={() => openAddDialog('config')}
>
<FileText class="w-3.5 h-3.5 text-amber-500" />
Config
</button>
<button
class="w-full px-2.5 py-1.5 text-left text-xs text-zinc-700 dark:text-zinc-200 hover:bg-zinc-100 dark:hover:bg-zinc-700 flex items-center gap-2 transition-colors"
onclick={() => openAddDialog('secret')}
>
<Lock class="w-3.5 h-3.5 text-red-500" />
Secret
</button>
</div>
{/if}
</div>
<!-- Connection mode toggle -->
<Button
variant={connectionMode ? 'default' : 'outline'}
size="sm"
class="h-6 px-2 text-xs gap-1 w-[98px] justify-center"
onclick={toggleConnectionMode}
>
<Link class="w-3 h-3" />
{connectionMode ? 'Cancel' : 'Dependency'}
</Button>
<!-- Mount mode toggle (volume/network/config/secret to service) -->
<Button
variant={mountMode ? 'default' : 'outline'}
size="sm"
class="h-6 px-2 text-xs gap-1 w-[68px] justify-center"
onclick={toggleMountMode}
>
<HardDrive class="w-3 h-3" />
{mountMode ? 'Cancel' : 'Mount'}
</Button>
<!-- Hint when in connection/mount mode -->
{#if connectionMode || mountMode}
<div class="flex items-center gap-1.5 px-2 py-0.5 bg-amber-100 dark:bg-amber-900/30 text-amber-800 dark:text-amber-200 rounded text-xs">
<Lightbulb class="w-3 h-3" />
{#if connectionMode}
{#if connectionSource}
Click target service
{:else}
Click source service
{/if}
{:else if mountMode}
{#if mountSource}
Click target service
{:else}
Click volume/network/config/secret
{/if}
{/if}
</div>
{/if}
</div>
<!-- Controls -->
<div class="flex items-center gap-0.5">
<!-- Layout selector -->
<div class="relative">
<button
onclick={() => showLayoutMenu = !showLayoutMenu}
class="h-6 px-2 flex items-center gap-1 rounded text-xs text-zinc-600 dark:text-zinc-300 hover:bg-zinc-200 dark:hover:bg-zinc-700 transition-colors"
title="Change layout"
>
{#if currentLayout === 'breadthfirst'}
<GitBranch class="w-3 h-3" />
{:else if currentLayout === 'grid'}
<LayoutGrid class="w-3 h-3" />
{:else if currentLayout === 'circle'}
<Circle class="w-3 h-3" />
{:else if currentLayout === 'concentric'}
<Target class="w-3 h-3" />
{:else}
<Sparkles class="w-3 h-3" />
{/if}
<ChevronDown class="w-3 h-3" />
</button>
{#if showLayoutMenu}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="absolute right-0 top-full mt-1 bg-white dark:bg-zinc-800 rounded-lg shadow-lg border border-zinc-200 dark:border-zinc-700 py-1 z-20 min-w-[120px]"
onmouseleave={() => showLayoutMenu = false}
>
<button
class="w-full px-3 py-1.5 text-left text-xs flex items-center gap-2 hover:bg-zinc-100 dark:hover:bg-zinc-700 {currentLayout === 'breadthfirst' ? 'text-blue-600 dark:text-blue-400 font-medium' : 'text-zinc-700 dark:text-zinc-200'}"
onclick={() => applyLayout('breadthfirst')}
>
<GitBranch class="w-3.5 h-3.5" />
Tree
</button>
<button
class="w-full px-3 py-1.5 text-left text-xs flex items-center gap-2 hover:bg-zinc-100 dark:hover:bg-zinc-700 {currentLayout === 'grid' ? 'text-blue-600 dark:text-blue-400 font-medium' : 'text-zinc-700 dark:text-zinc-200'}"
onclick={() => applyLayout('grid')}
>
<LayoutGrid class="w-3.5 h-3.5" />
Grid
</button>
<button
class="w-full px-3 py-1.5 text-left text-xs flex items-center gap-2 hover:bg-zinc-100 dark:hover:bg-zinc-700 {currentLayout === 'circle' ? 'text-blue-600 dark:text-blue-400 font-medium' : 'text-zinc-700 dark:text-zinc-200'}"
onclick={() => applyLayout('circle')}
>
<Circle class="w-3.5 h-3.5" />
Circle
</button>
<button
class="w-full px-3 py-1.5 text-left text-xs flex items-center gap-2 hover:bg-zinc-100 dark:hover:bg-zinc-700 {currentLayout === 'concentric' ? 'text-blue-600 dark:text-blue-400 font-medium' : 'text-zinc-700 dark:text-zinc-200'}"
onclick={() => applyLayout('concentric')}
>
<Target class="w-3.5 h-3.5" />
Radial
</button>
<button
class="w-full px-3 py-1.5 text-left text-xs flex items-center gap-2 hover:bg-zinc-100 dark:hover:bg-zinc-700 {currentLayout === 'cose' ? 'text-blue-600 dark:text-blue-400 font-medium' : 'text-zinc-700 dark:text-zinc-200'}"
onclick={() => applyLayout('cose')}
>
<Sparkles class="w-3.5 h-3.5" />
Force
</button>
</div>
{/if}
</div>
<div class="w-px h-4 bg-zinc-300 dark:bg-zinc-600 mx-1"></div>
<!-- Theme toggle -->
<button
onclick={toggleGraphTheme}
class="h-6 w-6 flex items-center justify-center rounded text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200 hover:bg-zinc-200 dark:hover:bg-zinc-700 transition-colors"
title={graphTheme === 'light' ? 'Switch to dark theme' : 'Switch to light theme'}
>
{#if graphTheme === 'light'}
<Moon class="w-3.5 h-3.5" />
{:else}
<Sun class="w-3.5 h-3.5" />
{/if}
</button>
<div class="w-px h-4 bg-zinc-300 dark:bg-zinc-600 mx-1"></div>
<Button variant="ghost" size="sm" onclick={zoomOut} class="h-6 w-6 p-0 text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200">
<ZoomOut class="w-3.5 h-3.5" />
</Button>
<Button variant="ghost" size="sm" onclick={zoomIn} class="h-6 w-6 p-0 text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200">
<ZoomIn class="w-3.5 h-3.5" />
</Button>
<Button variant="ghost" size="sm" onclick={fitToScreen} class="h-6 w-6 p-0 text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200">
<Maximize2 class="w-3.5 h-3.5" />
</Button>
<Button variant="ghost" size="sm" onclick={resetLayout} class="h-6 w-6 p-0 text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200">
<RotateCcw class="w-3.5 h-3.5" />
</Button>
</div>
</div>
<div class="flex-1 flex min-h-0 h-full">
<!-- Graph container -->
<div class="flex-1 relative h-full {graphTheme === 'dark' ? 'bg-zinc-900' : 'bg-zinc-100'}">
<div bind:this={containerEl} class="w-full h-full"></div>
{#if parseError}
<div class="absolute top-2 left-2 right-2 z-10">
<div class="bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-lg px-3 py-2 shadow-sm">
<p class="text-xs text-red-600 dark:text-red-400 font-medium">YAML Parse Error</p>
<p class="text-xs text-red-500 dark:text-red-300 mt-0.5 font-mono">{parseError}</p>
</div>
</div>
{/if}
<!-- Footer: Legend -->
<div class="absolute bottom-2 left-2 pointer-events-none z-10">
<div class="flex items-center gap-2 text-xs bg-white/80 dark:bg-zinc-800/80 backdrop-blur-sm rounded px-2 py-1 shadow-sm border border-zinc-200/50 dark:border-zinc-700/50 whitespace-nowrap">
<div class="flex items-center gap-1 flex-shrink-0">
<div class="w-2 h-2 rounded-sm bg-blue-500 flex-shrink-0"></div>
<span class="text-zinc-600 dark:text-zinc-300">Service</span>
</div>
<div class="flex items-center gap-1 flex-shrink-0">
<div class="w-2 h-2 rounded-sm bg-violet-500 flex-shrink-0"></div>
<span class="text-zinc-600 dark:text-zinc-300">Network</span>
</div>
<div class="flex items-center gap-1 flex-shrink-0">
<div class="w-2 h-2 rounded-sm bg-emerald-500 flex-shrink-0"></div>
<span class="text-zinc-600 dark:text-zinc-300">Volume</span>
</div>
<div class="flex items-center gap-1 flex-shrink-0">
<div class="w-2 h-2 rounded-sm bg-amber-500 flex-shrink-0"></div>
<span class="text-zinc-600 dark:text-zinc-300">Config</span>
</div>
<div class="flex items-center gap-1 flex-shrink-0">
<div class="w-2 h-2 rounded-sm bg-red-500 flex-shrink-0"></div>
<span class="text-zinc-600 dark:text-zinc-300">Secret</span>
</div>
</div>
</div>
<!-- Details panel (overlay) -->
{#if selectedNode || selectedEdge}
<div class="absolute top-0 right-0 bottom-0 w-[420px] border-l border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/95 shadow-lg z-20 flex flex-col">
<!-- Sticky header -->
{#if selectedNode}
{@const NodeIcon = getNodeIcon(selectedNode.type)}
<div class="sticky top-0 z-10 p-3 border-b border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/95">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<div class="p-1.5 rounded {getNodeColor(selectedNode.type)}">
<NodeIcon class="w-3.5 h-3.5 text-white" />
</div>
<div>
<h3 class="font-semibold text-sm text-zinc-800 dark:text-zinc-100">{selectedNode.label}</h3>
<p class="text-xs text-zinc-500 dark:text-zinc-400 capitalize">{selectedNode.type}</p>
</div>
</div>
<div class="flex items-center gap-1">
{#if (selectedNode.type === 'service' && serviceEditDirty) ||
(selectedNode.type === 'network' && networkEditDirty) ||
(selectedNode.type === 'volume' && volumeEditDirty) ||
(selectedNode.type === 'config' && configEditDirty) ||
(selectedNode.type === 'secret' && secretEditDirty)}
<Button
variant="ghost"
size="sm"
class="h-6 w-6 p-0 text-emerald-500 hover:text-emerald-600 hover:bg-emerald-50 dark:hover:bg-emerald-900/30"
onclick={() => {
if (selectedNode.type === 'service') saveServiceEdit();
else if (selectedNode.type === 'network') saveNetworkEdit();
else if (selectedNode.type === 'volume') saveVolumeEdit();
else if (selectedNode.type === 'config') saveConfigEdit();
else if (selectedNode.type === 'secret') saveSecretEdit();
selectedNode = null;
selectedEdge = null;
}}
title="Save and close"
>
<Save class="w-3.5 h-3.5" />
</Button>
{/if}
<Button
variant="ghost"
size="sm"
class="h-6 w-6 p-0 text-red-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/30"
onclick={deleteSelectedNode}
title="Delete"
>
<Trash2 class="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
class="h-6 w-6 p-0 text-zinc-500 hover:text-zinc-600 hover:bg-zinc-100 dark:hover:bg-zinc-700"
onclick={() => { selectedNode = null; selectedEdge = null; }}
title="Close"
>
<X class="w-3.5 h-3.5" />
</Button>
</div>
</div>
</div>
{:else if selectedEdge}
<!-- Sticky header for edge -->
<div class="sticky top-0 z-10 p-3 border-b border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/95">
<div class="flex items-center justify-between">
<div>
<h3 class="font-semibold text-sm text-zinc-800 dark:text-zinc-100 capitalize">{selectedEdge.type.replace('-', ' ')}</h3>
<p class="text-xs text-zinc-500 dark:text-zinc-400">
{selectedEdge.source.replace(/^(service|network|volume|config|secret)-/, '')}
{selectedEdge.target.replace(/^(service|network|volume|config|secret)-/, '')}
</p>
</div>
<div class="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
class="h-6 w-6 p-0 text-red-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/30"
onclick={deleteSelectedEdge}
title="Remove connection"
>
<Trash2 class="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
class="h-6 w-6 p-0 text-zinc-500 hover:text-zinc-600 hover:bg-zinc-100 dark:hover:bg-zinc-700"
onclick={() => { selectedNode = null; selectedEdge = null; }}
title="Close"
>
<X class="w-3.5 h-3.5" />
</Button>
</div>
</div>
</div>
{/if}
<!-- Scrollable content -->
<div class="flex-1 overflow-y-auto p-3">
{#if selectedNode}
{#if selectedNode.type === 'service'}
<div class="space-y-3 text-sm">
<!-- Image -->
<div class="space-y-1.5">
<div class="flex items-center justify-between">
<span class="text-xs font-medium text-zinc-600 dark:text-zinc-300">Image</span>
</div>
<Input
bind:value={editServiceImage}
oninput={markServiceDirty}
placeholder="nginx:alpine"
class="h-8 text-xs"
/>
</div>
<!-- Command -->
<div class="space-y-1.5">
<span class="text-xs font-medium text-zinc-600 dark:text-zinc-300">Command</span>
<Input
bind:value={editServiceCommand}
oninput={markServiceDirty}
placeholder="/bin/sh -c '...'"
class="h-8 text-xs"
/>
</div>
<!-- Restart policy -->
<div class="space-y-1.5">
<span class="text-xs font-medium text-zinc-600 dark:text-zinc-300">Restart policy</span>
<Select.Root type="single" bind:value={editServiceRestart} onValueChange={() => { serviceEditDirty = true; }}>
<Select.Trigger class="h-8 text-xs">
<span>{editServiceRestart === 'no' ? 'No' : editServiceRestart === 'always' ? 'Always' : editServiceRestart === 'on-failure' ? 'On failure' : 'Unless stopped'}</span>
</Select.Trigger>
<Select.Content>
<Select.Item value="no" label="No" />
<Select.Item value="always" label="Always" />
<Select.Item value="on-failure" label="On failure" />
<Select.Item value="unless-stopped" label="Unless stopped" />
</Select.Content>
</Select.Root>
</div>
<!-- Port mappings -->
<div class="space-y-1.5">
<div class="flex items-center justify-between">
<span class="text-xs font-medium text-zinc-600 dark:text-zinc-300">Port mappings</span>
<button onclick={addServicePort} class="text-xs text-blue-500 hover:text-blue-600">
<Plus class="w-3.5 h-3.5" />
</button>
</div>
<div class="space-y-4 pt-2">
{#each editServicePorts as port, index}
<div class="flex gap-1 items-center">
<div class="flex-1 relative">
<span class="absolute -top-2 left-2 text-[9px] text-zinc-400 bg-white dark:bg-zinc-800 px-1 z-10">Host</span>
<Input bind:value={port.host} oninput={markServiceDirty} class="h-9 pt-3 text-xs" />
</div>
<div class="flex-1 relative">
<span class="absolute -top-2 left-2 text-[9px] text-zinc-400 bg-white dark:bg-zinc-800 px-1 z-10">Container</span>
<Input bind:value={port.container} oninput={markServiceDirty} class="h-9 pt-3 text-xs" />
</div>
<button
onclick={() => removeServicePort(index)}
disabled={editServicePorts.length === 1}
class="p-1 text-zinc-400 hover:text-red-500 disabled:opacity-30"
>
<Trash2 class="w-3 h-3" />
</button>
</div>
{/each}
</div>
</div>
<!-- Volumes -->
<div class="space-y-1.5">
<div class="flex items-center justify-between">
<span class="text-xs font-medium text-zinc-600 dark:text-zinc-300">Volumes</span>
<button onclick={addServiceVolume} class="text-xs text-blue-500 hover:text-blue-600">
<Plus class="w-3.5 h-3.5" />
</button>
</div>
<div class="space-y-4 pt-2">
{#each editServiceVolumes as vol, index}
<div class="flex gap-1 items-center">
<div class="flex-1 relative">
<span class="absolute -top-2 left-2 text-[9px] text-zinc-400 bg-white dark:bg-zinc-800 px-1 z-10">Host</span>
<Input bind:value={vol.host} oninput={markServiceDirty} class="h-9 pt-3 text-xs" />
</div>
<div class="flex-1 relative">
<span class="absolute -top-2 left-2 text-[9px] text-zinc-400 bg-white dark:bg-zinc-800 px-1 z-10">Container</span>
<Input bind:value={vol.container} oninput={markServiceDirty} class="h-9 pt-3 text-xs" />
</div>
<button
onclick={() => removeServiceVolume(index)}
disabled={editServiceVolumes.length === 1}
class="p-1 text-zinc-400 hover:text-red-500 disabled:opacity-30"
>
<Trash2 class="w-3 h-3" />
</button>
</div>
{/each}
</div>
</div>
<!-- Environment variables -->
<div class="space-y-1.5">
<div class="flex items-center justify-between">
<span class="text-xs font-medium text-zinc-600 dark:text-zinc-300">Environment</span>
<button onclick={addServiceEnvVar} class="text-xs text-blue-500 hover:text-blue-600">
<Plus class="w-3.5 h-3.5" />
</button>
</div>
<div class="space-y-4 pt-2">
{#each editServiceEnvVars as env, index}
<div class="flex gap-1 items-center">
<div class="flex-1 relative">
<span class="absolute -top-2 left-2 text-[9px] text-zinc-400 bg-white dark:bg-zinc-800 px-1 z-10">Key</span>
<Input bind:value={env.key} oninput={markServiceDirty} class="h-9 pt-3 text-xs" />
</div>
<div class="flex-1 relative">
<span class="absolute -top-2 left-2 text-[9px] text-zinc-400 bg-white dark:bg-zinc-800 px-1 z-10">Value</span>
<Input bind:value={env.value} oninput={markServiceDirty} class="h-9 pt-3 text-xs" />
</div>
<button
onclick={() => removeServiceEnvVar(index)}
disabled={editServiceEnvVars.length === 1}
class="p-1 text-zinc-400 hover:text-red-500 disabled:opacity-30"
>
<Trash2 class="w-3 h-3" />
</button>
</div>
{/each}
</div>
</div>
<!-- Labels -->
<div class="space-y-1.5">
<div class="flex items-center justify-between">
<span class="text-xs font-medium text-zinc-600 dark:text-zinc-300">Labels</span>
<button onclick={addServiceLabel} class="text-xs text-blue-500 hover:text-blue-600">
<Plus class="w-3.5 h-3.5" />
</button>
</div>
<div class="space-y-4 pt-2">
{#each editServiceLabels as label, index}
<div class="flex gap-1 items-center">
<div class="flex-1 relative">
<span class="absolute -top-2 left-2 text-[9px] text-zinc-400 bg-white dark:bg-zinc-800 px-1 z-10">Key</span>
<Input bind:value={label.key} oninput={markServiceDirty} class="h-9 pt-3 text-xs" />
</div>
<div class="flex-1 relative">
<span class="absolute -top-2 left-2 text-[9px] text-zinc-400 bg-white dark:bg-zinc-800 px-1 z-10">Value</span>
<Input bind:value={label.value} oninput={markServiceDirty} class="h-9 pt-3 text-xs" />
</div>
<button
onclick={() => removeServiceLabel(index)}
disabled={editServiceLabels.length === 1}
class="p-1 text-zinc-400 hover:text-red-500 disabled:opacity-30"
>
<Trash2 class="w-3 h-3" />
</button>
</div>
{/each}
</div>
</div>
<!-- Dependencies -->
{#if selectedNode.dependsOn?.length > 0}
<div class="space-y-1.5">
<span class="text-xs font-medium text-zinc-600 dark:text-zinc-300">Depends on</span>
<div class="space-y-1">
{#each selectedNode.dependsOn as dep}
<div class="flex items-center justify-between text-zinc-700 text-xs bg-zinc-100 px-2 py-1.5 rounded">
<span class="font-mono">{dep}</span>
<button
class="text-zinc-400 hover:text-red-500"
onclick={() => removeDependency(dep, selectedNode.label)}
title="Remove dependency"
>
<X class="w-3 h-3" />
</button>
</div>
{/each}
</div>
</div>
{/if}
</div>
{:else if selectedNode.type === 'network'}
<div class="space-y-3 text-sm">
<!-- Driver -->
<div class="space-y-1.5">
<span class="text-xs font-medium text-zinc-600 dark:text-zinc-300">Driver</span>
<Select.Root type="single" bind:value={editNetworkDriver} onValueChange={() => { networkEditDirty = true; }}>
<Select.Trigger class="h-8 text-xs">
<span class="flex items-center gap-1.5">
{#if editNetworkDriver === 'bridge'}
<Share2 class="w-3.5 h-3.5 text-emerald-500" />
{:else if editNetworkDriver === 'host'}
<Server class="w-3.5 h-3.5 text-sky-500" />
{:else if editNetworkDriver === 'overlay'}
<Globe class="w-3.5 h-3.5 text-violet-500" />
{:else if editNetworkDriver === 'macvlan'}
<MonitorSmartphone class="w-3.5 h-3.5 text-amber-500" />
{:else if editNetworkDriver === 'ipvlan'}
<Cpu class="w-3.5 h-3.5 text-orange-500" />
{:else}
<CircleOff class="w-3.5 h-3.5 text-muted-foreground" />
{/if}
<span class="capitalize">{editNetworkDriver}</span>
</span>
</Select.Trigger>
<Select.Content>
<Select.Item value="bridge" label="Bridge">
{#snippet children()}
<div class="flex items-center gap-2">
<Share2 class="w-3.5 h-3.5 text-emerald-500" />
<span>Bridge</span>
</div>
{/snippet}
</Select.Item>
<Select.Item value="host" label="Host">
{#snippet children()}
<div class="flex items-center gap-2">
<Server class="w-3.5 h-3.5 text-sky-500" />
<span>Host</span>
</div>
{/snippet}
</Select.Item>
<Select.Item value="overlay" label="Overlay">
{#snippet children()}
<div class="flex items-center gap-2">
<Globe class="w-3.5 h-3.5 text-violet-500" />
<span>Overlay</span>
</div>
{/snippet}
</Select.Item>
<Select.Item value="macvlan" label="Macvlan">
{#snippet children()}
<div class="flex items-center gap-2">
<MonitorSmartphone class="w-3.5 h-3.5 text-amber-500" />
<span>Macvlan</span>
</div>
{/snippet}
</Select.Item>
<Select.Item value="ipvlan" label="IPvlan">
{#snippet children()}
<div class="flex items-center gap-2">
<Cpu class="w-3.5 h-3.5 text-orange-500" />
<span>IPvlan</span>
</div>
{/snippet}
</Select.Item>
<Select.Item value="none" label="None">
{#snippet children()}
<div class="flex items-center gap-2">
<CircleOff class="w-3.5 h-3.5 text-muted-foreground" />
<span>None</span>
</div>
{/snippet}
</Select.Item>
</Select.Content>
</Select.Root>
</div>
<!-- IPAM Config -->
<div class="space-y-1.5">
<span class="text-xs font-medium text-zinc-600 dark:text-zinc-300">IPAM configuration</span>
<div class="space-y-4 pt-2">
<div class="relative">
<span class="absolute -top-2 left-2 text-[9px] text-zinc-400 bg-white dark:bg-zinc-800 px-1 z-10">Subnet</span>
<Input bind:value={editNetworkSubnet} oninput={markNetworkDirty} placeholder="172.20.0.0/16" class="h-9 pt-3 text-xs" />
</div>
<div class="relative">
<span class="absolute -top-2 left-2 text-[9px] text-zinc-400 bg-white dark:bg-zinc-800 px-1 z-10">Gateway</span>
<Input bind:value={editNetworkGateway} oninput={markNetworkDirty} placeholder="172.20.0.1" class="h-9 pt-3 text-xs" />
</div>
</div>
</div>
<!-- Boolean flags -->
<div class="space-y-2">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" bind:checked={editNetworkExternal} onchange={markNetworkDirty} class="rounded border-zinc-300" />
<span class="text-xs text-zinc-600">External network</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" bind:checked={editNetworkInternal} onchange={markNetworkDirty} class="rounded border-zinc-300" />
<span class="text-xs text-zinc-600">Internal network</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" bind:checked={editNetworkAttachable} onchange={markNetworkDirty} class="rounded border-zinc-300" />
<span class="text-xs text-zinc-600">Attachable</span>
</label>
</div>
<!-- Labels -->
<div class="space-y-1.5">
<div class="flex items-center justify-between">
<span class="text-xs font-medium text-zinc-600 dark:text-zinc-300">Labels</span>
<button onclick={addNetworkLabel} class="text-xs text-blue-500 hover:text-blue-600">
<Plus class="w-3.5 h-3.5" />
</button>
</div>
<div class="space-y-4 pt-2">
{#each editNetworkLabels as label, index}
<div class="flex gap-1 items-center">
<div class="flex-1 relative">
<span class="absolute -top-2 left-2 text-[9px] text-zinc-400 bg-white dark:bg-zinc-800 px-1 z-10">Key</span>
<Input bind:value={label.key} oninput={markNetworkDirty} class="h-9 pt-3 text-xs" />
</div>
<div class="flex-1 relative">
<span class="absolute -top-2 left-2 text-[9px] text-zinc-400 bg-white dark:bg-zinc-800 px-1 z-10">Value</span>
<Input bind:value={label.value} oninput={markNetworkDirty} class="h-9 pt-3 text-xs" />
</div>
<button onclick={() => removeNetworkLabel(index)} disabled={editNetworkLabels.length === 1} class="p-1 text-zinc-400 hover:text-red-500 disabled:opacity-30">
<Trash2 class="w-3 h-3" />
</button>
</div>
{/each}
</div>
</div>
<!-- Driver options -->
<div class="space-y-1.5">
<div class="flex items-center justify-between">
<span class="text-xs font-medium text-zinc-600 dark:text-zinc-300">Driver options</span>
<button onclick={addNetworkOption} class="text-xs text-blue-500 hover:text-blue-600">
<Plus class="w-3.5 h-3.5" />
</button>
</div>
<div class="space-y-4 pt-2">
{#each editNetworkOptions as opt, index}
<div class="flex gap-1 items-center">
<div class="flex-1 relative">
<span class="absolute -top-2 left-2 text-[9px] text-zinc-400 bg-white dark:bg-zinc-800 px-1 z-10">Key</span>
<Input bind:value={opt.key} oninput={markNetworkDirty} class="h-9 pt-3 text-xs" />
</div>
<div class="flex-1 relative">
<span class="absolute -top-2 left-2 text-[9px] text-zinc-400 bg-white dark:bg-zinc-800 px-1 z-10">Value</span>
<Input bind:value={opt.value} oninput={markNetworkDirty} class="h-9 pt-3 text-xs" />
</div>
<button onclick={() => removeNetworkOption(index)} disabled={editNetworkOptions.length === 1} class="p-1 text-zinc-400 hover:text-red-500 disabled:opacity-30">
<Trash2 class="w-3 h-3" />
</button>
</div>
{/each}
</div>
</div>
</div>
{:else if selectedNode.type === 'volume'}
<div class="space-y-3 text-sm">
<!-- Driver -->
<div class="space-y-1.5">
<span class="text-xs font-medium text-zinc-600 dark:text-zinc-300">Driver</span>
<Input bind:value={editVolumeDriver} oninput={markVolumeDirty} placeholder="local" class="h-8 text-xs" />
</div>
<!-- External -->
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" bind:checked={editVolumeExternal} onchange={markVolumeDirty} class="rounded border-zinc-300" />
<span class="text-xs text-zinc-600">External volume</span>
</label>
<!-- Labels -->
<div class="space-y-1.5">
<div class="flex items-center justify-between">
<span class="text-xs font-medium text-zinc-600 dark:text-zinc-300">Labels</span>
<button onclick={addVolumeLabel} class="text-xs text-blue-500 hover:text-blue-600">
<Plus class="w-3.5 h-3.5" />
</button>
</div>
<div class="space-y-4 pt-2">
{#each editVolumeLabels as label, index}
<div class="flex gap-1 items-center">
<div class="flex-1 relative">
<span class="absolute -top-2 left-2 text-[9px] text-zinc-400 bg-white dark:bg-zinc-800 px-1 z-10">Key</span>
<Input bind:value={label.key} oninput={markVolumeDirty} class="h-9 pt-3 text-xs" />
</div>
<div class="flex-1 relative">
<span class="absolute -top-2 left-2 text-[9px] text-zinc-400 bg-white dark:bg-zinc-800 px-1 z-10">Value</span>
<Input bind:value={label.value} oninput={markVolumeDirty} class="h-9 pt-3 text-xs" />
</div>
<button onclick={() => removeVolumeLabel(index)} disabled={editVolumeLabels.length === 1} class="p-1 text-zinc-400 hover:text-red-500 disabled:opacity-30">
<Trash2 class="w-3 h-3" />
</button>
</div>
{/each}
</div>
</div>
<!-- Driver options -->
<div class="space-y-1.5">
<div class="flex items-center justify-between">
<span class="text-xs font-medium text-zinc-600 dark:text-zinc-300">Driver options</span>
<button onclick={addVolumeOption} class="text-xs text-blue-500 hover:text-blue-600">
<Plus class="w-3.5 h-3.5" />
</button>
</div>
<div class="space-y-4 pt-2">
{#each editVolumeOptions as opt, index}
<div class="flex gap-1 items-center">
<div class="flex-1 relative">
<span class="absolute -top-2 left-2 text-[9px] text-zinc-400 bg-white dark:bg-zinc-800 px-1 z-10">Key</span>
<Input bind:value={opt.key} oninput={markVolumeDirty} class="h-9 pt-3 text-xs" />
</div>
<div class="flex-1 relative">
<span class="absolute -top-2 left-2 text-[9px] text-zinc-400 bg-white dark:bg-zinc-800 px-1 z-10">Value</span>
<Input bind:value={opt.value} oninput={markVolumeDirty} class="h-9 pt-3 text-xs" />
</div>
<button onclick={() => removeVolumeOption(index)} disabled={editVolumeOptions.length === 1} class="p-1 text-zinc-400 hover:text-red-500 disabled:opacity-30">
<Trash2 class="w-3 h-3" />
</button>
</div>
{/each}
</div>
</div>
</div>
{:else if selectedNode.type === 'config'}
<div class="space-y-3 text-sm">
<!-- External checkbox -->
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" bind:checked={editConfigExternal} onchange={markConfigDirty} class="rounded border-zinc-300" />
<span class="text-xs text-zinc-600">External config</span>
</label>
{#if editConfigExternal}
<!-- External name -->
<div class="space-y-1.5">
<span class="text-xs font-medium text-zinc-600 dark:text-zinc-300">External name (optional)</span>
<Input bind:value={editConfigName} oninput={markConfigDirty} placeholder="my-external-config" class="h-8 text-xs" />
</div>
{:else}
<!-- Source type selector hint -->
<p class="text-2xs text-zinc-400">Specify one of: file, content, or environment</p>
<!-- File path -->
<div class="space-y-1.5">
<span class="text-xs font-medium text-zinc-600 dark:text-zinc-300">File path</span>
<Input bind:value={editConfigFile} oninput={markConfigDirty} placeholder="./config/app.conf" class="h-8 text-xs" />
</div>
<!-- Content -->
<div class="space-y-1.5">
<span class="text-xs font-medium text-zinc-600 dark:text-zinc-300">Content (inline)</span>
<textarea
bind:value={editConfigContent}
oninput={markConfigDirty}
placeholder="key=value&#10;another=setting"
class="w-full h-20 text-xs rounded-md border border-zinc-200 px-2 py-1.5 resize-none focus:outline-none focus:ring-1 focus:ring-zinc-400"
></textarea>
</div>
<!-- Environment variable -->
<div class="space-y-1.5">
<span class="text-xs font-medium text-zinc-600 dark:text-zinc-300">Environment variable</span>
<Input bind:value={editConfigEnvironment} oninput={markConfigDirty} placeholder="MY_CONFIG_VAR" class="h-8 text-xs" />
</div>
{/if}
</div>
{:else if selectedNode.type === 'secret'}
<div class="space-y-3 text-sm">
<!-- External checkbox -->
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" bind:checked={editSecretExternal} onchange={markSecretDirty} class="rounded border-zinc-300" />
<span class="text-xs text-zinc-600">External secret</span>
</label>
{#if editSecretExternal}
<!-- External name -->
<div class="space-y-1.5">
<span class="text-xs font-medium text-zinc-600 dark:text-zinc-300">External name (optional)</span>
<Input bind:value={editSecretName} oninput={markSecretDirty} placeholder="my-external-secret" class="h-8 text-xs" />
</div>
{:else}
<!-- Source type selector hint -->
<p class="text-2xs text-zinc-400">Specify one of: file or environment</p>
<!-- File path -->
<div class="space-y-1.5">
<span class="text-xs font-medium text-zinc-600 dark:text-zinc-300">File path</span>
<Input bind:value={editSecretFile} oninput={markSecretDirty} placeholder="./secrets/password.txt" class="h-8 text-xs" />
</div>
<!-- Environment variable -->
<div class="space-y-1.5">
<span class="text-xs font-medium text-zinc-600 dark:text-zinc-300">Environment variable</span>
<Input bind:value={editSecretEnvironment} oninput={markSecretDirty} placeholder="MY_SECRET_VAR" class="h-8 text-xs" />
</div>
{/if}
</div>
{/if}
{:else if selectedEdge}
{#if selectedEdge.type === 'dependency'}
<p class="text-xs text-zinc-500 dark:text-zinc-400">
This service depends on {selectedEdge.source.replace('service-', '')} and will start after it.
</p>
{:else if selectedEdge.type === 'volume-mount'}
<p class="text-xs text-zinc-500 dark:text-zinc-400">
Volume mounted to this service.
</p>
{:else if selectedEdge.type === 'network-connection'}
<p class="text-xs text-zinc-500 dark:text-zinc-400">
Service connected to this network.
</p>
{:else if selectedEdge.type === 'config-mount'}
<p class="text-xs text-zinc-500 dark:text-zinc-400">
Config mounted to this service.
</p>
{:else if selectedEdge.type === 'secret-mount'}
<p class="text-xs text-zinc-500 dark:text-zinc-400">
Secret mounted to this service.
</p>
{/if}
{/if}
</div>
</div>
{/if}
</div>
</div>
</div>
<!-- Add Element Dialog -->
<Dialog.Root bind:open={showAddDialog}>
<Dialog.Content class="max-w-md">
<Dialog.Header>
<Dialog.Title class="flex items-center gap-2">
{@const DialogIcon = getNodeIcon(addElementType)}
<DialogIcon class="w-5 h-5" />
Add {getElementTypeLabel(addElementType)}
</Dialog.Title>
</Dialog.Header>
<div class="space-y-4 py-4">
<div class="space-y-2">
<Label for="element-name">Name</Label>
<Input
id="element-name"
bind:value={newElementName}
placeholder={`my-${addElementType}`}
/>
</div>
{#if addElementType === 'service'}
<div class="space-y-2">
<Label for="service-image">Image</Label>
<Input
id="service-image"
bind:value={newServiceImage}
placeholder="nginx:alpine"
/>
</div>
<div class="space-y-2">
<Label for="service-ports">Ports (comma-separated)</Label>
<Input
id="service-ports"
bind:value={newServicePorts}
placeholder="8080:80, 443:443"
/>
</div>
{/if}
</div>
<div class="flex justify-end gap-2">
<Button variant="outline" size="sm" onclick={() => showAddDialog = false}>Cancel</Button>
<Button variant="secondary" size="sm" onclick={addElement} disabled={!newElementName.trim()}>
<Plus class="w-3.5 h-3.5 mr-1.5" />
Add {getElementTypeLabel(addElementType)}
</Button>
</div>
</Dialog.Content>
</Dialog.Root>
<!-- Click outside to close add menu -->
{#if showAddMenu}
<button
class="fixed inset-0 z-40"
onclick={() => showAddMenu = false}
aria-label="Close menu"
></button>
{/if}