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 @@ {modelName} + {#if downloadedOnNodes.length > 0} + + + + + + + {/if} {#if isAdded} + +
+

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} Promise; totalMemoryGB: number; usedMemoryGB: number; + downloadsData?: Record; + 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(null); let expandedGroups = $state>(new Set()); let showFilters = $state(false); - let filters = $state({ capabilities: [], sizeRange: null }); + let filters = $state({ + capabilities: [], + sizeRange: null, + downloadedOnly: false, + }); let infoGroup = $state(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(); + 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 { + const map = new Map(); + 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([]); @@ -95,15 +174,12 @@ let manualModelId = $state(""); let addModelError = $state(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, ); @@ -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} {/each} + {#if filters.downloadedOnly} + Downloaded + {/if} {#if filters.sizeRange} {filters.sizeRange.min}GB - {filters.sizeRange.max}GB @@ -742,6 +842,40 @@
{/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} />