← back to Exo
feat: show download availability in model picker (#1377)
5c2f29f3f22d81a69eeab4294da4f5d74f9e3dd0 · 2026-02-05 06:32:53 -0800 · Alex Cheema
## Motivation
Users browsing models in the picker need to know which models are
already downloaded and ready to run on their cluster, without having to
check the downloads page separately.
## Changes
- **ModelPickerModal.svelte**: Computes per-model download availability
by checking which nodes have `DownloadCompleted` entries and summing
their total RAM against the model's storage size. Passes availability
data to `ModelPickerGroup`. Enhances the info modal with a "Downloaded
on:" section showing node friendly names with green badges.
- **ModelPickerGroup.svelte**: Accepts new `downloadStatus` prop. Shows
a green checkmark-in-circle icon next to models that are downloaded on
sufficient nodes. Tooltip shows which nodes have the model.
- **+page.svelte**: Passes `downloadsData` and `topologyNodes` to
`ModelPickerModal`.
## Why It Works
The download state from `/state` already tracks per-node completed
downloads. The shared `getNodesWithModelDownloaded()` utility (from PR
#1375) finds nodes with `DownloadCompleted` entries for each model.
Total RAM is summed from the topology node data (using `ram_total`, not
`ram_available`) and compared to the model's `storage_size_megabytes` to
determine if there's enough aggregate memory. This is intentionally a
simple heuristic — not a full placement preview.
## Test Plan
### Manual Testing
<!-- Hardware: (e.g., MacBook Pro M1 Max 32GB, Mac Mini M2 16GB,
connected via Thunderbolt 4) -->
<!-- What you did: -->
- Open the model picker modal
- Verify downloaded models show a green checkmark icon
- Verify the checkmark appears dimmer for models downloaded on nodes
with insufficient total RAM
- Click the (i) info button on a downloaded model
- Verify "Downloaded on:" section appears with correct node names
- Verify models with no downloads show no indicator
### Automated Testing
- Dashboard builds successfully (`npm run build`)
- No new Python changes requiring type checking
> **Note:** This is a chained PR. Base branch is
`alexcheema/topology-download-indicators` (#1375).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Files touched
M dashboard/src/lib/components/HuggingFaceResultItem.svelteM dashboard/src/lib/components/ModelFilterPopover.svelteM dashboard/src/lib/components/ModelPickerGroup.svelteM dashboard/src/lib/components/ModelPickerModal.svelteA dashboard/src/lib/utils/downloads.tsM dashboard/src/routes/+page.svelte
Diff
commit 5c2f29f3f22d81a69eeab4294da4f5d74f9e3dd0
Author: Alex Cheema <41707476+AlexCheema@users.noreply.github.com>
Date: Thu Feb 5 06:32:53 2026 -0800
feat: show download availability in model picker (#1377)
## Motivation
Users browsing models in the picker need to know which models are
already downloaded and ready to run on their cluster, without having to
check the downloads page separately.
## Changes
- **ModelPickerModal.svelte**: Computes per-model download availability
by checking which nodes have `DownloadCompleted` entries and summing
their total RAM against the model's storage size. Passes availability
data to `ModelPickerGroup`. Enhances the info modal with a "Downloaded
on:" section showing node friendly names with green badges.
- **ModelPickerGroup.svelte**: Accepts new `downloadStatus` prop. Shows
a green checkmark-in-circle icon next to models that are downloaded on
sufficient nodes. Tooltip shows which nodes have the model.
- **+page.svelte**: Passes `downloadsData` and `topologyNodes` to
`ModelPickerModal`.
## Why It Works
The download state from `/state` already tracks per-node completed
downloads. The shared `getNodesWithModelDownloaded()` utility (from PR
#1375) finds nodes with `DownloadCompleted` entries for each model.
Total RAM is summed from the topology node data (using `ram_total`, not
`ram_available`) and compared to the model's `storage_size_megabytes` to
determine if there's enough aggregate memory. This is intentionally a
simple heuristic — not a full placement preview.
## Test Plan
### Manual Testing
<!-- Hardware: (e.g., MacBook Pro M1 Max 32GB, Mac Mini M2 16GB,
connected via Thunderbolt 4) -->
<!-- What you did: -->
- Open the model picker modal
- Verify downloaded models show a green checkmark icon
- Verify the checkmark appears dimmer for models downloaded on nodes
with insufficient total RAM
- Click the (i) info button on a downloaded model
- Verify "Downloaded on:" section appears with correct node names
- Verify models with no downloads show no indicator
### Automated Testing
- Dashboard builds successfully (`npm run build`)
- No new Python changes requiring type checking
> **Note:** This is a chained PR. Base branch is
`alexcheema/topology-download-indicators` (#1375).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
---
.../lib/components/HuggingFaceResultItem.svelte | 24 ++++
.../src/lib/components/ModelFilterPopover.svelte | 31 +++++
.../src/lib/components/ModelPickerGroup.svelte | 81 ++++++++++-
.../src/lib/components/ModelPickerModal.svelte | 148 +++++++++++++++++++-
dashboard/src/lib/utils/downloads.ts | 152 +++++++++++++++++++++
dashboard/src/routes/+page.svelte | 2 +
6 files changed, 429 insertions(+), 9 deletions(-)
diff --git a/dashboard/src/lib/components/HuggingFaceResultItem.svelte b/dashboard/src/lib/components/HuggingFaceResultItem.svelte
index 566d8e17..5f35c94a 100644
--- a/dashboard/src/lib/components/HuggingFaceResultItem.svelte
+++ b/dashboard/src/lib/components/HuggingFaceResultItem.svelte
@@ -14,6 +14,7 @@
isAdding: boolean;
onAdd: () => void;
onSelect: () => void;
+ downloadedOnNodes?: string[];
};
let {
@@ -22,6 +23,7 @@
isAdding,
onAdd,
onSelect,
+ downloadedOnNodes = [],
}: HuggingFaceResultItemProps = $props();
function formatNumber(num: number): string {
@@ -45,6 +47,28 @@
<span class="text-sm font-mono text-white truncate" title={model.id}
>{modelName}</span
>
+ {#if downloadedOnNodes.length > 0}
+ <span
+ class="flex-shrink-0"
+ title={`Downloaded on ${downloadedOnNodes.join(", ")}`}
+ >
+ <svg
+ class="w-4 h-4"
+ 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>
+ {/if}
{#if isAdded}
<span
class="px-1.5 py-0.5 text-[10px] font-mono bg-green-500/20 text-green-400 rounded"
diff --git a/dashboard/src/lib/components/ModelFilterPopover.svelte b/dashboard/src/lib/components/ModelFilterPopover.svelte
index 5406618a..d9253066 100644
--- a/dashboard/src/lib/components/ModelFilterPopover.svelte
+++ b/dashboard/src/lib/components/ModelFilterPopover.svelte
@@ -5,6 +5,7 @@
interface FilterState {
capabilities: string[];
sizeRange: { min: number; max: number } | null;
+ downloadedOnly: boolean;
}
type ModelFilterPopoverProps = {
@@ -148,6 +149,36 @@
</div>
</div>
+ <!-- Downloaded only -->
+ <div>
+ <h4 class="text-xs font-mono text-white/50 mb-2">Availability</h4>
+ <button
+ type="button"
+ class="px-2 py-1 text-xs font-mono rounded transition-colors {filters.downloadedOnly
+ ? 'bg-green-500/20 text-green-400 border border-green-500/30'
+ : 'bg-white/5 text-white/60 hover:bg-white/10 border border-transparent'}"
+ onclick={() =>
+ onChange({ ...filters, downloadedOnly: !filters.downloadedOnly })}
+ >
+ <svg
+ class="w-3.5 h-3.5 inline-block"
+ 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="ml-1">Downloaded</span>
+ </button>
+ </div>
+
<!-- Size range -->
<div>
<h4 class="text-xs font-mono text-white/50 mb-2">Model Size</h4>
diff --git a/dashboard/src/lib/components/ModelPickerGroup.svelte b/dashboard/src/lib/components/ModelPickerGroup.svelte
index b3ad425b..86155ea5 100644
--- a/dashboard/src/lib/components/ModelPickerGroup.svelte
+++ b/dashboard/src/lib/components/ModelPickerGroup.svelte
@@ -21,6 +21,12 @@
hasMultipleVariants: boolean;
}
+ type DownloadAvailability = {
+ available: boolean;
+ nodeNames: string[];
+ nodeIds: string[];
+ };
+
type ModelPickerGroupProps = {
group: ModelGroup;
isExpanded: boolean;
@@ -31,6 +37,7 @@
onSelectModel: (modelId: string) => void;
onToggleFavorite: (baseModelId: string) => void;
onShowInfo: (group: ModelGroup) => void;
+ downloadStatusMap?: Map<string, DownloadAvailability>;
};
let {
@@ -43,8 +50,19 @@
onSelectModel,
onToggleFavorite,
onShowInfo,
+ downloadStatusMap,
}: ModelPickerGroupProps = $props();
+ // Group-level download status: show if any variant is downloaded
+ const groupDownloadStatus = $derived.by(() => {
+ if (!downloadStatusMap || downloadStatusMap.size === 0) return undefined;
+ // Return the first available entry (prefer "available" ones)
+ for (const avail of downloadStatusMap.values()) {
+ if (avail.available) return avail;
+ }
+ return downloadStatusMap.values().next().value;
+ });
+
// Format storage size
function formatSize(mb: number | undefined): string {
if (!mb) return "";
@@ -198,10 +216,42 @@
</span>
{/if}
- <!-- Variant count -->
+ <!-- Variant count with size range -->
{#if group.hasMultipleVariants}
+ {@const sizes = group.variants
+ .map((v) => v.storage_size_megabytes || 0)
+ .filter((s) => s > 0)
+ .sort((a, b) => a - b)}
<span class="text-xs font-mono text-white/30 flex-shrink-0">
- {group.variants.length} variants
+ {group.variants.length} variants{#if sizes.length >= 2}{" "}({formatSize(
+ sizes[0],
+ )}-{formatSize(sizes[sizes.length - 1])}){/if}
+ </span>
+ {/if}
+
+ <!-- Download availability indicator -->
+ {#if groupDownloadStatus && groupDownloadStatus.nodeIds.length > 0}
+ <span
+ class="flex-shrink-0"
+ title={groupDownloadStatus.available
+ ? `Ready — downloaded on ${groupDownloadStatus.nodeNames.join(", ")}`
+ : `Downloaded on ${groupDownloadStatus.nodeNames.join(", ")} (may need more nodes)`}
+ >
+ <svg
+ class="w-4 h-4"
+ 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>
{/if}
@@ -305,6 +355,33 @@
{formatSize(variant.storage_size_megabytes)}
</span>
+ <!-- Download indicator for this variant -->
+ {#if downloadStatusMap?.get(variant.id)}
+ {@const variantDl = downloadStatusMap.get(variant.id)}
+ {#if variantDl}
+ <span
+ class="flex-shrink-0"
+ title={`Downloaded on ${variantDl.nodeNames.join(", ")}`}
+ >
+ <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>
+ {/if}
+ {/if}
+
<!-- Check mark if selected -->
{#if isSelected}
<svg
diff --git a/dashboard/src/lib/components/ModelPickerModal.svelte b/dashboard/src/lib/components/ModelPickerModal.svelte
index 40827a0e..562f1b81 100644
--- a/dashboard/src/lib/components/ModelPickerModal.svelte
+++ b/dashboard/src/lib/components/ModelPickerModal.svelte
@@ -5,6 +5,7 @@
import ModelPickerGroup from "./ModelPickerGroup.svelte";
import ModelFilterPopover from "./ModelFilterPopover.svelte";
import HuggingFaceResultItem from "./HuggingFaceResultItem.svelte";
+ import { getNodesWithModelDownloaded } from "$lib/utils/downloads";
interface ModelInfo {
id: string;
@@ -33,6 +34,7 @@
interface FilterState {
capabilities: string[];
sizeRange: { min: number; max: number } | null;
+ downloadedOnly: boolean;
}
interface HuggingFaceModel {
@@ -58,6 +60,15 @@
onDeleteModel: (modelId: string) => Promise<void>;
totalMemoryGB: number;
usedMemoryGB: number;
+ downloadsData?: Record<string, unknown[]>;
+ topologyNodes?: Record<
+ string,
+ {
+ friendly_name?: string;
+ system_info?: { model_id?: string };
+ macmon_info?: { memory?: { ram_total?: number } };
+ }
+ >;
};
let {
@@ -74,6 +85,8 @@
onDeleteModel,
totalMemoryGB,
usedMemoryGB,
+ downloadsData,
+ topologyNodes,
}: ModelPickerModalProps = $props();
// Local state
@@ -81,9 +94,75 @@
let selectedFamily = $state<string | null>(null);
let expandedGroups = $state<Set<string>>(new Set());
let showFilters = $state(false);
- let filters = $state<FilterState>({ capabilities: [], sizeRange: null });
+ let filters = $state<FilterState>({
+ capabilities: [],
+ sizeRange: null,
+ downloadedOnly: false,
+ });
let infoGroup = $state<ModelGroup | null>(null);
+ // Download availability per model group
+ type DownloadAvailability = {
+ available: boolean;
+ nodeNames: string[];
+ nodeIds: string[];
+ };
+
+ function getNodeName(nodeId: string): string {
+ const node = topologyNodes?.[nodeId];
+ return (
+ node?.friendly_name || node?.system_info?.model_id || nodeId.slice(0, 8)
+ );
+ }
+
+ const modelDownloadAvailability = $derived.by(() => {
+ const result = new Map<string, DownloadAvailability>();
+ if (!downloadsData || !topologyNodes) return result;
+
+ for (const model of models) {
+ const nodeIds = getNodesWithModelDownloaded(downloadsData, model.id);
+ if (nodeIds.length === 0) continue;
+
+ // Sum total RAM across nodes that have the model
+ let totalRamBytes = 0;
+ for (const nodeId of nodeIds) {
+ const ramTotal = topologyNodes[nodeId]?.macmon_info?.memory?.ram_total;
+ if (typeof ramTotal === "number") totalRamBytes += ramTotal;
+ }
+
+ const modelSizeBytes = (model.storage_size_megabytes || 0) * 1024 * 1024;
+ result.set(model.id, {
+ available: modelSizeBytes > 0 && totalRamBytes >= modelSizeBytes,
+ nodeNames: nodeIds.map(getNodeName),
+ nodeIds,
+ });
+ }
+ return result;
+ });
+
+ // Aggregate download availability per group (available if ANY variant is available)
+ function getGroupDownloadAvailability(
+ group: ModelGroup,
+ ): DownloadAvailability | undefined {
+ for (const variant of group.variants) {
+ const avail = modelDownloadAvailability.get(variant.id);
+ if (avail && avail.nodeIds.length > 0) return avail;
+ }
+ return undefined;
+ }
+
+ // Get per-variant download map for a group
+ function getVariantDownloadMap(
+ group: ModelGroup,
+ ): Map<string, DownloadAvailability> {
+ const map = new Map<string, DownloadAvailability>();
+ for (const variant of group.variants) {
+ const avail = modelDownloadAvailability.get(variant.id);
+ if (avail && avail.nodeIds.length > 0) map.set(variant.id, avail);
+ }
+ return map;
+ }
+
// HuggingFace Hub state
let hfSearchQuery = $state("");
let hfSearchResults = $state<HuggingFaceModel[]>([]);
@@ -95,15 +174,12 @@
let manualModelId = $state("");
let addModelError = $state<string | null>(null);
- // Reset state when modal opens
+ // Reset transient state when modal opens, but preserve tab selection
$effect(() => {
if (isOpen) {
searchQuery = "";
- selectedFamily = null;
expandedGroups = new Set();
showFilters = false;
- hfSearchQuery = "";
- hfSearchResults = [];
manualModelId = "";
addModelError = null;
}
@@ -339,6 +415,16 @@
});
}
+ // Filter to downloaded models only
+ if (filters.downloadedOnly) {
+ result = result.filter((g) =>
+ g.variants.some((v) => {
+ const avail = modelDownloadAvailability.get(v.id);
+ return avail && avail.nodeIds.length > 0;
+ }),
+ );
+ }
+
// Sort: models that fit first, then by size (largest first)
result.sort((a, b) => {
const aFits = a.variants.some((v) => canModelFit(v.id));
@@ -385,11 +471,13 @@
}
function clearFilters() {
- filters = { capabilities: [], sizeRange: null };
+ filters = { capabilities: [], sizeRange: null, downloadedOnly: false };
}
const hasActiveFilters = $derived(
- filters.capabilities.length > 0 || filters.sizeRange !== null,
+ filters.capabilities.length > 0 ||
+ filters.sizeRange !== null ||
+ filters.downloadedOnly,
);
</script>
@@ -576,6 +664,12 @@
isAdding={addingModelId === model.id}
onAdd={() => handleAddModel(model.id)}
onSelect={() => handleSelectHfModel(model.id)}
+ downloadedOnNodes={downloadsData
+ ? getNodesWithModelDownloaded(
+ downloadsData,
+ model.id,
+ ).map(getNodeName)
+ : []}
/>
{/each}
{/if}
@@ -650,6 +744,7 @@
onSelectModel={handleSelect}
{onToggleFavorite}
onShowInfo={(g) => (infoGroup = g)}
+ downloadStatusMap={getVariantDownloadMap(group)}
/>
{/each}
{/if}
@@ -667,6 +762,11 @@
>{cap}</span
>
{/each}
+ {#if filters.downloadedOnly}
+ <span class="px-1.5 py-0.5 bg-green-500/20 text-green-400 rounded"
+ >Downloaded</span
+ >
+ {/if}
{#if filters.sizeRange}
<span class="px-1.5 py-0.5 bg-exo-yellow/20 text-exo-yellow rounded">
{filters.sizeRange.min}GB - {filters.sizeRange.max}GB
@@ -742,6 +842,40 @@
</div>
</div>
{/if}
+ {#if getGroupDownloadAvailability(infoGroup)?.nodeNames?.length}
+ {@const infoDownload = getGroupDownloadAvailability(infoGroup)}
+ {#if infoDownload}
+ <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">Downloaded on:</span>
+ </div>
+ <div class="flex flex-wrap gap-1 mt-1">
+ {#each infoDownload.nodeNames as nodeName}
+ <span
+ class="px-1.5 py-0.5 bg-green-500/10 text-green-400/80 border border-green-500/20 rounded text-[10px]"
+ >
+ {nodeName}
+ </span>
+ {/each}
+ </div>
+ </div>
+ {/if}
+ {/if}
</div>
</div>
{/if}
diff --git a/dashboard/src/lib/utils/downloads.ts b/dashboard/src/lib/utils/downloads.ts
new file mode 100644
index 00000000..49114910
--- /dev/null
+++ b/dashboard/src/lib/utils/downloads.ts
@@ -0,0 +1,152 @@
+/**
+ * Shared utilities for parsing and querying download state.
+ *
+ * The download state from `/state` is shaped as:
+ * Record<NodeId, Array<TaggedDownloadEntry>>
+ *
+ * Each entry is a tagged union object like:
+ * { "DownloadCompleted": { shard_metadata: { "PipelineShardMetadata": { model_card: { model_id: "..." }, ... } }, ... } }
+ */
+
+/** Unwrap one level of tagged-union envelope, returning [tag, payload]. */
+function unwrapTagged(
+ obj: Record<string, unknown>,
+): [string, Record<string, unknown>] | null {
+ const keys = Object.keys(obj);
+ if (keys.length !== 1) return null;
+ const tag = keys[0];
+ const payload = obj[tag];
+ if (!payload || typeof payload !== "object") return null;
+ return [tag, payload as Record<string, unknown>];
+}
+
+/** Extract the model ID string from a download entry's nested shard_metadata. */
+export function extractModelIdFromDownload(
+ downloadPayload: Record<string, unknown>,
+): string | null {
+ const shardMetadata =
+ downloadPayload.shard_metadata ?? downloadPayload.shardMetadata;
+ if (!shardMetadata || typeof shardMetadata !== "object") return null;
+
+ const unwrapped = unwrapTagged(shardMetadata as Record<string, unknown>);
+ if (!unwrapped) return null;
+ const [, shardData] = unwrapped;
+
+ const modelMeta = shardData.model_card ?? shardData.modelCard;
+ if (!modelMeta || typeof modelMeta !== "object") return null;
+
+ const meta = modelMeta as Record<string, unknown>;
+ return (meta.model_id as string) ?? (meta.modelId as string) ?? null;
+}
+
+/** Extract the shard_metadata object from a download entry payload. */
+export function extractShardMetadata(
+ downloadPayload: Record<string, unknown>,
+): Record<string, unknown> | null {
+ const shardMetadata =
+ downloadPayload.shard_metadata ?? downloadPayload.shardMetadata;
+ if (!shardMetadata || typeof shardMetadata !== "object") return null;
+ return shardMetadata as Record<string, unknown>;
+}
+
+/** Get the download tag (DownloadCompleted, DownloadOngoing, etc.) from a wrapped entry. */
+export function getDownloadTag(
+ entry: unknown,
+): [string, Record<string, unknown>] | null {
+ if (!entry || typeof entry !== "object") return null;
+ return unwrapTagged(entry as Record<string, unknown>);
+}
+
+/**
+ * Iterate over all download entries for a given node, yielding [tag, payload, modelId].
+ */
+function* iterNodeDownloads(
+ nodeDownloads: unknown[],
+): Generator<[string, Record<string, unknown>, string]> {
+ for (const entry of nodeDownloads) {
+ const tagged = getDownloadTag(entry);
+ if (!tagged) continue;
+ const [tag, payload] = tagged;
+ const modelId = extractModelIdFromDownload(payload);
+ if (!modelId) continue;
+ yield [tag, payload, modelId];
+ }
+}
+
+/** Check if a specific model is fully downloaded (DownloadCompleted) on a specific node. */
+export function isModelDownloadedOnNode(
+ downloadsData: Record<string, unknown[]>,
+ nodeId: string,
+ modelId: string,
+): boolean {
+ const nodeDownloads = downloadsData[nodeId];
+ if (!Array.isArray(nodeDownloads)) return false;
+
+ for (const [tag, , entryModelId] of iterNodeDownloads(nodeDownloads)) {
+ if (tag === "DownloadCompleted" && entryModelId === modelId) return true;
+ }
+ return false;
+}
+
+/** Get all node IDs where a model is fully downloaded (DownloadCompleted). */
+export function getNodesWithModelDownloaded(
+ downloadsData: Record<string, unknown[]>,
+ modelId: string,
+): string[] {
+ const result: string[] = [];
+ for (const nodeId of Object.keys(downloadsData)) {
+ if (isModelDownloadedOnNode(downloadsData, nodeId, modelId)) {
+ result.push(nodeId);
+ }
+ }
+ return result;
+}
+
+/**
+ * Find shard metadata for a model from any download entry across all nodes.
+ * Returns the first match found (completed entries are preferred).
+ */
+export function getShardMetadataForModel(
+ downloadsData: Record<string, unknown[]>,
+ modelId: string,
+): Record<string, unknown> | null {
+ let fallback: Record<string, unknown> | null = null;
+
+ for (const nodeDownloads of Object.values(downloadsData)) {
+ if (!Array.isArray(nodeDownloads)) continue;
+
+ for (const [tag, payload, entryModelId] of iterNodeDownloads(
+ nodeDownloads,
+ )) {
+ if (entryModelId !== modelId) continue;
+ const shard = extractShardMetadata(payload);
+ if (!shard) continue;
+
+ if (tag === "DownloadCompleted") return shard;
+ if (!fallback) fallback = shard;
+ }
+ }
+ return fallback;
+}
+
+/**
+ * Get the download status tag for a specific model on a specific node.
+ * Returns the "best" status: DownloadCompleted > DownloadOngoing > others.
+ */
+export function getModelDownloadStatus(
+ downloadsData: Record<string, unknown[]>,
+ nodeId: string,
+ modelId: string,
+): string | null {
+ const nodeDownloads = downloadsData[nodeId];
+ if (!Array.isArray(nodeDownloads)) return null;
+
+ let best: string | null = null;
+ for (const [tag, , entryModelId] of iterNodeDownloads(nodeDownloads)) {
+ if (entryModelId !== modelId) continue;
+ if (tag === "DownloadCompleted") return tag;
+ if (tag === "DownloadOngoing") best = tag;
+ else if (!best) best = tag;
+ }
+ return best;
+}
diff --git a/dashboard/src/routes/+page.svelte b/dashboard/src/routes/+page.svelte
index 288c3991..90389d65 100644
--- a/dashboard/src/routes/+page.svelte
+++ b/dashboard/src/routes/+page.svelte
@@ -3264,4 +3264,6 @@
onDeleteModel={deleteCustomModel}
totalMemoryGB={clusterMemory().total / (1024 * 1024 * 1024)}
usedMemoryGB={clusterMemory().used / (1024 * 1024 * 1024)}
+ {downloadsData}
+ topologyNodes={data?.nodes}
/>
← ffe6396c Add Qwen3-Coder-Next model cards (#1367)
·
back to Exo
·
set exo as the nix default package (#1391) e59ebd98 →