← back to Exo

dashboard/src/lib/components/ModelCard.svelte

1074 lines

<script lang="ts">
  import type {
    DownloadProgress,
    NodeInfo,
    PlacementPreview,
    TopologyEdge,
  } from "$lib/stores/app.svelte";
  import { debugMode, topologyData } from "$lib/stores/app.svelte";

  interface Props {
    model: { id: string; name?: string; storage_size_megabytes?: number };
    isLaunching?: boolean;
    downloadStatus?: {
      isDownloading: boolean;
      progress: DownloadProgress | null;
      perNode?: Array<{
        nodeId: string;
        nodeName: string;
        status: "completed" | "partial" | "pending" | "downloading";
        percentage: number;
        progress: DownloadProgress | null;
      }>;
    } | null;
    nodes?: Record<string, NodeInfo>;
    sharding?: "Pipeline" | "Tensor";
    runtime?: "MlxRing" | "MlxJaccl";
    onLaunch?: () => void;
    tags?: string[];
    apiPreview?: PlacementPreview | null;
    modelIdOverride?: string | null;
  }

  let {
    model,
    isLaunching = false,
    downloadStatus = null,
    nodes = {},
    sharding = "Pipeline",
    runtime = "MlxRing",
    onLaunch,
    tags = [],
    apiPreview = null,
    modelIdOverride = null,
  }: Props = $props();

  // Estimate memory requirements from model name
  // Uses regex with word boundaries to avoid false matches like '4bit' matching '4b'
  function estimateMemoryGB(modelId: string, modelName?: string): number {
    // Check both ID and name for quantization info
    const combined = `${modelId} ${modelName || ""}`.toLowerCase();

    // Detect quantization level - affects memory by roughly 2x between levels
    const is4bit =
      combined.includes("4bit") ||
      combined.includes("4-bit") ||
      combined.includes(":4bit");
    const is8bit =
      combined.includes("8bit") ||
      combined.includes("8-bit") ||
      combined.includes(":8bit");
    // 4-bit = 0.5 bytes/param, 8-bit = 1 byte/param, fp16 = 2 bytes/param
    const quantMultiplier = is4bit ? 0.5 : is8bit ? 1 : 2;
    const id = modelId.toLowerCase();

    // Known large models that don't follow the standard naming pattern
    // DeepSeek V3 has 685B parameters
    if (id.includes("deepseek-v3")) {
      return Math.round(685 * quantMultiplier);
    }
    // DeepSeek V2 has 236B parameters
    if (id.includes("deepseek-v2")) {
      return Math.round(236 * quantMultiplier);
    }
    // Llama 4 Scout/Maverick are large models
    if (id.includes("llama-4")) {
      return Math.round(400 * quantMultiplier);
    }

    // Match parameter counts with word boundaries (e.g., "70b" but not "4bit")
    const paramMatch = id.match(/(\d+(?:\.\d+)?)\s*b(?![a-z])/i);
    if (paramMatch) {
      const params = parseFloat(paramMatch[1]);
      return Math.max(4, Math.round(params * quantMultiplier));
    }

    // Fallback patterns for explicit size markers (assume fp16 baseline, adjust for quant)
    if (id.includes("405b") || id.includes("400b"))
      return Math.round(405 * quantMultiplier);
    if (id.includes("180b")) return Math.round(180 * quantMultiplier);
    if (id.includes("141b") || id.includes("140b"))
      return Math.round(140 * quantMultiplier);
    if (id.includes("123b") || id.includes("120b"))
      return Math.round(123 * quantMultiplier);
    if (id.includes("72b") || id.includes("70b"))
      return Math.round(70 * quantMultiplier);
    if (id.includes("67b") || id.includes("65b"))
      return Math.round(65 * quantMultiplier);
    if (
      id.includes("35b") ||
      id.includes("34b") ||
      id.includes("32b") ||
      id.includes("30b")
    )
      return Math.round(32 * quantMultiplier);
    if (id.includes("27b") || id.includes("26b") || id.includes("22b"))
      return Math.round(24 * quantMultiplier);
    if (id.includes("14b") || id.includes("13b") || id.includes("15b"))
      return Math.round(14 * quantMultiplier);
    if (id.includes("8b") || id.includes("9b") || id.includes("7b"))
      return Math.round(8 * quantMultiplier);
    if (id.includes("3b") || id.includes("3.8b"))
      return Math.round(4 * quantMultiplier);
    if (
      id.includes("2b") ||
      id.includes("1b") ||
      id.includes("1.5b") ||
      id.includes("0.5b")
    )
      return Math.round(2 * quantMultiplier);

    return 16; // Default fallback
  }

  function formatBytes(bytes: number, decimals = 1): string {
    if (!bytes || bytes === 0) return "0 B";
    const k = 1024;
    const sizes = ["B", "KB", "MB", "GB", "TB"];
    const i = Math.floor(Math.log(bytes) / Math.log(k));
    return (
      parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)) + " " + sizes[i]
    );
  }

  function formatSpeed(bps: number): string {
    if (!bps || bps <= 0) return "0 B/s";
    return formatBytes(bps) + "/s";
  }

  function formatEta(ms: number): string {
    if (!ms || ms <= 0) return "--";
    const totalSeconds = Math.round(ms / 1000);
    const s = totalSeconds % 60;
    const m = Math.floor(totalSeconds / 60) % 60;
    const h = Math.floor(totalSeconds / 3600);
    if (h > 0) return `${h}h ${m}m`;
    if (m > 0) return `${m}m ${s}s`;
    return `${s}s`;
  }

  const perNode = $derived(downloadStatus?.perNode ?? []);

  function toggleNodeDetails(nodeId: string): void {
    const next = new Set(expandedNodes);
    if (next.has(nodeId)) {
      next.delete(nodeId);
    } else {
      next.add(nodeId);
    }
    expandedNodes = next;
  }

  // Use actual storage_size_megabytes from API if available, otherwise fall back to estimate
  const estimatedMemory = $derived(
    model.storage_size_megabytes
      ? Math.round(model.storage_size_megabytes / 1024)
      : estimateMemoryGB(model.id, model.name),
  );

  function getDeviceType(
    name: string,
  ): "macbook" | "studio" | "mini" | "unknown" {
    const lower = name.toLowerCase();
    if (lower.includes("macbook")) return "macbook";
    if (lower.includes("studio")) return "studio";
    if (lower.includes("mini")) return "mini";
    return "unknown";
  }

  const clampPercent = (value: number): number =>
    Math.min(100, Math.max(0, value));
  const huggingFaceModelId = $derived(modelIdOverride ?? model.id);

  // Get node list in the same order as the topology graph (insertion order of
  // topology nodes), while still ensuring preview nodes render even if the
  // topology payload is missing them. Topology order is preserved exactly so
  // that the mini preview matches the main TopologyGraph layout.
  const nodeList = $derived(() => {
    const nodesFromTopology = Object.keys(nodes).map((id) => {
      const info = nodes[id];
      const totalBytes =
        info.macmon_info?.memory?.ram_total ?? info.system_info?.memory ?? 0;
      const usedBytes = info.macmon_info?.memory?.ram_usage ?? 0;
      const availableBytes = Math.max(totalBytes - usedBytes, 0);
      const totalGB = totalBytes / (1024 * 1024 * 1024);
      const availableGB = availableBytes / (1024 * 1024 * 1024);
      const usedGB = Math.max(totalGB - availableGB, 0);
      const deviceName = info.system_info?.model_id ?? "Unknown";
      const deviceType = getDeviceType(deviceName);

      return {
        id,
        totalGB,
        availableGB,
        usedGB,
        deviceName,
        deviceType,
        usedBytes,
        totalBytes,
      };
    });

    const previewEntries = apiPreview?.memory_delta_by_node ?? null;
    const previewIds = previewEntries ? Object.keys(previewEntries) : [];

    if (previewIds.length === 0) return nodesFromTopology;

    // Append any preview-only nodes (not in topology) at the end
    const topologyIds = new Set(nodesFromTopology.map((n) => n.id));
    const extraPreviewNodes = previewIds
      .filter((id) => !topologyIds.has(id))
      .map((id) => {
        const deltaBytes = previewEntries?.[id] ?? 0;
        const deltaGB = deltaBytes / (1024 * 1024 * 1024);
        const totalGB = Math.max(deltaGB * 1.2, 1);
        const usedGB = Math.max(totalGB - deltaGB, 0);

        return {
          id,
          totalGB,
          availableGB: Math.max(totalGB - usedGB, 0),
          usedGB,
          deviceName: "Unknown",
          deviceType: "unknown" as const,
          usedBytes: usedGB * 1024 * 1024 * 1024,
          totalBytes: totalGB * 1024 * 1024 * 1024,
        };
      });

    return [...nodesFromTopology, ...extraPreviewNodes];
  });

  // Calculate placement preview with all SVG metrics pre-computed
  // Uses API preview data when available, falls back to local estimation
  const placementPreview = $derived(() => {
    const nodeArray = nodeList();
    if (nodeArray.length === 0)
      return {
        nodes: [],
        canFit: false,
        totalAvailable: 0,
        topoWidth: 260,
        topoHeight: 90,
        error: null,
      };

    const numNodes = nodeArray.length;
    const iconSize = numNodes === 1 ? 50 : 36;
    const topoWidth = 260;
    const topoHeight =
      numNodes === 1 ? 90 : numNodes === 2 ? 140 : numNodes * 50 + 20;
    const centerX = topoWidth / 2;
    const centerY = topoHeight / 2;
    const radius =
      numNodes === 1
        ? 0
        : numNodes === 2
          ? 45
          : Math.min(topoWidth, topoHeight) * 0.32;

    // Only use API preview data - no local estimation
    const hasApiPreview =
      apiPreview !== null &&
      apiPreview.error === null &&
      apiPreview.memory_delta_by_node !== null;
    const error = apiPreview?.error ?? null;

    let placementNodes: Array<{
      id: string;
      deviceName: string;
      deviceType: "macbook" | "studio" | "mini" | "unknown";
      totalGB: number;
      currentUsedGB: number;
      modelUsageGB: number;
      currentPercent: number;
      newPercent: number;
      isUsed: boolean;
      x: number;
      y: number;
      iconSize: number;
      screenHeight: number;
      currentFillHeight: number;
      modelFillHeight: number;
    }> = [];

    // Use API placement data directly
    const memoryDelta = apiPreview?.memory_delta_by_node ?? {};
    placementNodes = nodeArray.map((n, i) => {
      const deltaBytes = memoryDelta[n.id] ?? 0;
      const modelUsageGB = deltaBytes / (1024 * 1024 * 1024);
      const isUsed = deltaBytes > 0;
      const angle =
        numNodes === 1 ? 0 : (i / numNodes) * Math.PI * 2 - Math.PI / 2;
      const safeTotal = Math.max(n.totalGB, 0.001);
      const currentPercent = clampPercent((n.usedGB / safeTotal) * 100);
      const newPercent = clampPercent(
        ((n.usedGB + modelUsageGB) / safeTotal) * 100,
      );
      const screenHeight = iconSize * 0.58;

      return {
        id: n.id,
        deviceName: n.deviceName,
        deviceType: n.deviceType,
        totalGB: n.totalGB,
        currentUsedGB: n.usedGB,
        modelUsageGB,
        currentPercent,
        newPercent,
        isUsed,
        x: centerX + Math.cos(angle) * radius,
        y: centerY + Math.sin(angle) * radius,
        iconSize,
        screenHeight,
        currentFillHeight: screenHeight * (currentPercent / 100),
        modelFillHeight: screenHeight * ((newPercent - currentPercent) / 100),
      };
    });

    const totalAvailable = nodeArray.reduce((sum, n) => sum + n.availableGB, 0);
    return {
      nodes: placementNodes,
      canFit: hasApiPreview,
      totalAvailable,
      topoWidth,
      topoHeight,
      error,
    };
  });

  const canFit = $derived(
    apiPreview ? apiPreview.error === null : placementPreview().canFit,
  );
  const placementError = $derived(apiPreview?.error ?? null);
  const nodeCount = $derived(nodeList().length);
  const filterId = $derived(model.id.replace(/[^a-zA-Z0-9]/g, ""));

  // Debug mode state
  const isDebugMode = $derived(debugMode());
  const topology = $derived(topologyData());
  const isRdma = $derived(runtime === "MlxJaccl");

  // Get interface name for an IP from node data
  function getInterfaceForIp(nodeId: string, ip?: string): string | null {
    if (!ip || !topology?.nodes) return null;

    // Strip port if present
    const cleanIp =
      ip.includes(":") && !ip.includes("[") ? ip.split(":")[0] : ip;

    // Check specified node first
    const node = topology.nodes[nodeId];
    if (node) {
      const match = node.network_interfaces?.find((iface) =>
        (iface.addresses || []).some((addr) => addr === cleanIp || addr === ip),
      );
      if (match?.name) return match.name;

      const mapped =
        node.ip_to_interface?.[cleanIp] || node.ip_to_interface?.[ip];
      if (mapped) return mapped;
    }

    // Fallback: check all nodes
    for (const [, otherNode] of Object.entries(topology.nodes)) {
      if (!otherNode) continue;
      const match = otherNode.network_interfaces?.find((iface) =>
        (iface.addresses || []).some((addr) => addr === cleanIp || addr === ip),
      );
      if (match?.name) return match.name;

      const mapped =
        otherNode.ip_to_interface?.[cleanIp] || otherNode.ip_to_interface?.[ip];
      if (mapped) return mapped;
    }

    return null;
  }

  // Get directional arrow based on node positions
  function getArrow(
    fromNode: { x: number; y: number },
    toNode: { x: number; y: number },
  ): string {
    const dx = toNode.x - fromNode.x;
    const dy = toNode.y - fromNode.y;
    const absX = Math.abs(dx);
    const absY = Math.abs(dy);

    if (absX > absY * 2) {
      return dx > 0 ? "→" : "←";
    } else if (absY > absX * 2) {
      return dy > 0 ? "↓" : "↑";
    } else {
      if (dx > 0 && dy > 0) return "↘";
      if (dx > 0 && dy < 0) return "↗";
      if (dx < 0 && dy > 0) return "↙";
      return "↖";
    }
  }

  // Get connection info for edges between two nodes
  // Returns exactly one connection per direction (A→B and B→A), preferring non-loopback
  function getConnectionInfo(
    nodeId1: string,
    nodeId2: string,
  ): Array<{ ip: string; iface: string | null; from: string; to: string }> {
    if (!topology?.edges) return [];

    // Collect candidates for each direction
    const aToBCandidates: Array<{ ip: string; iface: string | null }> = [];
    const bToACandidates: Array<{ ip: string; iface: string | null }> = [];

    for (const edge of topology.edges) {
      let ip: string;
      let iface: string | null;

      if (edge.sourceRdmaIface || edge.sinkRdmaIface) {
        ip = "RDMA";
        iface = `${edge.sourceRdmaIface || "?"} \u2192 ${edge.sinkRdmaIface || "?"}`;
      } else {
        ip = edge.sendBackIp || "?";
        iface = edge.sendBackInterface || getInterfaceForIp(edge.source, ip);
      }

      if (edge.source === nodeId1 && edge.target === nodeId2) {
        aToBCandidates.push({ ip, iface });
      } else if (edge.source === nodeId2 && edge.target === nodeId1) {
        bToACandidates.push({ ip, iface });
      }
    }

    // Pick best (prefer non-loopback)
    const pickBest = (
      candidates: Array<{ ip: string; iface: string | null }>,
    ) => {
      if (candidates.length === 0) return null;
      return candidates.find((c) => !c.ip.startsWith("127.")) || candidates[0];
    };

    const result: Array<{
      ip: string;
      iface: string | null;
      from: string;
      to: string;
    }> = [];

    const bestAtoB = pickBest(aToBCandidates);
    if (bestAtoB) result.push({ ...bestAtoB, from: nodeId1, to: nodeId2 });

    const bestBtoA = pickBest(bToACandidates);
    if (bestBtoA) result.push({ ...bestBtoA, from: nodeId2, to: nodeId1 });

    return result;
  }
