← back to Exo
dashboard: redesign downloads page as model×node table (#1465)
d6301ed593733de84ed00b87032413b736510cf3 · 2026-02-17 06:31:47 -0800 · Alex Cheema
## Motivation
The current downloads page uses a node-centric card grid layout that is
messy and hard to read — the same model across different nodes appears
in separate cards, and deep nesting wastes space. This makes it
difficult to quickly see which models are on which nodes.
## Changes
Rewrote the downloads page
(`dashboard/src/routes/downloads/+page.svelte`) from a card grid to a
clean table layout:
- **Rows** = models (unique across all nodes)
- **Columns** = nodes (with disk free shown in header)
- **Cells** show status at a glance:
- ✅ Green checkmark + size for completed downloads
- 🟡 Yellow percentage + mini progress bar + speed for active downloads
- `...` for pending downloads
- ❌ Red X for failed downloads
- `--` for models not present on a node
- Delete/download action buttons appear on row hover
- Model name column is sticky on horizontal scroll (for many-node
clusters)
- Models sorted by number of nodes with completed downloads
- Imported shared utilities from `$lib/utils/downloads` instead of
inline re-implementations
### Backend: model directory in download events
- Added `model_directory` field to `BaseDownloadProgress` so all
download status events include the on-disk path
- Added `_model_dir()` helper to `DownloadCoordinator` to compute the
path from `EXO_MODELS_DIR`
- Dashboard uses this to show file location and enable "open in Finder"
for completed downloads
### Info modal
- Clicking a model name opens an info modal showing card details
(family, quantization, capabilities, storage size, layer count, tensor
parallelism support)
### Other fixes
- Fixed model name truncation in the table
- Excluded `tests/start_distributed_test.py` from pytest collection (CLI
script that calls `sys.exit()` at import time)
## Test Plan
- [x] `uv run basedpyright` — 0 errors
- [x] `uv run ruff check` — all passed
- [x] `nix fmt` — clean
- [x] `uv run pytest` — 188 passed, 1 skipped
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Files touched
M dashboard/src/routes/downloads/+page.svelteM pyproject.tomlM src/exo/download/coordinator.pyM src/exo/shared/types/worker/downloads.py
Diff
commit d6301ed593733de84ed00b87032413b736510cf3
Author: Alex Cheema <41707476+AlexCheema@users.noreply.github.com>
Date: Tue Feb 17 06:31:47 2026 -0800
dashboard: redesign downloads page as model×node table (#1465)
## Motivation
The current downloads page uses a node-centric card grid layout that is
messy and hard to read — the same model across different nodes appears
in separate cards, and deep nesting wastes space. This makes it
difficult to quickly see which models are on which nodes.
## Changes
Rewrote the downloads page
(`dashboard/src/routes/downloads/+page.svelte`) from a card grid to a
clean table layout:
- **Rows** = models (unique across all nodes)
- **Columns** = nodes (with disk free shown in header)
- **Cells** show status at a glance:
- ✅ Green checkmark + size for completed downloads
- 🟡 Yellow percentage + mini progress bar + speed for active downloads
- `...` for pending downloads
- ❌ Red X for failed downloads
- `--` for models not present on a node
- Delete/download action buttons appear on row hover
- Model name column is sticky on horizontal scroll (for many-node
clusters)
- Models sorted by number of nodes with completed downloads
- Imported shared utilities from `$lib/utils/downloads` instead of
inline re-implementations
### Backend: model directory in download events
- Added `model_directory` field to `BaseDownloadProgress` so all
download status events include the on-disk path
- Added `_model_dir()` helper to `DownloadCoordinator` to compute the
path from `EXO_MODELS_DIR`
- Dashboard uses this to show file location and enable "open in Finder"
for completed downloads
### Info modal
- Clicking a model name opens an info modal showing card details
(family, quantization, capabilities, storage size, layer count, tensor
parallelism support)
### Other fixes
- Fixed model name truncation in the table
- Excluded `tests/start_distributed_test.py` from pytest collection (CLI
script that calls `sys.exit()` at import time)
## Test Plan
- [x] `uv run basedpyright` — 0 errors
- [x] `uv run ruff check` — all passed
- [x] `nix fmt` — clean
- [x] `uv run pytest` — 188 passed, 1 skipped
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
---
dashboard/src/routes/downloads/+page.svelte | 985 +++++++++++++++-------------
pyproject.toml | 2 +-
src/exo/download/coordinator.py | 28 +-
src/exo/shared/types/worker/downloads.py | 1 +
4 files changed, 553 insertions(+), 463 deletions(-)
diff --git a/dashboard/src/routes/downloads/+page.svelte b/dashboard/src/routes/downloads/+page.svelte
index 94e10612..91719dc1 100644
--- a/dashboard/src/routes/downloads/+page.svelte
+++ b/dashboard/src/routes/downloads/+page.svelte
@@ -1,43 +1,59 @@
<script lang="ts">
import { onMount } from "svelte";
+ import { fade, fly } from "svelte/transition";
+ import { cubicOut } from "svelte/easing";
import {
topologyData,
downloads,
nodeDisk,
- type DownloadProgress,
refreshState,
lastUpdate as lastUpdateStore,
startDownload,
deleteDownload,
} from "$lib/stores/app.svelte";
+ import {
+ getDownloadTag,
+ extractModelIdFromDownload,
+ extractShardMetadata,
+ } from "$lib/utils/downloads";
import HeaderNav from "$lib/components/HeaderNav.svelte";
- type FileProgress = {
- name: string;
- totalBytes: number;
- downloadedBytes: number;
- speed: number;
- etaMs: number;
- percentage: number;
+ type CellStatus =
+ | { kind: "completed"; totalBytes: number; modelDirectory?: string }
+ | {
+ kind: "downloading";
+ percentage: number;
+ downloadedBytes: number;
+ totalBytes: number;
+ speed: number;
+ etaMs: number;
+ modelDirectory?: string;
+ }
+ | { kind: "pending"; 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 ModelEntry = {
+ type ModelRow = {
modelId: string;
- prettyName?: string | null;
- percentage: number;
- downloadedBytes: number;
- totalBytes: number;
- speed: number;
- etaMs: number;
- status: "completed" | "downloading";
- files: FileProgress[];
- shardMetadata?: Record<string, unknown>;
+ prettyName: string | null;
+ cells: Record<string, CellStatus>;
+ shardMetadata: Record<string, unknown> | null;
+ modelCard: ModelCardInfo | null;
};
- type NodeEntry = {
+ type NodeColumn = {
nodeId: string;
- nodeName: string;
- models: ModelEntry[];
+ label: string;
diskAvailable?: number;
diskTotal?: number;
};
@@ -102,270 +118,188 @@
return Math.min(100, Math.max(0, value as number));
}
- function extractModelIdFromDownload(
- downloadPayload: Record<string, unknown>,
- ): string | null {
- const shardMetadata =
- downloadPayload.shard_metadata ?? downloadPayload.shardMetadata;
- if (!shardMetadata || typeof shardMetadata !== "object") return null;
+ 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 null;
-
+ if (shardKeys.length !== 1) return { prettyName: null, card: null };
const shardData = shardObj[shardKeys[0]] as Record<string, unknown>;
- if (!shardData) return null;
-
- const modelMeta = shardData.model_card ?? shardData.modelCard;
- if (!modelMeta || typeof modelMeta !== "object") return null;
-
+ const modelMeta = shardData?.model_card ?? shardData?.modelCard;
+ if (!modelMeta || typeof modelMeta !== "object")
+ return { prettyName: null, card: null };
const meta = modelMeta as Record<string, unknown>;
- return (meta.model_id as string) ?? (meta.modelId as string) ?? null;
- }
-
- function parseDownloadProgress(
- payload: Record<string, unknown>,
- ): DownloadProgress | null {
- const progress = payload.download_progress ?? payload.downloadProgress;
- if (!progress || typeof progress !== "object") return null;
- const prog = progress as Record<string, unknown>;
- const totalBytes = getBytes(prog.total_bytes ?? prog.totalBytes);
- const downloadedBytes = getBytes(
- prog.downloaded_bytes ?? prog.downloadedBytes,
- );
- const speed = (prog.speed as number) ?? 0;
- const completedFiles =
- (prog.completed_files as number) ?? (prog.completedFiles as number) ?? 0;
- const totalFiles =
- (prog.total_files as number) ?? (prog.totalFiles as number) ?? 0;
- const etaMs = (prog.eta_ms as number) ?? (prog.etaMs as number) ?? 0;
-
- const files: DownloadProgress["files"] = [];
- const filesObj = (prog.files ?? {}) as Record<string, unknown>;
- for (const [fileName, fileData] of Object.entries(filesObj)) {
- if (!fileData || typeof fileData !== "object") continue;
- const fd = fileData as Record<string, unknown>;
- const fTotal = getBytes(fd.total_bytes ?? fd.totalBytes);
- const fDownloaded = getBytes(fd.downloaded_bytes ?? fd.downloadedBytes);
- files.push({
- name: fileName,
- totalBytes: fTotal,
- downloadedBytes: fDownloaded,
- speed: (fd.speed as number) ?? 0,
- etaMs: (fd.eta_ms as number) ?? (fd.etaMs as number) ?? 0,
- percentage: fTotal > 0 ? (fDownloaded / fTotal) * 100 : 0,
- });
- }
-
- return {
- totalBytes,
- downloadedBytes,
- speed,
- etaMs:
- etaMs ||
- (speed > 0 ? ((totalBytes - downloadedBytes) / speed) * 1000 : 0),
- percentage: totalBytes > 0 ? (downloadedBytes / totalBytes) * 100 : 0,
- completedFiles,
- totalFiles,
- files,
+ 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,
};
- }
- function getBarGradient(percentage: number): string {
- if (percentage >= 100) return "from-green-500 to-green-400";
- if (percentage <= 0) return "from-red-500 to-red-400";
- return "from-exo-yellow to-exo-yellow/70";
+ return { prettyName, card };
}
- let downloadOverview = $state<NodeEntry[]>([]);
+ let modelRows = $state<ModelRow[]>([]);
+ let nodeColumns = $state<NodeColumn[]>([]);
+ let infoRow = $state<ModelRow | null>(null);
$effect(() => {
try {
if (!downloadsData || Object.keys(downloadsData).length === 0) {
- downloadOverview = [];
+ modelRows = [];
+ nodeColumns = [];
return;
}
- const entries = Object.entries(downloadsData);
- const built: NodeEntry[] = [];
+ 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 entries) {
- const modelMap = new Map<string, ModelEntry>();
- const nodeEntries = Array.isArray(nodeDownloads)
+ 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 downloadWrapped of nodeEntries) {
- if (!downloadWrapped || typeof downloadWrapped !== "object") continue;
-
- const keys = Object.keys(downloadWrapped as Record<string, unknown>);
- if (keys.length !== 1) continue;
-
- const downloadKind = keys[0];
- const downloadPayload = (downloadWrapped as Record<string, unknown>)[
- downloadKind
- ] as Record<string, unknown>;
- if (!downloadPayload) continue;
+ for (const entry of entries) {
+ const tagged = getDownloadTag(entry);
+ if (!tagged) continue;
+ const [tag, payload] = tagged;
const modelId =
- extractModelIdFromDownload(downloadPayload) ?? "unknown-model";
- const prettyName = (() => {
- const shardMetadata =
- downloadPayload.shard_metadata ?? downloadPayload.shardMetadata;
- if (!shardMetadata || typeof shardMetadata !== "object")
- return null;
- const shardObj = shardMetadata as Record<string, unknown>;
- const shardKeys = Object.keys(shardObj);
- if (shardKeys.length !== 1) return null;
- const shardData = shardObj[shardKeys[0]] as Record<string, unknown>;
- const modelMeta = shardData?.model_card ?? shardData?.modelCard;
- if (!modelMeta || typeof modelMeta !== "object") return null;
- const meta = modelMeta as Record<string, unknown>;
- return (meta.prettyName as string) ?? null;
- })();
-
- const rawProgress =
- (downloadPayload as Record<string, unknown>).download_progress ??
- (downloadPayload as Record<string, unknown>).downloadProgress ??
- {};
- // For DownloadCompleted, total_bytes is at top level; for DownloadOngoing, it's inside download_progress
- const totalBytes = getBytes(
- (downloadPayload as Record<string, unknown>).total_bytes ??
- (downloadPayload as Record<string, unknown>).totalBytes ??
- (rawProgress as Record<string, unknown>).total_bytes ??
- (rawProgress as Record<string, unknown>).totalBytes,
- );
- const downloadedBytes = getBytes(
- (rawProgress as Record<string, unknown>).downloaded_bytes ??
- (rawProgress as Record<string, unknown>).downloadedBytes,
- );
- const speed =
- ((rawProgress as Record<string, unknown>).speed as number) ?? 0;
- const etaMs =
- ((rawProgress as Record<string, unknown>).eta_ms as number) ??
- ((rawProgress as Record<string, unknown>).etaMs as number) ??
- 0;
- const percentage =
- totalBytes > 0 ? (downloadedBytes / totalBytes) * 100 : 0;
-
- const files: FileProgress[] = [];
- const filesObj = (rawProgress as Record<string, unknown>).files as
- | Record<string, unknown>
- | undefined;
- if (filesObj && typeof filesObj === "object") {
- for (const [fileName, fileData] of Object.entries(filesObj)) {
- if (!fileData || typeof fileData !== "object") continue;
- const fd = fileData as Record<string, unknown>;
- const fTotal = getBytes(fd.total_bytes ?? fd.totalBytes);
- const fDownloaded = getBytes(
- fd.downloaded_bytes ?? fd.downloadedBytes,
- );
- files.push({
- name: fileName,
- totalBytes: fTotal,
- downloadedBytes: fDownloaded,
- speed: (fd.speed as number) ?? 0,
- etaMs: (fd.eta_ms as number) ?? (fd.etaMs as number) ?? 0,
- percentage: clampPercent(
- fTotal > 0 ? (fDownloaded / fTotal) * 100 : 0,
- ),
- });
- }
+ extractModelIdFromDownload(payload) ?? "unknown-model";
+ const { prettyName, card } = extractModelCard(payload);
+
+ if (!rowMap.has(modelId)) {
+ rowMap.set(modelId, {
+ modelId,
+ prettyName,
+ cells: {},
+ shardMetadata: extractShardMetadata(payload),
+ modelCard: card,
+ });
}
-
- // Extract shard_metadata for use with download actions
- const shardMetadata = (downloadPayload.shard_metadata ??
- downloadPayload.shardMetadata) as
- | Record<string, unknown>
- | undefined;
-
- const entry: ModelEntry = {
- modelId,
- prettyName,
- percentage:
- downloadKind === "DownloadCompleted"
- ? 100
- : clampPercent(percentage),
- downloadedBytes,
- totalBytes,
- speed,
- etaMs,
- status:
- downloadKind === "DownloadCompleted"
- ? "completed"
- : "downloading",
- files,
- shardMetadata,
- };
-
- const existing = modelMap.get(modelId);
- if (!existing) {
- modelMap.set(modelId, entry);
- } else if (
- (entry.status === "completed" && existing.status !== "completed") ||
- (entry.status === existing.status &&
- entry.downloadedBytes > existing.downloadedBytes)
- ) {
- modelMap.set(modelId, entry);
+ 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_bytes ?? payload.totalBytes,
+ );
+ 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_bytes ??
+ prog.totalBytes ??
+ payload.total_bytes ??
+ payload.totalBytes,
+ );
+ const downloadedBytes = getBytes(
+ prog.downloaded_bytes ?? prog.downloadedBytes,
+ );
+ 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 {
+ cell = { kind: "pending", modelDirectory };
}
- }
- let models = Array.from(modelMap.values()).sort(
- (a, b) => b.percentage - a.percentage,
- );
- if (models.length === 0 && nodeEntries.length > 0) {
- models = [
- {
- modelId: "Unknown download",
- percentage: 0,
- downloadedBytes: 0,
- totalBytes: 0,
- speed: 0,
- etaMs: 0,
- status: "downloading",
- files: [],
- },
- ];
+ const existing = row.cells[nodeId];
+ if (!existing || shouldUpgradeCell(existing, cell)) {
+ row.cells[nodeId] = cell;
+ }
}
-
- // Get disk info for this node
- const diskInfo = nodeDiskData?.[nodeId];
- const diskAvailable = diskInfo?.available?.inBytes;
- const diskTotal = diskInfo?.total?.inBytes;
-
- built.push({
- nodeId,
- nodeName: getNodeLabel(nodeId),
- models,
- diskAvailable,
- diskTotal,
- });
}
- downloadOverview = built;
+ const rows = Array.from(rowMap.values()).sort((a, b) => {
+ const aCompleted = Object.values(a.cells).filter(
+ (c) => c.kind === "completed",
+ ).length;
+ const bCompleted = Object.values(b.cells).filter(
+ (c) => c.kind === "completed",
+ ).length;
+ if (aCompleted !== bCompleted) return bCompleted - aCompleted;
+ return a.modelId.localeCompare(b.modelId);
+ });
+
+ modelRows = rows;
+ nodeColumns = columns;
} catch (err) {
console.error("Parse downloads error", err);
- downloadOverview = [];
+ modelRows = [];
+ nodeColumns = [];
}
});
- const hasDownloads = $derived(downloadOverview.length > 0);
+ const hasDownloads = $derived(modelRows.length > 0);
const lastUpdateTs = $derived(lastUpdateStore());
const downloadKeys = $derived(Object.keys(downloadsData || {}));
- let expanded = $state<Set<string>>(new Set());
- function toggleExpand(key: string): void {
- const next = new Set(expanded);
- if (next.has(key)) next.delete(key);
- else next.add(key);
- expanded = next;
- }
-
onMount(() => {
- // Ensure we fetch at least once when visiting downloads directly
refreshState();
});
</script>
@@ -415,253 +349,384 @@
</div>
</div>
{:else}
- <div class="downloads-grid gap-4">
- {#each downloadOverview as node}
- <div
- class="rounded border border-exo-medium-gray/30 bg-exo-black/30 p-4 space-y-3 flex flex-col"
- >
- <div class="flex items-center justify-between gap-3">
- <div class="min-w-0 flex-1">
- <div class="text-lg font-mono text-white truncate">
- {node.nodeName}
- </div>
- <div class="text-xs text-exo-light-gray font-mono truncate">
- {node.nodeId}
- </div>
- <div class="text-xs text-exo-light-gray font-mono mt-1">
- {formatBytes(
- node.models
- .filter((m) => m.status === "completed")
- .reduce((sum, m) => sum + m.totalBytes, 0),
- )} models{#if node.diskAvailable != null}
- - {formatBytes(node.diskAvailable)} free{/if}
- </div>
- </div>
- <div
- class="text-xs font-mono uppercase tracking-wider whitespace-nowrap shrink-0 text-right"
- >
- <div>
- <span class="text-green-400"
- >{node.models.filter((m) => m.status === "completed")
- .length}</span
- ><span class="text-exo-yellow">
- / {node.models.length} models</span
- >
- </div>
- </div>
- </div>
-
- {#each node.models as model}
- {@const key = `${node.nodeId}|${model.modelId}`}
- {@const pct = clampPercent(model.percentage)}
- {@const gradient = getBarGradient(pct)}
- {@const isExpanded = expanded.has(key)}
- <div
- class="rounded border border-exo-medium-gray/30 bg-exo-dark-gray/60 p-3 space-y-2"
+ <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"
>
- <div class="flex items-center justify-between gap-3">
- <div class="min-w-0 space-y-0.5">
- <div
- class="text-xs font-mono text-white truncate"
- title={model.prettyName ?? model.modelId}
- >
- {model.prettyName ?? model.modelId}
- </div>
+ 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-[10px] text-exo-light-gray font-mono truncate"
- title={model.modelId}
+ class="text-[9px] text-exo-light-gray/60 normal-case tracking-normal mt-0.5"
>
- {model.modelId}
+ {formatBytes(col.diskAvailable)} free
</div>
- {#if model.status !== "completed"}
- <div class="text-[11px] text-exo-light-gray font-mono">
- {formatBytes(model.downloadedBytes)} / {formatBytes(
- model.totalBytes,
- )}
- </div>
- {/if}
- </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">
- <span
- class="text-xs font-mono {pct >= 100
- ? 'text-green-400'
- : pct <= 0
- ? 'text-red-400'
- : 'text-exo-yellow'}"
+ <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-exo-light-gray/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"
>
- {pct.toFixed(1)}%
- </span>
- {#if model.status !== "completed" && model.shardMetadata}
- <button
- type="button"
- class="text-exo-light-gray hover:text-exo-yellow transition-colors"
- onclick={() =>
- startDownload(node.nodeId, model.shardMetadata!)}
- title="Start download"
+ <svg
+ class="w-4 h-4 text-white/30 hover:text-white/60"
+ 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-0.5"
+ title="Completed ({formatBytes(cell.totalBytes)})"
>
<svg
- class="w-4 h-4"
+ class="w-5 h-5 text-green-400"
viewBox="0 0 20 20"
- fill="none"
- stroke="currentColor"
- stroke-width="2"
+ fill="currentColor"
>
<path
- d="M10 3v10m0 0l-3-3m3 3l3-3M3 17h14"
- stroke-linecap="round"
- stroke-linejoin="round"
+ 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>
- </button>
- {/if}
- {#if model.status === "completed"}
- <button
- type="button"
- class="text-exo-light-gray hover:text-red-400 transition-colors"
- onclick={() =>
- deleteDownload(node.nodeId, model.modelId)}
- title="Delete download"
+ <span class="text-[10px] text-exo-light-gray/70"
+ >{formatBytes(cell.totalBytes)}</span
+ >
+ <button
+ type="button"
+ class="text-exo-light-gray/40 hover:text-red-400 transition-colors mt-0.5"
+ onclick={() =>
+ deleteDownload(col.nodeId, row.modelId)}
+ title="Delete from this node"
+ >
+ <svg
+ class="w-3.5 h-3.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>
+ </button>
+ </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-xs font-medium"
+ >{clampPercent(cell.percentage).toFixed(1)}%</span
+ >
+ <div
+ class="w-14 h-1.5 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-[9px] text-exo-light-gray/60"
+ >{formatSpeed(cell.speed)}</span
+ >
+ </div>
+ {:else if cell.kind === "pending"}
+ <div
+ class="flex flex-col items-center gap-0.5"
+ title="Download pending"
+ >
+ <span class="text-exo-light-gray/50 text-sm">...</span>
+ </div>
+ {:else if cell.kind === "failed"}
+ <div
+ class="flex flex-col items-center gap-0.5"
+ title="Download failed"
>
<svg
- class="w-4 h-4"
+ class="w-5 h-5 text-red-400"
viewBox="0 0 20 20"
- fill="none"
- stroke="currentColor"
- stroke-width="2"
+ fill="currentColor"
>
<path
- d="M4 6h12M8 6V4h4v2m1 0v10a1 1 0 01-1 1H8a1 1 0 01-1-1V6h6"
- stroke-linecap="round"
- stroke-linejoin="round"
+ 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>
- </button>
- {/if}
- <button
- type="button"
- class="text-exo-light-gray hover:text-exo-yellow transition-colors"
- onclick={() => toggleExpand(key)}
- aria-expanded={isExpanded}
- title="Toggle file details"
- >
- <svg
- class="w-4 h-4"
- viewBox="0 0 20 20"
- fill="none"
- stroke="currentColor"
- stroke-width="2"
- >
- <path
- d="M6 8l4 4 4-4"
- class={isExpanded
- ? "transform rotate-180 origin-center transition-transform duration-150"
- : "transition-transform duration-150"}
- ></path>
- </svg>
- </button>
- </div>
- </div>
-
- <div
- class="relative h-2 bg-exo-black/60 rounded-sm overflow-hidden"
- >
- <div
- class={`absolute inset-y-0 left-0 bg-gradient-to-r ${gradient} transition-all duration-300`}
- style={`width: ${pct.toFixed(1)}%`}
- ></div>
- </div>
-
- <div
- class="flex items-center justify-between text-xs font-mono text-exo-light-gray"
- >
- <span
- >{model.status === "completed"
- ? `Completed (${formatBytes(model.totalBytes)})`
- : `${formatSpeed(model.speed)} • ETA ${formatEta(model.etaMs)}`}</span
- >
- {#if model.status !== "completed"}
- <span
- >{model.files.length} file{model.files.length === 1
- ? ""
- : "s"}</span
- >
- {/if}
- </div>
-
- {#if isExpanded}
- <div class="mt-2 space-y-1.5">
- {#if model.files.length === 0}
- <div class="text-[11px] font-mono text-exo-light-gray/70">
- No file details reported.
+ {#if row.shardMetadata}
+ <button
+ type="button"
+ class="text-exo-light-gray/40 hover:text-exo-yellow transition-colors"
+ onclick={() =>
+ startDownload(col.nodeId, row.shardMetadata!)}
+ title="Retry download on this node"
+ >
+ <svg
+ class="w-3.5 h-3.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>
+ </button>
+ {/if}
</div>
{:else}
- {#each model.files as f}
- {@const fpct = clampPercent(f.percentage)}
- {@const fgradient = getBarGradient(fpct)}
- <div
- class="rounded border border-exo-medium-gray/20 bg-exo-black/40 p-2 space-y-1"
+ <div
+ class="flex flex-col items-center"
+ title="Not on this node"
+ >
+ <span class="text-exo-medium-gray text-lg leading-none"
+ >--</span
>
- <div
- class="flex items-center justify-between text-[11px] font-mono text-exo-light-gray/90"
- >
- <span class="truncate pr-2">{f.name}</span>
- <span
- class={fpct >= 100
- ? "text-green-400"
- : fpct <= 0
- ? "text-red-400"
- : "text-exo-yellow"}>{fpct.toFixed(1)}%</span
- >
- </div>
- <div
- class="relative h-1.5 bg-exo-black/60 rounded-sm overflow-hidden"
+ {#if row.shardMetadata}
+ <button
+ type="button"
+ class="text-exo-light-gray/30 hover:text-exo-yellow transition-colors mt-0.5 opacity-0 group-hover:opacity-100"
+ onclick={() =>
+ startDownload(col.nodeId, row.shardMetadata!)}
+ title="Download to this node"
>
- <div
- class={`absolute inset-y-0 left-0 bg-gradient-to-r ${fgradient} transition-all duration-300`}
- style={`width: ${fpct.toFixed(1)}%`}
- ></div>
- </div>
- <div
- class="flex items-center justify-between text-[10px] text-exo-light-gray/70"
- >
- <span
- >{formatBytes(f.downloadedBytes)} / {formatBytes(
- f.totalBytes,
- )}</span
- >
- <span
- >{formatSpeed(f.speed)} • ETA {formatEta(
- f.etaMs,
- )}</span
+ <svg
+ class="w-3.5 h-3.5"
+ viewBox="0 0 20 20"
+ fill="none"
+ stroke="currentColor"
+ stroke-width="2"
>
- </div>
- </div>
- {/each}
+ <path
+ d="M10 3v10m0 0l-3-3m3 3l3-3M3 17h14"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ ></path>
+ </svg>
+ </button>
+ {/if}
+ </div>
{/if}
- </div>
- {/if}
- </div>
+ </td>
+ {/each}
+ </tr>
{/each}
- </div>
- {/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>
- .downloads-grid {
- display: grid;
- grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
- }
- @media (min-width: 1024px) {
- .downloads-grid {
- grid-template-columns: repeat(3, minmax(0, 1fr));
- }
- }
- @media (min-width: 1600px) {
- .downloads-grid {
- grid-template-columns: repeat(4, minmax(0, 1fr));
- }
+ table {
+ min-width: max-content;
}
</style>
diff --git a/pyproject.toml b/pyproject.toml
index def495c7..5d8d79a5 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -132,7 +132,7 @@ markers = [
env = [
"EXO_TESTS=1"
]
-addopts = "-m 'not slow'"
+addopts = "-m 'not slow' --ignore=tests/start_distributed_test.py"
filterwarnings = [
"ignore:builtin type Swig:DeprecationWarning",
]
diff --git a/src/exo/download/coordinator.py b/src/exo/download/coordinator.py
index a05bd6f8..db13ccef 100644
--- a/src/exo/download/coordinator.py
+++ b/src/exo/download/coordinator.py
@@ -14,6 +14,7 @@ from exo.download.download_utils import (
map_repo_download_progress_to_download_progress_data,
)
from exo.download.shard_downloader import ShardDownloader
+from exo.shared.constants import EXO_MODELS_DIR
from exo.shared.models.model_cards import ModelId
from exo.shared.types.commands import (
CancelDownload,
@@ -63,6 +64,9 @@ class DownloadCoordinator:
self.event_sender, self.event_receiver = channel[Event]()
self.shard_downloader.on_progress(self._download_progress_callback)
+ def _model_dir(self, model_id: ModelId) -> str:
+ return str(EXO_MODELS_DIR / model_id.normalize())
+
async def _download_progress_callback(
self, callback_shard: ShardMetadata, progress: RepoDownloadProgress
) -> None:
@@ -74,6 +78,7 @@ class DownloadCoordinator:
shard_metadata=callback_shard,
node_id=self.node_id,
total_bytes=progress.total_bytes,
+ model_directory=self._model_dir(model_id),
)
self.download_status[model_id] = completed
await self.event_sender.send(
@@ -93,6 +98,7 @@ class DownloadCoordinator:
download_progress=map_repo_download_progress_to_download_progress_data(
progress
),
+ model_directory=self._model_dir(model_id),
)
self.download_status[model_id] = ongoing
await self.event_sender.send(
@@ -170,7 +176,11 @@ class DownloadCoordinator:
return
# Emit pending status
- progress = DownloadPending(shard_metadata=shard, node_id=self.node_id)
+ progress = DownloadPending(
+ shard_metadata=shard,
+ node_id=self.node_id,
+ model_directory=self._model_dir(model_id),
+ )
self.download_status[model_id] = progress
await self.event_sender.send(NodeDownloadProgress(download_progress=progress))
@@ -184,6 +194,7 @@ class DownloadCoordinator:
shard_metadata=shard,
node_id=self.node_id,
total_bytes=initial_progress.total_bytes,
+ model_directory=self._model_dir(model_id),
)
self.download_status[model_id] = completed
await self.event_sender.send(
@@ -206,6 +217,7 @@ class DownloadCoordinator:
download_progress=map_repo_download_progress_to_download_progress_data(
initial_progress
),
+ model_directory=self._model_dir(model_id),
)
self.download_status[model_id] = status
self.event_sender.send_nowait(NodeDownloadProgress(download_progress=status))
@@ -219,6 +231,7 @@ class DownloadCoordinator:
shard_metadata=shard,
node_id=self.node_id,
error_message=str(e),
+ model_directory=self._model_dir(model_id),
)
self.download_status[model_id] = failed
await self.event_sender.send(
@@ -253,6 +266,7 @@ class DownloadCoordinator:
pending = DownloadPending(
shard_metadata=current_status.shard_metadata,
node_id=self.node_id,
+ model_directory=self._model_dir(model_id),
)
await self.event_sender.send(
NodeDownloadProgress(download_progress=pending)
@@ -295,11 +309,18 @@ class DownloadCoordinator:
node_id=self.node_id,
shard_metadata=progress.shard,
total_bytes=progress.total_bytes,
+ model_directory=self._model_dir(
+ progress.shard.model_card.model_id
+ ),
)
elif progress.status in ["in_progress", "not_started"]:
if progress.downloaded_bytes_this_session.in_bytes == 0:
status = DownloadPending(
- node_id=self.node_id, shard_metadata=progress.shard
+ node_id=self.node_id,
+ shard_metadata=progress.shard,
+ model_directory=self._model_dir(
+ progress.shard.model_card.model_id
+ ),
)
else:
status = DownloadOngoing(
@@ -308,6 +329,9 @@ class DownloadCoordinator:
download_progress=map_repo_download_progress_to_download_progress_data(
progress
),
+ model_directory=self._model_dir(
+ progress.shard.model_card.model_id
+ ),
)
else:
continue
diff --git a/src/exo/shared/types/worker/downloads.py b/src/exo/shared/types/worker/downloads.py
index 77162e7c..a29edcbf 100644
--- a/src/exo/shared/types/worker/downloads.py
+++ b/src/exo/shared/types/worker/downloads.py
@@ -26,6 +26,7 @@ class DownloadProgressData(CamelCaseModel):
class BaseDownloadProgress(TaggedModel):
node_id: NodeId
shard_metadata: ShardMetadata
+ model_directory: str = ""
class DownloadPending(BaseDownloadProgress):
← 6d1ca668 don't time out node identities (#1493)
·
back to Exo
·
Fix graceful process shutdown in macOS app (#1372) db79c350 →