+
+
+
Availability
+
+
+
Model Size
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
;
};
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 @@
{/if}
-
+
{#if group.hasMultipleVariants}
+ {@const sizes = group.variants
+ .map((v) => v.storage_size_megabytes || 0)
+ .filter((s) => s > 0)
+ .sort((a, b) => a - b)}
- {group.variants.length} variants
+ {group.variants.length} variants{#if sizes.length >= 2}{" "}({formatSize(
+ sizes[0],
+ )}-{formatSize(sizes[sizes.length - 1])}){/if}
+
+ {/if}
+
+
+ {#if groupDownloadStatus && groupDownloadStatus.nodeIds.length > 0}
+
+
{/if}
@@ -305,6 +355,33 @@
{formatSize(variant.storage_size_megabytes)}
+
+ {#if downloadStatusMap?.get(variant.id)}
+ {@const variantDl = downloadStatusMap.get(variant.id)}
+ {#if variantDl}
+
+
+
+ {/if}
+ {/if}
+
{#if isSelected}
{/if}
+ {#if getGroupDownloadAvailability(infoGroup)?.nodeNames?.length}
+ {@const infoDownload = getGroupDownloadAvailability(infoGroup)}
+ {#if infoDownload}
+
+
+
+
Downloaded on:
+
+
+ {#each infoDownload.nodeNames as nodeName}
+
+ {nodeName}
+
+ {/each}
+
+
+ {/if}
+ {/if}
{/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>
+ *
+ * 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, Record] | 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];
+}
+
+/** Extract the model ID string from a download entry's nested shard_metadata. */
+export function extractModelIdFromDownload(
+ downloadPayload: Record,
+): string | null {
+ const shardMetadata =
+ downloadPayload.shard_metadata ?? downloadPayload.shardMetadata;
+ if (!shardMetadata || typeof shardMetadata !== "object") return null;
+
+ const unwrapped = unwrapTagged(shardMetadata as Record);
+ 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;
+ 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,
+): Record | null {
+ const shardMetadata =
+ downloadPayload.shard_metadata ?? downloadPayload.shardMetadata;
+ if (!shardMetadata || typeof shardMetadata !== "object") return null;
+ return shardMetadata as Record;
+}
+
+/** Get the download tag (DownloadCompleted, DownloadOngoing, etc.) from a wrapped entry. */
+export function getDownloadTag(
+ entry: unknown,
+): [string, Record] | null {
+ if (!entry || typeof entry !== "object") return null;
+ return unwrapTagged(entry as Record);
+}
+
+/**
+ * Iterate over all download entries for a given node, yielding [tag, payload, modelId].
+ */
+function* iterNodeDownloads(
+ nodeDownloads: unknown[],
+): Generator<[string, Record, 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,
+ 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,
+ 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,
+ modelId: string,
+): Record | null {
+ let fallback: Record | 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,
+ 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}
/>