</script>

<div class="relative group">
  <!-- Corner accents -->
  <div
    class="absolute -top-px -left-px w-2 h-2 border-l border-t {canFit
      ? 'border-exo-yellow/30 group-hover:border-exo-yellow/60'
      : 'border-red-500/30'} transition-colors"
  ></div>
  <div
    class="absolute -top-px -right-px w-2 h-2 border-r border-t {canFit
      ? 'border-exo-yellow/30 group-hover:border-exo-yellow/60'
      : 'border-red-500/30'} transition-colors"
  ></div>
  <div
    class="absolute -bottom-px -left-px w-2 h-2 border-l border-b {canFit
      ? 'border-exo-yellow/30 group-hover:border-exo-yellow/60'
      : 'border-red-500/30'} transition-colors"
  ></div>
  <div
    class="absolute -bottom-px -right-px w-2 h-2 border-r border-b {canFit
      ? 'border-exo-yellow/30 group-hover:border-exo-yellow/60'
      : 'border-red-500/30'} transition-colors"
  ></div>

  <div
    class="bg-exo-dark-gray/60 border {canFit
      ? 'border-exo-yellow/20 group-hover:border-exo-yellow/40'
      : 'border-red-500/20'} p-3 transition-all duration-200 group-hover:shadow-[0_0_15px_rgba(255,215,0,0.1)]"
  >
    <!-- Model Name & Memory Required -->
    <div class="flex items-start justify-between gap-2 mb-2">
      <div class="flex-1 min-w-0">
        <div class="flex items-center gap-2">
          <div
            class="text-exo-yellow text-xs font-mono tracking-wide truncate"
            title={model.name || model.id}
          >
            {model.name || model.id}
          </div>
          {#if huggingFaceModelId}
            <a
              class="shrink-0 text-white/60 hover:text-exo-yellow transition-colors"
              href={`https://huggingface.co/${huggingFaceModelId}`}
              target="_blank"
              rel="noreferrer noopener"
              aria-label="View model on Hugging Face"
            >
              <svg
                class="w-3.5 h-3.5"
                viewBox="0 0 24 24"
                fill="none"
                stroke="currentColor"
                stroke-width="2"
                stroke-linecap="round"
                stroke-linejoin="round"
              >
                <path d="M14 3h7v7" />
                <path d="M10 14l11-11" />
                <path
                  d="M21 14v6a1 1 0 0 1-1 1h-16a1 1 0 0 1-1-1v-16a1 1 0 0 1 1-1h6"
                />
              </svg>
            </a>
          {/if}
          {#if tags.length > 0}
            <div class="flex gap-1 flex-shrink-0">
              {#each tags as tag}
                <span
                  class="px-1.5 py-0.5 text-xs font-mono tracking-wider uppercase rounded {tag ===
                  'FASTEST'
                    ? 'bg-green-500/20 text-green-400 border border-green-500/30'
                    : 'bg-purple-500/20 text-purple-400 border border-purple-500/30'}"
                >
                  {tag}
                </span>
              {/each}
            </div>
          {/if}
        </div>
        {#if model.name && model.name !== model.id}
          <div
            class="text-xs text-exo-light-gray font-mono truncate mt-0.5"
            title={model.id}
          >
            {model.id}
          </div>
        {/if}
      </div>
      <div class="flex-shrink-0 text-right">
        <div
          class="text-xs font-mono {canFit
            ? 'text-exo-yellow'
            : 'text-red-400'}"
        >
          {estimatedMemory}GB
        </div>
      </div>
    </div>

    <!-- Configuration Badge -->
    <div class="flex items-center gap-1.5 mb-2">
      <span
        class="px-1.5 py-0.5 text-xs font-mono tracking-wider uppercase bg-exo-medium-gray/30 text-exo-light-gray border border-exo-medium-gray/40"
        title={sharding === "Pipeline"
          ? "Pipeline: splits model into sequential stages across devices. Lower network overhead."
          : "Tensor: splits each layer across devices. Best with high-bandwidth connections (Thunderbolt)."}
      >
        {sharding}
      </span>
      <span
        class="px-1.5 py-0.5 text-xs font-mono tracking-wider uppercase bg-exo-medium-gray/30 text-exo-light-gray border border-exo-medium-gray/40"
        title={runtime === "MlxRing"
          ? "Ring: standard networking. Works over any connection (Wi-Fi, Ethernet, Thunderbolt)."
          : "RDMA: direct memory access over Thunderbolt. Significantly faster for multi-device inference."}
      >
        {runtime === "MlxRing"
          ? "MLX Ring"
          : runtime === "MlxJaccl"
            ? "MLX RDMA"
            : runtime}
      </span>
    </div>

    <!-- Download Status (per-node) -->
    {#if perNode.length > 0}
      <div class="mb-2 space-y-1">
        <div
          class="text-[10px] font-mono text-white/20 tracking-widest uppercase"
        >
          Download progress
        </div>
        {#each perNode as node}
          <div class="flex items-center gap-2 text-xs font-mono">
            <span class="text-white/40 w-20 truncate" title={node.nodeId}
              >{node.nodeName}</span
            >
            <div
              class="flex-1 h-1 bg-exo-medium-gray/30 rounded overflow-hidden"
            >
              <div
                class="h-full transition-all duration-300 {node.status ===
                'downloading'
                  ? 'bg-blue-500/70'
                  : node.status === 'completed'
                    ? 'bg-exo-yellow/40'
                    : 'bg-white/20'}"
                style="width: {node.percentage}%"
              ></div>
            </div>
            <span
              class="text-right {node.status === 'completed'
                ? 'text-exo-yellow/60'
                : node.status === 'downloading'
                  ? 'text-blue-400/60'
                  : 'text-white/30'}"
            >
              {#if node.status === "downloading" && node.progress}
                {Math.round(node.percentage)}% {formatSpeed(
                  node.progress.speed,
                )}
              {:else}
                {node.percentage > 0 ? `${Math.round(node.percentage)}%` : "0%"}
              {/if}
            </span>
          </div>
        {/each}
      </div>
    {/if}

    <!-- Mini Topology Preview -->
    {#if placementPreview().nodes.length > 0}
      {@const preview = placementPreview()}
      <div
        class="mb-3 bg-exo-black/60 rounded border border-exo-medium-gray/20 p-2 relative overflow-hidden"
      >
        <!-- Scanline effect -->
        <div
          class="absolute inset-0 bg-[repeating-linear-gradient(0deg,transparent,transparent_2px,rgba(255,215,0,0.02)_2px,rgba(255,215,0,0.02)_4px)] pointer-events-none"
        ></div>

        <svg
          width="100%"
          height={preview.topoHeight}
          viewBox="0 0 {preview.topoWidth} {preview.topoHeight}"
          class="overflow-visible"
        >
          <defs>
            <!-- Glow filter for active nodes -->
            <filter
              id="nodeGlow-{filterId}"
              x="-50%"
              y="-50%"
              width="200%"
              height="200%"
            >
              <feGaussianBlur stdDeviation="2" result="blur" />
              <feMerge>
                <feMergeNode in="blur" />
                <feMergeNode in="SourceGraphic" />
              </feMerge>
            </filter>

            <!-- Strong glow for new memory -->
            <filter
              id="memGlow-{filterId}"
              x="-100%"
              y="-100%"
              width="300%"
              height="300%"
            >
              <feGaussianBlur stdDeviation="3" result="blur" />
              <feComposite in="SourceGraphic" in2="blur" operator="over" />
            </filter>
          </defs>

          <!-- Connection lines between nodes (if multiple) -->
          {#if preview.nodes.length > 1}
            {@const usedNodes = preview.nodes.filter((n) => n.isUsed)}
            {@const nodePositions = Object.fromEntries(
              preview.nodes.map((n) => [n.id, { x: n.x, y: n.y }]),
            )}
            {@const allConnections =
              isDebugMode && usedNodes.length > 1
                ? (() => {
                    const conns: Array = [];
                    for (let i = 0; i < usedNodes.length; i++) {
                      for (let j = i + 1; j < usedNodes.length; j++) {
                        const n1 = usedNodes[i];
                        const n2 = usedNodes[j];
                        const midX = (n1.x + n2.x) / 2;
                        const midY = (n1.y + n2.y) / 2;
                        for (const c of getConnectionInfo(n1.id, n2.id)) {
                          const fromPos = nodePositions[c.from];
                          const toPos = nodePositions[c.to];
                          const arrow =
                            fromPos && toPos ? getArrow(fromPos, toPos) : "→";
                          conns.push({
                            ...c,
                            midX,
                            midY,
                            arrow,
                          });
                        }
                      }
                    }
                    return conns;
                  })()
                : []}
            {#each preview.nodes as node, i}
              {#each preview.nodes.slice(i + 1) as node2}
                <line
                  x1={node.x}
                  y1={node.y}
                  x2={node2.x}
                  y2={node2.y}
                  stroke={node.isUsed && node2.isUsed ? "#FFD700" : "#374151"}
                  stroke-width="1"
                  stroke-dasharray={node.isUsed && node2.isUsed ? "4,2" : "2,4"}
                  opacity={node.isUsed && node2.isUsed ? 0.4 : 0.15}
                />
              {/each}
            {/each}
            <!-- Debug: Show connection IPs/interfaces in corners -->
            {#if isDebugMode && allConnections.length > 0}
              {@const centerX = preview.topoWidth / 2}
              {@const centerY = preview.topoHeight / 2}
              {@const quadrants = {
                topLeft: allConnections.filter(
                  (c) => c.midX < centerX && c.midY < centerY,
                ),
                topRight: allConnections.filter(
                  (c) => c.midX >= centerX && c.midY < centerY,
                ),
                bottomLeft: allConnections.filter(
                  (c) => c.midX < centerX && c.midY >= centerY,
                ),
                bottomRight: allConnections.filter(
                  (c) => c.midX >= centerX && c.midY >= centerY,
                ),
              }}
              {@const padding = 4}
              {@const lineHeight = 8}
              <!-- Top Left -->
              {#each quadrants.topLeft as conn, idx}
                <text
                  x={padding}
                  y={padding + idx * lineHeight}
                  text-anchor="start"
                  dominant-baseline="hanging"
                  font-size="6"
                  font-family="SF Mono, Monaco, monospace"
                  fill={conn.iface
                    ? "rgba(255,255,255,0.85)"
                    : "rgba(248,113,113,0.85)"}
                >
                  {conn.arrow}
                  {isRdma
                    ? conn.iface || "?"
                    : `${conn.ip}${conn.iface ? ` (${conn.iface})` : ""}`}
                </text>
              {/each}
              <!-- Top Right -->
              {#each quadrants.topRight as conn, idx}
                <text
                  x={preview.topoWidth - padding}
                  y={padding + idx * lineHeight}
                  text-anchor="end"
                  dominant-baseline="hanging"
                  font-size="6"
                  font-family="SF Mono, Monaco, monospace"
                  fill={conn.iface
                    ? "rgba(255,255,255,0.85)"
                    : "rgba(248,113,113,0.85)"}
                >
                  {conn.arrow}
                  {isRdma
                    ? conn.iface || "?"
                    : `${conn.ip}${conn.iface ? ` (${conn.iface})` : ""}`}
                </text>
              {/each}
              <!-- Bottom Left -->
              {#each quadrants.bottomLeft as conn, idx}
                <text
                  x={padding}
                  y={preview.topoHeight -
                    padding -
                    (quadrants.bottomLeft.length - 1 - idx) * lineHeight}
                  text-anchor="start"
                  dominant-baseline="auto"
                  font-size="6"
                  font-family="SF Mono, Monaco, monospace"
                  fill={conn.iface
                    ? "rgba(255,255,255,0.85)"
                    : "rgba(248,113,113,0.85)"}
                >
                  {conn.arrow}
                  {isRdma
                    ? conn.iface || "?"
                    : `${conn.ip}${conn.iface ? ` (${conn.iface})` : ""}`}
                </text>
              {/each}
              <!-- Bottom Right -->
              {#each quadrants.bottomRight as conn, idx}
                <text
                  x={preview.topoWidth - padding}
                  y={preview.topoHeight -
                    padding -
                    (quadrants.bottomRight.length - 1 - idx) * lineHeight}
                  text-anchor="end"
                  dominant-baseline="auto"
                  font-size="6"
                  font-family="SF Mono, Monaco, monospace"
                  fill={conn.iface
                    ? "rgba(255,255,255,0.85)"
                    : "rgba(248,113,113,0.85)"}
                >
                  {conn.arrow}
                  {isRdma
                    ? conn.iface || "?"
                    : `${conn.ip}${conn.iface ? ` (${conn.iface})` : ""}`}
                </text>
              {/each}
            {/if}
          {/if}

          {#each preview.nodes as node}
            <g
              transform="translate({node.x}, {node.y})"
              opacity={node.isUsed ? 1 : 0.25}
              filter={node.isUsed ? `url(#nodeGlow-${filterId})` : "none"}
            >
              <!-- Device icon based on type -->
              {#if node.deviceType === "macbook"}
                <!-- MacBook Pro icon with memory fill -->
                <g
                  transform="translate({-node.iconSize / 2}, {-node.iconSize /
                    2})"
                >
                  <!-- Screen bezel -->
                  <rect
                    x="2"
                    y="0"
                    width={node.iconSize - 4}
                    height={node.iconSize * 0.65}
                    rx="2"
                    fill="none"
                    stroke={node.isUsed ? "#FFD700" : "#4B5563"}
                    stroke-width="1.5"
                  />
                  <!-- Screen area (memory fill container) -->
                  <rect
                    x="4"
                    y="2"
                    width={node.iconSize - 8}
                    height={node.screenHeight}
                    fill="#0a0a0a"
                  />
                  <!-- Current memory fill (gray) -->
                  <rect
                    x="4"
                    y={2 + node.screenHeight - node.currentFillHeight}
                    width={node.iconSize - 8}
                    height={node.currentFillHeight}
                    fill="#374151"
                  />
                  <!-- New model memory fill (glowing yellow) -->
                  {#if node.modelUsageGB > 0 && node.isUsed}
                    <rect
                      x="4"
                      y={2 +
                        node.screenHeight -
                        node.currentFillHeight -
                        node.modelFillHeight}
                      width={node.iconSize - 8}
                      height={node.modelFillHeight}
                      fill="#FFD700"
                      filter="url(#memGlow-{filterId})"
                      class="animate-pulse-slow"
                    />
                  {/if}
                  <!-- Base/keyboard -->
                  <path
                    d="M 0 {node.iconSize *
                      0.68} L {node.iconSize} {node.iconSize *
                      0.68} L {node.iconSize - 2} {node.iconSize *
                      0.78} L 2 {node.iconSize * 0.78} Z"
                    fill="none"
                    stroke={node.isUsed ? "#FFD700" : "#4B5563"}
                    stroke-width="1.5"
                  />
                </g>
              {:else if node.deviceType === "studio"}
                <!-- Mac Studio icon -->
                <g
                  transform="translate({-node.iconSize / 2}, {-node.iconSize /
                    2})"
                >
                  <rect
                    x="2"
                    y="2"
                    width={node.iconSize - 4}
                    height={node.iconSize - 4}
                    rx="4"
                    fill="none"
                    stroke={node.isUsed ? "#FFD700" : "#4B5563"}
                    stroke-width="1.5"
                  />
                  <!-- Memory fill background -->
                  <rect
                    x="4"
                    y="4"
                    width={node.iconSize - 8}
                    height={node.iconSize - 8}
                    fill="#0a0a0a"
                  />
                  <!-- Current memory fill -->
                  <rect
                    x="4"
                    y={4 +
                      (node.iconSize - 8) * (1 - node.currentPercent / 100)}
                    width={node.iconSize - 8}
                    height={(node.iconSize - 8) * (node.currentPercent / 100)}
                    fill="#374151"
                  />
                  <!-- New model memory fill -->
                  {#if node.modelUsageGB > 0 && node.isUsed}
                    <rect
                      x="4"
                      y={4 + (node.iconSize - 8) * (1 - node.newPercent / 100)}
                      width={node.iconSize - 8}
                      height={(node.iconSize - 8) *
                        ((node.newPercent - node.currentPercent) / 100)}
                      fill="#FFD700"
                      filter="url(#memGlow-{filterId})"
                      class="animate-pulse-slow"
                    />
                  {/if}
                </g>
              {:else if node.deviceType === "mini"}
                <!-- Mac Mini icon -->
                <g
                  transform="translate({-node.iconSize / 2}, {-node.iconSize /
                    2})"
                >
                  <rect
                    x="2"
                    y={node.iconSize * 0.3}
                    width={node.iconSize - 4}
                    height={node.iconSize * 0.4}
                    rx="3"
                    fill="none"
                    stroke={node.isUsed ? "#FFD700" : "#4B5563"}
                    stroke-width="1.5"
                  />
                  <!-- Memory fill background -->
                  <rect
                    x="4"
                    y={node.iconSize * 0.32}
                    width={node.iconSize - 8}
                    height={node.iconSize * 0.36}
                    fill="#0a0a0a"
                  />
                  <!-- Current memory fill -->
                  <rect
                    x="4"
                    y={node.iconSize * 0.32 +
                      node.iconSize * 0.36 * (1 - node.currentPercent / 100)}
                    width={node.iconSize - 8}
                    height={node.iconSize * 0.36 * (node.currentPercent / 100)}
                    fill="#374151"
                  />
                  <!-- New model memory fill -->
                  {#if node.modelUsageGB > 0 && node.isUsed}
                    <rect
                      x="4"
                      y={node.iconSize * 0.32 +
                        node.iconSize * 0.36 * (1 - node.newPercent / 100)}
                      width={node.iconSize - 8}
                      height={node.iconSize *
                        0.36 *
                        ((node.newPercent - node.currentPercent) / 100)}
                      fill="#FFD700"
                      filter="url(#memGlow-{filterId})"
                      class="animate-pulse-slow"
                    />
                  {/if}
                </g>
              {:else}
                <!-- Unknown device - hexagon -->
                <g
                  transform="translate({-node.iconSize / 2}, {-node.iconSize /
                    2})"
                >
                  <polygon
                    points="{node.iconSize /
                      2},0 {node.iconSize},{node.iconSize *
                      0.25} {node.iconSize},{node.iconSize *
                      0.75} {node.iconSize /
                      2},{node.iconSize} 0,{node.iconSize *
                      0.75} 0,{node.iconSize * 0.25}"
                    fill={node.isUsed ? "rgba(255,215,0,0.1)" : "#0a0a0a"}
                    stroke={node.isUsed ? "#FFD700" : "#4B5563"}
                    stroke-width="1.5"
                  />
                </g>
              {/if}

              <!-- Percentage label -->
              <text
                y={node.iconSize / 2 + 12}
                text-anchor="middle"
                font-size="8"
                font-family="SF Mono, Monaco, monospace"
                fill={node.isUsed
                  ? node.newPercent > 90
                    ? "#f87171"
                    : "#FFD700"
                  : "#4B5563"}
              >
                {node.newPercent.toFixed(0)}%
              </text>
            </g>
          {/each}
        </svg>
      </div>
    {/if}

    <!-- Launch Button -->
    <button
      onclick={onLaunch}
      disabled={isLaunching || !canFit}
      class="w-full py-2 text-sm font-mono tracking-wider uppercase border transition-all duration-200
				{isLaunching
        ? 'bg-transparent text-exo-yellow border-exo-yellow/50 cursor-wait'
        : !canFit
          ? 'bg-red-500/10 text-red-400/70 border-red-500/30 cursor-not-allowed'
          : 'bg-transparent text-exo-light-gray border-exo-light-gray/40 hover:text-exo-yellow hover:border-exo-yellow/50 cursor-pointer'}"
    >
      {#if isLaunching}
        <span class="flex items-center justify-center gap-1.5">
          <span
            class="w-2 h-2 border border-exo-yellow border-t-transparent rounded-full animate-spin"
          ></span>
          LAUNCHING...
        </span>
      {:else if !canFit}
        INSUFFICIENT MEMORY
      {:else}
        ▸ LAUNCH
      {/if}
    </button>
  </div>
</div>

<style>
  @keyframes pulse-slow {
    0%,
    100% {
      opacity: 0.8;
    }
    50% {
      opacity: 1;
    }
  }
  .animate-pulse-slow {
    animation: pulse-slow 1.5s ease-in-out infinite;
  }
</style>