← back to Exo

dashboard/src/routes/downloads/+page.svelte

852 lines

<script lang="ts">
  import { onMount } from "svelte";
  import { fade, fly } from "svelte/transition";
  import { cubicOut } from "svelte/easing";
  import {
    topologyData,
    downloads,
    nodeDisk,
    refreshState,
    lastUpdate as lastUpdateStore,
    startDownload,
    cancelDownload,
    deleteDownload,
  } from "$lib/stores/app.svelte";
  import {
    getDownloadTag,
    extractModelIdFromDownload,
    extractShardMetadata,
  } from "$lib/utils/downloads";
  import HeaderNav from "$lib/components/HeaderNav.svelte";

  type CellStatus =
    | { kind: "completed"; totalBytes: number; modelDirectory?: string }
    | {
        kind: "downloading";
        percentage: number;
        downloadedBytes: number;
        totalBytes: number;
        speed: number;
        etaMs: number;
        modelDirectory?: string;
      }
    | {
        kind: "pending";
        downloaded: number;
        total: number;
        modelDirectory?: string;
      }
    | { kind: "failed"; modelDirectory?: string }
    | { kind: "not_present" };

  type ModelCardInfo = {
    family: string;
    quantization: string;
    baseModel: string;
    capabilities: string[];
    storageSize: number;
    nLayers: number;
    supportsTensor: boolean;
  };

  type ModelRow = {
    modelId: string;
    prettyName: string | null;
    cells: Record<string, CellStatus>;
    shardMetadata: Record<string, unknown> | null;
    modelCard: ModelCardInfo | null;
  };

  type NodeColumn = {
    nodeId: string;
    label: string;
    diskAvailable?: number;
    diskTotal?: number;
  };

  const data = $derived(topologyData());
  const downloadsData = $derived(downloads());
  const nodeDiskData = $derived(nodeDisk());

  function getNodeLabel(nodeId: string): string {
    const node = data?.nodes?.[nodeId];
    if (!node) return nodeId.slice(0, 8);
    return (
      node.friendly_name || node.system_info?.model_id || nodeId.slice(0, 8)
    );
  }

  function getBytes(value: unknown): number {
    if (typeof value === "number") return value;
    if (value && typeof value === "object") {
      const v = value as Record<string, unknown>;
      if (typeof v.inBytes === "number") return v.inBytes;
    }
    return 0;
  }

  function formatBytes(bytes: number): string {
    if (!bytes || bytes <= 0) return "0B";
    const units = ["B", "KB", "MB", "GB", "TB"];
    const i = Math.min(
      Math.floor(Math.log(bytes) / Math.log(1024)),
      units.length - 1,
    );
    const val = bytes / Math.pow(1024, i);
    return `${val.toFixed(val >= 10 ? 0 : 1)}${units[i]}`;
  }

  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`;
  }

  function formatSpeed(bytesPerSecond: number): string {
    if (!bytesPerSecond || bytesPerSecond <= 0) return "--";
    const units = ["B/s", "KB/s", "MB/s", "GB/s"];
    const i = Math.min(
      Math.floor(Math.log(bytesPerSecond) / Math.log(1024)),
      units.length - 1,
    );
    const val = bytesPerSecond / Math.pow(1024, i);
    return `${val.toFixed(val >= 10 ? 0 : 1)}${units[i]}`;
  }

  function clampPercent(value: number | undefined): number {
    if (!Number.isFinite(value)) return 0;
    return Math.min(100, Math.max(0, value as number));
  }

  const CELL_PRIORITY: Record<CellStatus["kind"], number> = {
    completed: 4,
    downloading: 3,
    pending: 2,
    failed: 1,
    not_present: 0,
  };

  function shouldUpgradeCell(
    existing: CellStatus,
    candidate: CellStatus,
  ): boolean {
    return CELL_PRIORITY[candidate.kind] > CELL_PRIORITY[existing.kind];
  }

  function extractModelCard(payload: Record<string, unknown>): {
    prettyName: string | null;
    card: ModelCardInfo | null;
  } {
    const shardMetadata = payload.shard_metadata ?? payload.shardMetadata;
    if (!shardMetadata || typeof shardMetadata !== "object")
      return { prettyName: null, card: null };
    const shardObj = shardMetadata as Record<string, unknown>;
    const shardKeys = Object.keys(shardObj);
    if (shardKeys.length !== 1) return { prettyName: null, card: null };
    const shardData = shardObj[shardKeys[0]] as Record<string, unknown>;
    const modelMeta = shardData?.model_card ?? shardData?.modelCard;
    if (!modelMeta || typeof modelMeta !== "object")
      return { prettyName: null, card: null };
    const meta = modelMeta as Record<string, unknown>;

    const prettyName = (meta.prettyName as string) ?? null;

    const card: ModelCardInfo = {
      family: (meta.family as string) ?? "",
      quantization: (meta.quantization as string) ?? "",
      baseModel:
        (meta.base_model as string) ?? (meta.baseModel as string) ?? "",
      capabilities: Array.isArray(meta.capabilities)
        ? (meta.capabilities as string[])
        : [],
      storageSize: getBytes(meta.storage_size ?? meta.storageSize),
      nLayers: (meta.n_layers as number) ?? (meta.nLayers as number) ?? 0,
      supportsTensor:
        (meta.supports_tensor as boolean) ??
        (meta.supportsTensor as boolean) ??
        false,
    };

    return { prettyName, card };
  }

  let modelRows = $state<ModelRow[]>([]);
  let nodeColumns = $state<NodeColumn[]>([]);
  let infoRow = $state<ModelRow | null>(null);

  $effect(() => {
    try {
      if (!downloadsData || Object.keys(downloadsData).length === 0) {
        modelRows = [];
        nodeColumns = [];
        return;
      }

      const allNodeIds = Object.keys(downloadsData);
      const columns: NodeColumn[] = allNodeIds.map((nodeId) => {
        const diskInfo = nodeDiskData?.[nodeId];
        return {
          nodeId,
          label: getNodeLabel(nodeId),
          diskAvailable: diskInfo?.available?.inBytes,
          diskTotal: diskInfo?.total?.inBytes,
        };
      });

      const rowMap = new Map<string, ModelRow>();

      for (const [nodeId, nodeDownloads] of Object.entries(downloadsData)) {
        const entries = Array.isArray(nodeDownloads)
          ? nodeDownloads
          : nodeDownloads && typeof nodeDownloads === "object"
            ? Object.values(nodeDownloads as Record<string, unknown>)
            : [];

        for (const entry of entries) {
          const tagged = getDownloadTag(entry);
          if (!tagged) continue;
          const [tag, payload] = tagged;

          const modelId =
            extractModelIdFromDownload(payload) ?? "unknown-model";
          const { prettyName, card } = extractModelCard(payload);

          if (!rowMap.has(modelId)) {
            rowMap.set(modelId, {
              modelId,
              prettyName,
              cells: {},
              shardMetadata: extractShardMetadata(payload),
              modelCard: card,
            });
          }
          const row = rowMap.get(modelId)!;
          if (prettyName && !row.prettyName) row.prettyName = prettyName;
          if (!row.shardMetadata)
            row.shardMetadata = extractShardMetadata(payload);
          if (!row.modelCard && card) row.modelCard = card;

          const modelDirectory =
            ((payload.model_directory ?? payload.modelDirectory) as string) ||
            undefined;
          let cell: CellStatus;
          if (tag === "DownloadCompleted") {
            const totalBytes = getBytes(payload.total);
            cell = { kind: "completed", totalBytes, modelDirectory };
          } else if (tag === "DownloadOngoing") {
            const rawProgress =
              payload.download_progress ?? payload.downloadProgress ?? {};
            const prog = rawProgress as Record<string, unknown>;
            const totalBytes = getBytes(prog.total ?? payload.total);
            const downloadedBytes = getBytes(prog.downloaded);
            const speed = (prog.speed as number) ?? 0;
            const etaMs =
              (prog.eta_ms as number) ?? (prog.etaMs as number) ?? 0;
            const percentage =
              totalBytes > 0 ? (downloadedBytes / totalBytes) * 100 : 0;
            cell = {
              kind: "downloading",
              percentage: clampPercent(percentage),
              downloadedBytes,
              totalBytes,
              speed,
              etaMs,
              modelDirectory,
            };
          } else if (tag === "DownloadFailed") {
            cell = { kind: "failed", modelDirectory };
          } else {
            const downloaded = getBytes(
              payload.downloaded ??
                payload.downloaded_bytes ??
                payload.downloadedBytes,
            );
            const total = getBytes(
              payload.total ?? payload.total_bytes ?? payload.totalBytes,
            );
            cell = {
              kind: "pending",
              downloaded,
              total,
              modelDirectory,
            };
          }

          const existing = row.cells[nodeId];
          if (!existing || shouldUpgradeCell(existing, cell)) {
            row.cells[nodeId] = cell;
          }
        }
      }

      function rowSortKey(row: ModelRow): number {
        // in progress (4) -> completed (3) -> paused (2) -> not started (1) -> not present (0)
        let best = 0;
        for (const cell of Object.values(row.cells)) {
          let score = 0;
          if (cell.kind === "downloading") score = 4;
          else if (cell.kind === "completed") score = 3;
          else if (cell.kind === "pending" && cell.downloaded > 0)
            score = 2; // paused
          else if (cell.kind === "pending" || cell.kind === "failed") score = 1; // not started
          if (score > best) best = score;
        }
        return best;
      }

      function totalCompletedBytes(row: ModelRow): number {
        let total = 0;
        for (const cell of Object.values(row.cells)) {
          if (cell.kind === "completed") total += cell.totalBytes;
        }
        return total;
      }

      const rows = Array.from(rowMap.values()).sort((a, b) => {
        const aPriority = rowSortKey(a);
        const bPriority = rowSortKey(b);
        if (aPriority !== bPriority) return bPriority - aPriority;
        // Within completed or paused, sort by biggest size first
        if (aPriority === 3 && bPriority === 3) {
          const sizeDiff = totalCompletedBytes(b) - totalCompletedBytes(a);
          if (sizeDiff !== 0) return sizeDiff;
        }
        if (aPriority === 2 && bPriority === 2) {
          const aSize = Math.max(
            ...Object.values(a.cells).map((c) =>
              c.kind === "pending" ? c.total : 0,
            ),
          );
          const bSize = Math.max(
            ...Object.values(b.cells).map((c) =>
              c.kind === "pending" ? c.total : 0,
            ),
          );
          if (aSize !== bSize) return bSize - aSize;
        }
        return a.modelId.localeCompare(b.modelId);
      });

      modelRows = rows;
      nodeColumns = columns;
    } catch (err) {
      console.error("Parse downloads error", err);
      modelRows = [];
      nodeColumns = [];
    }
  });

  const hasDownloads = $derived(modelRows.length > 0);
  const lastUpdateTs = $derived(lastUpdateStore());
  const downloadKeys = $derived(Object.keys(downloadsData || {}));

  onMount(() => {
    refreshState();
  });
</script>

{#snippet trashIcon()}
  <svg
    class="w-5 h-5"
    viewBox="0 0 20 20"
    fill="none"
    stroke="currentColor"
    stroke-width="2"
  >
    <path
      d="M4 6h12M8 6V4h4v2m1 0v10a1 1 0 01-1 1H8a1 1 0 01-1-1V6h6"
      stroke-linecap="round"
      stroke-linejoin="round"
    ></path>
  </svg>
{/snippet}

{#snippet downloadIcon(size?: string)}
  <svg
    class={size ?? "w-5 h-5"}
    viewBox="0 0 20 20"
    fill="none"
    stroke="currentColor"
    stroke-width="2"
  >
    <path
      d="M10 3v10m0 0l-3-3m3 3l3-3M3 17h14"
      stroke-linecap="round"
      stroke-linejoin="round"
    ></path>
  </svg>
{/snippet}

{#snippet pauseIcon()}
  <svg class="w-5 h-5" viewBox="0 0 20 20" fill="currentColor">
    <path
      fill-rule="evenodd"
      d="M6 4h2v12H6V4zm6 0h2v12h-2V4z"
      clip-rule="evenodd"
    ></path>
  </svg>
{/snippet}

{#snippet deleteButton(nodeId: string, modelId: string)}
  <button
    type="button"
    class="text-white/50 hover:text-red-400 transition-colors cursor-pointer"
    onclick={() => deleteDownload(nodeId, modelId)}
    title="Delete from this node"
  >
    {@render trashIcon()}
  </button>
{/snippet}

<div class="min-h-screen bg-exo-dark-gray text-white">
  <HeaderNav showHome={true} />
  <div class="max-w-7xl mx-auto px-4 lg:px-8 py-6 space-y-6">
    <div class="flex items-center justify-between gap-4 flex-wrap">
      <div>
        <h1
          class="text-2xl font-mono tracking-[0.2em] uppercase text-exo-yellow"
        >
          Downloads
        </h1>
        <p class="text-sm text-exo-light-gray">
          Overview of models on each node
        </p>
      </div>
      <div class="flex items-center gap-3">
        <button
          type="button"
          class="text-xs font-mono text-exo-light-gray hover:text-exo-yellow transition-colors uppercase border border-exo-medium-gray/40 px-2 py-1 rounded"
          onclick={() => refreshState()}
          title="Force refresh from /state"
        >
          Refresh
        </button>
        <div class="text-[11px] font-mono text-exo-light-gray">
          Last update: {lastUpdateTs
            ? new Date(lastUpdateTs).toLocaleTimeString()
            : "n/a"}
        </div>
      </div>
    </div>

    {#if !hasDownloads}
      <div
        class="rounded border border-exo-medium-gray/30 bg-exo-black/30 p-6 text-center text-exo-light-gray space-y-2"
      >
        <div class="text-sm">
          No downloads found. Start a model download to see progress here.
        </div>
        <div class="text-[11px] text-exo-light-gray/70">
          Download keys detected: {downloadKeys.length === 0
            ? "none"
            : downloadKeys.join(", ")}
        </div>
      </div>
    {:else}
      <div
        class="rounded border border-exo-medium-gray/30 bg-exo-black/30 overflow-x-auto"
      >
        <table class="w-full text-left font-mono text-xs">
          <thead>
            <tr class="border-b border-exo-medium-gray/30">
              <th
                class="sticky left-0 z-10 bg-exo-black px-4 py-3 text-[11px] uppercase tracking-wider text-exo-yellow font-medium whitespace-nowrap border-r border-exo-medium-gray/20"
              >
                Model
              </th>
              {#each nodeColumns as col}
                <th
                  class="px-4 py-3 text-[11px] uppercase tracking-wider text-exo-light-gray font-medium text-center whitespace-nowrap min-w-[120px]"
                >
                  <div>{col.label}</div>
                  {#if col.diskAvailable != null}
                    <div
                      class="text-[9px] text-white/70 normal-case tracking-normal mt-0.5"
                    >
                      {formatBytes(col.diskAvailable)} free
                    </div>
                  {/if}
                </th>
              {/each}
            </tr>
          </thead>
          <tbody>
            {#each modelRows as row}
              <tr
                class="group border-b border-exo-medium-gray/20 hover:bg-exo-medium-gray/10 transition-colors"
              >
                <td
                  class="sticky left-0 z-10 bg-exo-dark-gray group-hover:bg-[oklch(0.18_0_0)] transition-colors px-4 py-3 whitespace-nowrap border-r border-exo-medium-gray/20"
                >
                  <div class="flex items-center gap-2">
                    <div class="min-w-0">
                      <div class="text-white text-xs" title={row.modelId}>
                        {row.prettyName ?? row.modelId}
                      </div>
                      {#if row.prettyName}
                        <div
                          class="text-[10px] text-white/60"
                          title={row.modelId}
                        >
                          {row.modelId}
                        </div>
                      {/if}
                    </div>
                    <button
                      type="button"
                      class="p-1 rounded hover:bg-white/10 transition-colors flex-shrink-0 opacity-60 group-hover:opacity-100"
                      onclick={() => (infoRow = row)}
                      title="View model details"
                    >
                      <svg
                        class="w-4 h-4 text-white/60 hover:text-white/80"
                        viewBox="0 0 24 24"
                        fill="currentColor"
                      >
                        <path
                          d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"
                        />
                      </svg>
                    </button>
                  </div>
                </td>

                {#each nodeColumns as col}
                  {@const cell = row.cells[col.nodeId] ?? {
                    kind: "not_present" as const,
                  }}
                  <td class="px-4 py-3 text-center align-middle">
                    {#if cell.kind === "completed"}
                      <div
                        class="flex flex-col items-center gap-1"
                        title="Completed ({formatBytes(cell.totalBytes)})"
                      >
                        <svg
                          class="w-7 h-7 text-green-400"
                          viewBox="0 0 20 20"
                          fill="currentColor"
                        >
                          <path
                            fill-rule="evenodd"
                            d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
                            clip-rule="evenodd"
                          ></path>
                        </svg>
                        <span class="text-xs text-white/70"
                          >{formatBytes(cell.totalBytes)}</span
                        >
                        {@render deleteButton(col.nodeId, row.modelId)}
                      </div>
                    {:else if cell.kind === "downloading"}
                      <div
                        class="flex flex-col items-center gap-1"
                        title="{formatBytes(
                          cell.downloadedBytes,
                        )} / {formatBytes(cell.totalBytes)} - {formatSpeed(
                          cell.speed,
                        )} - ETA {formatEta(cell.etaMs)}"
                      >
                        <span class="text-exo-yellow text-sm font-medium"
                          >{clampPercent(cell.percentage).toFixed(1)}%</span
                        >
                        <div
                          class="w-16 h-2 bg-exo-black/60 rounded-sm overflow-hidden"
                        >
                          <div
                            class="h-full bg-gradient-to-r from-exo-yellow to-exo-yellow/70 transition-all duration-300"
                            style="width: {clampPercent(
                              cell.percentage,
                            ).toFixed(1)}%"
                          ></div>
                        </div>
                        <span class="text-[10px] text-white/70"
                          >{formatSpeed(cell.speed)}</span
                        >
                        <div class="flex gap-1 mt-0.5">
                          <button
                            type="button"
                            class="text-white/50 hover:text-exo-yellow transition-colors cursor-pointer"
                            onclick={() =>
                              cancelDownload(col.nodeId, row.modelId)}
                            title="Pause download"
                          >
                            {@render pauseIcon()}
                          </button>
                          {@render deleteButton(col.nodeId, row.modelId)}
                        </div>
                      </div>
                    {:else if cell.kind === "pending"}
                      <div
                        class="flex flex-col items-center gap-1"
                        title={cell.downloaded > 0
                          ? `${formatBytes(cell.downloaded)} / ${formatBytes(cell.total)} downloaded (paused)`
                          : "Download pending"}
                      >
                        {#if cell.downloaded > 0 && cell.total > 0}
                          <span class="text-white/70 text-xs"
                            >{formatBytes(cell.downloaded)} / {formatBytes(
                              cell.total,
                            )}</span
                          >
                          <div
                            class="w-full h-1.5 bg-white/10 rounded-full overflow-hidden"
                          >
                            <div
                              class="h-full bg-exo-light-gray/40 rounded-full"
                              style="width: {(
                                (cell.downloaded / cell.total) *
                                100
                              ).toFixed(1)}%"
                            ></div>
                          </div>
                          <div class="flex gap-1">
                            {#if row.shardMetadata}
                              <button
                                type="button"
                                class="text-white/50 hover:text-exo-yellow transition-colors cursor-pointer"
                                onclick={() =>
                                  startDownload(col.nodeId, row.shardMetadata!)}
                                title="Resume download on this node"
                              >
                                {@render downloadIcon()}
                              </button>
                            {:else}
                              <span class="text-white/50 text-[10px]"
                                >paused</span
                              >
                            {/if}
                            {@render deleteButton(col.nodeId, row.modelId)}
                          </div>
                        {:else if row.shardMetadata}
                          <button
                            type="button"
                            class="text-white/50 hover:text-exo-yellow transition-colors cursor-pointer"
                            onclick={() =>
                              startDownload(col.nodeId, row.shardMetadata!)}
                            title="Start download on this node"
                          >
                            {@render downloadIcon("w-6 h-6")}
                          </button>
                        {:else}
                          <span class="text-white/40 text-sm">...</span>
                        {/if}
                      </div>
                    {:else if cell.kind === "failed"}
                      <div
                        class="flex flex-col items-center gap-1"
                        title="Download failed"
                      >
                        <svg
                          class="w-7 h-7 text-red-400"
                          viewBox="0 0 20 20"
                          fill="currentColor"
                        >
                          <path
                            fill-rule="evenodd"
                            d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
                            clip-rule="evenodd"
                          ></path>
                        </svg>
                        <div class="flex gap-1">
                          {#if row.shardMetadata}
                            <button
                              type="button"
                              class="text-white/50 hover:text-exo-yellow transition-colors cursor-pointer"
                              onclick={() =>
                                startDownload(col.nodeId, row.shardMetadata!)}
                              title="Retry download on this node"
                            >
                              {@render downloadIcon()}
                            </button>
                          {/if}
                          {@render deleteButton(col.nodeId, row.modelId)}
                        </div>
                      </div>
                    {:else}
                      <div
                        class="flex flex-col items-center"
                        title="Not on this node"
                      >
                        <span class="text-exo-medium-gray text-lg leading-none"
                          >--</span
                        >
                        {#if row.shardMetadata}
                          <button
                            type="button"
                            class="text-white/50 hover:text-exo-yellow transition-colors mt-0.5 opacity-0 group-hover:opacity-100 cursor-pointer"
                            onclick={() =>
                              startDownload(col.nodeId, row.shardMetadata!)}
                            title="Download to this node"
                          >
                            {@render downloadIcon()}
                          </button>
                        {/if}
                      </div>
                    {/if}
                  </td>
                {/each}
              </tr>
            {/each}
          </tbody>
        </table>
      </div>
    {/if}
  </div>
</div>

<!-- Info modal -->
{#if infoRow}
  <div
    class="fixed inset-0 z-[60] bg-black/60"
    transition:fade={{ duration: 150 }}
    onclick={() => (infoRow = null)}
    role="presentation"
  ></div>
  <div
    class="fixed z-[60] top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[min(80vw,400px)] bg-exo-dark-gray border border-exo-yellow/10 rounded-lg shadow-2xl p-4"
    transition:fly={{ y: 10, duration: 200, easing: cubicOut }}
    role="dialog"
    aria-modal="true"
  >
    <div class="flex items-start justify-between mb-3">
      <h3 class="font-mono text-lg text-white">
        {infoRow.prettyName ?? infoRow.modelId}
      </h3>
      <button
        type="button"
        class="p-1 rounded hover:bg-white/10 transition-colors text-white/50"
        onclick={() => (infoRow = null)}
        title="Close model details"
        aria-label="Close info dialog"
      >
        <svg class="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
          <path
            d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z"
          />
        </svg>
      </button>
    </div>
    <div class="space-y-2 text-xs font-mono">
      <div class="flex items-center gap-2">
        <span class="text-white/40">Model ID:</span>
        <span class="text-white/70">{infoRow.modelId}</span>
      </div>
      {#if infoRow.modelCard}
        {#if infoRow.modelCard.family}
          <div class="flex items-center gap-2">
            <span class="text-white/40">Family:</span>
            <span class="text-white/70">{infoRow.modelCard.family}</span>
          </div>
        {/if}
        {#if infoRow.modelCard.baseModel}
          <div class="flex items-center gap-2">
            <span class="text-white/40">Base model:</span>
            <span class="text-white/70">{infoRow.modelCard.baseModel}</span>
          </div>
        {/if}
        {#if infoRow.modelCard.quantization}
          <div class="flex items-center gap-2">
            <span class="text-white/40">Quantization:</span>
            <span class="text-white/70">{infoRow.modelCard.quantization}</span>
          </div>
        {/if}
        {#if infoRow.modelCard.storageSize > 0}
          <div class="flex items-center gap-2">
            <span class="text-white/40">Size:</span>
            <span class="text-white/70"
              >{formatBytes(infoRow.modelCard.storageSize)}</span
            >
          </div>
        {/if}
        {#if infoRow.modelCard.nLayers > 0}
          <div class="flex items-center gap-2">
            <span class="text-white/40">Layers:</span>
            <span class="text-white/70">{infoRow.modelCard.nLayers}</span>
          </div>
        {/if}
        {#if infoRow.modelCard.capabilities.length > 0}
          <div class="flex items-center gap-2">
            <span class="text-white/40">Capabilities:</span>
            <span class="text-white/70"
              >{infoRow.modelCard.capabilities.join(", ")}</span
            >
          </div>
        {/if}
        <div class="flex items-center gap-2">
          <span class="text-white/40">Tensor parallelism:</span>
          <span class="text-white/70"
            >{infoRow.modelCard.supportsTensor ? "Yes" : "No"}</span
          >
        </div>
      {/if}

      <!-- Per-node download status -->
      {#if nodeColumns.filter((col) => (infoRow?.cells[col.nodeId]?.kind ?? "not_present") !== "not_present").length > 0}
        <div class="mt-3 pt-3 border-t border-exo-yellow/10">
          <div class="flex items-center gap-2 mb-1">
            <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
                class="text-white/40"
                d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"
              />
              <path class="text-green-400" d="m9 13 2 2 4-4" />
            </svg>
            <span class="text-white/40">On nodes:</span>
          </div>
          <div class="flex flex-col gap-1.5 mt-1">
            {#each nodeColumns as col}
              {@const cellStatus = infoRow?.cells[col.nodeId]}
              {#if cellStatus && cellStatus.kind !== "not_present"}
                <div class="flex flex-col gap-0.5">
                  <span
                    class="inline-block w-fit px-1.5 py-0.5 rounded text-[10px] {cellStatus.kind ===
                    'completed'
                      ? 'bg-green-500/10 text-green-400/80 border border-green-500/20'
                      : cellStatus.kind === 'downloading'
                        ? 'bg-exo-yellow/10 text-exo-yellow/80 border border-exo-yellow/20'
                        : cellStatus.kind === 'failed'
                          ? 'bg-red-500/10 text-red-400/80 border border-red-500/20'
                          : 'bg-white/5 text-white/50 border border-white/10'}"
                  >
                    {col.label}
                    {#if cellStatus.kind === "downloading" && "percentage" in cellStatus}
                      ({clampPercent(cellStatus.percentage).toFixed(0)}%)
                    {/if}
                  </span>
                  {#if "modelDirectory" in cellStatus && cellStatus.modelDirectory}
                    <span
                      class="text-[9px] text-white/30 break-all pl-1"
                      title={cellStatus.modelDirectory}
                    >
                      {cellStatus.modelDirectory}
                    </span>
                  {/if}
                </div>
              {/if}
            {/each}
          </div>
        </div>
      {/if}
    </div>
  </div>
{/if}

<style>
  table {
    min-width: max-content;
  }
</style>