← back to Exo
dashboard: show disk usage for completed models
8d7b6789b3ae5402708ae57b999b80472aadf73b · 2026-01-09 11:12:31 +0000 · Jake Hillion
The downloads dashboard showed "Completed" for finished model downloads
but provided no indication of how much disk space each model or the
total models on a node were using.
Added total_bytes field to DownloadCompleted type so the size is
preserved when a download completes. Updated the dashboard to display
the model size next to "Completed" status (e.g., "Completed (251.1GB)")
and a total disk usage line below the model count for each node (e.g.,
"502.2GB on disk").
Test plan:
- Ran unit tests for download apply and planning logic
- Type checked all modified files with basedpyright
Files touched
M dashboard/src/routes/downloads/+page.svelteM src/exo/shared/tests/test_apply/test_apply_node_download.pyM src/exo/shared/types/worker/downloads.pyM src/exo/worker/main.pyM src/exo/worker/tests/unittests/test_plan/test_download_and_loading.py
Diff
commit 8d7b6789b3ae5402708ae57b999b80472aadf73b
Author: Jake Hillion <jake@hillion.co.uk>
Date: Fri Jan 9 11:12:31 2026 +0000
dashboard: show disk usage for completed models
The downloads dashboard showed "Completed" for finished model downloads
but provided no indication of how much disk space each model or the
total models on a node were using.
Added total_bytes field to DownloadCompleted type so the size is
preserved when a download completes. Updated the dashboard to display
the model size next to "Completed" status (e.g., "Completed (251.1GB)")
and a total disk usage line below the model count for each node (e.g.,
"502.2GB on disk").
Test plan:
- Ran unit tests for download apply and planning logic
- Type checked all modified files with basedpyright
---
dashboard/src/routes/downloads/+page.svelte | 19 +++++++---
.../tests/test_apply/test_apply_node_download.py | 4 +++
src/exo/shared/types/worker/downloads.py | 2 +-
src/exo/worker/main.py | 14 ++++++--
.../test_plan/test_download_and_loading.py | 41 +++++++++++++++++-----
5 files changed, 64 insertions(+), 16 deletions(-)
diff --git a/dashboard/src/routes/downloads/+page.svelte b/dashboard/src/routes/downloads/+page.svelte
index 9ee249a1..6d8ad418 100644
--- a/dashboard/src/routes/downloads/+page.svelte
+++ b/dashboard/src/routes/downloads/+page.svelte
@@ -199,7 +199,13 @@
const rawProgress = (downloadPayload as Record<string, unknown>).download_progress
?? (downloadPayload as Record<string, unknown>).downloadProgress
?? {};
- const totalBytes = getBytes((rawProgress as Record<string, unknown>).total_bytes ?? (rawProgress as Record<string, unknown>).totalBytes);
+ // 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;
@@ -332,8 +338,13 @@
<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>
- <div class="text-xs font-mono uppercase tracking-wider whitespace-nowrap shrink-0">
- <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 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 class="text-exo-light-gray normal-case tracking-normal">
+ {formatBytes(node.models.filter(m => m.status === 'completed').reduce((sum, m) => sum + m.totalBytes, 0))} on disk
+ </div>
</div>
</div>
@@ -385,7 +396,7 @@
</div>
<div class="flex items-center justify-between text-xs font-mono text-exo-light-gray">
- <span>{model.status === 'completed' ? 'Completed' : `${formatSpeed(model.speed)} • ETA ${formatEta(model.etaMs)}`}</span>
+ <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}
diff --git a/src/exo/shared/tests/test_apply/test_apply_node_download.py b/src/exo/shared/tests/test_apply/test_apply_node_download.py
index ac502165..22447455 100644
--- a/src/exo/shared/tests/test_apply/test_apply_node_download.py
+++ b/src/exo/shared/tests/test_apply/test_apply_node_download.py
@@ -2,6 +2,7 @@ from exo.shared.apply import apply_node_download_progress
from exo.shared.tests.conftest import get_pipeline_shard_metadata
from exo.shared.types.common import NodeId
from exo.shared.types.events import NodeDownloadProgress
+from exo.shared.types.memory import Memory
from exo.shared.types.state import State
from exo.shared.types.worker.downloads import DownloadCompleted
from exo.worker.tests.constants import MODEL_A_ID, MODEL_B_ID
@@ -13,6 +14,7 @@ def test_apply_node_download_progress():
event = DownloadCompleted(
node_id=NodeId("node-1"),
shard_metadata=shard1,
+ total_bytes=Memory(),
)
new_state = apply_node_download_progress(
@@ -28,10 +30,12 @@ def test_apply_two_node_download_progress():
event1 = DownloadCompleted(
node_id=NodeId("node-1"),
shard_metadata=shard1,
+ total_bytes=Memory(),
)
event2 = DownloadCompleted(
node_id=NodeId("node-1"),
shard_metadata=shard2,
+ total_bytes=Memory(),
)
state = State(downloads={NodeId("node-1"): [event1]})
diff --git a/src/exo/shared/types/worker/downloads.py b/src/exo/shared/types/worker/downloads.py
index 73255f62..a01ab5e9 100644
--- a/src/exo/shared/types/worker/downloads.py
+++ b/src/exo/shared/types/worker/downloads.py
@@ -28,7 +28,7 @@ class DownloadPending(BaseDownloadProgress):
class DownloadCompleted(BaseDownloadProgress):
- pass
+ total_bytes: Memory
class DownloadFailed(BaseDownloadProgress):
diff --git a/src/exo/worker/main.py b/src/exo/worker/main.py
index e10df75e..28b89e01 100644
--- a/src/exo/worker/main.py
+++ b/src/exo/worker/main.py
@@ -217,7 +217,9 @@ class Worker:
)
if initial_progress.status == "complete":
progress = DownloadCompleted(
- shard_metadata=shard, node_id=self.node_id
+ shard_metadata=shard,
+ node_id=self.node_id,
+ total_bytes=initial_progress.total_bytes,
)
self.download_status[shard.model_meta.model_id] = progress
await self.event_sender.send(
@@ -364,7 +366,11 @@ class Worker:
nonlocal self
nonlocal last_progress_time
if progress.status == "complete":
- status = DownloadCompleted(shard_metadata=shard, node_id=self.node_id)
+ status = DownloadCompleted(
+ shard_metadata=shard,
+ node_id=self.node_id,
+ total_bytes=progress.total_bytes,
+ )
self.download_status[shard.model_meta.model_id] = status
# Footgun!
self.event_sender.send_nowait(
@@ -457,7 +463,9 @@ class Worker:
) in self.shard_downloader.get_shard_download_status():
if progress.status == "complete":
status = DownloadCompleted(
- node_id=self.node_id, shard_metadata=progress.shard
+ node_id=self.node_id,
+ shard_metadata=progress.shard,
+ total_bytes=progress.total_bytes,
)
elif progress.status in ["in_progress", "not_started"]:
if progress.downloaded_bytes_this_session.in_bytes == 0:
diff --git a/src/exo/worker/tests/unittests/test_plan/test_download_and_loading.py b/src/exo/worker/tests/unittests/test_plan/test_download_and_loading.py
index cedee493..69211769 100644
--- a/src/exo/worker/tests/unittests/test_plan/test_download_and_loading.py
+++ b/src/exo/worker/tests/unittests/test_plan/test_download_and_loading.py
@@ -1,5 +1,6 @@
import exo.worker.plan as plan_mod
from exo.shared.types.common import NodeId
+from exo.shared.types.memory import Memory
from exo.shared.types.models import ModelId
from exo.shared.types.tasks import LoadModel
from exo.shared.types.worker.downloads import DownloadCompleted, DownloadProgress
@@ -94,13 +95,23 @@ def test_plan_loads_model_when_all_shards_downloaded_and_waiting():
# Local node has already marked its shard as downloaded (not actually used by _load_model)
local_download_status = {
- MODEL_A_ID: DownloadCompleted(shard_metadata=shard1, node_id=NODE_A)
+ MODEL_A_ID: DownloadCompleted(
+ shard_metadata=shard1, node_id=NODE_A, total_bytes=Memory()
+ )
}
# Global view has completed downloads for both nodes
global_download_status = {
- NODE_A: [DownloadCompleted(shard_metadata=shard1, node_id=NODE_A)],
- NODE_B: [DownloadCompleted(shard_metadata=shard2, node_id=NODE_B)],
+ NODE_A: [
+ DownloadCompleted(
+ shard_metadata=shard1, node_id=NODE_A, total_bytes=Memory()
+ )
+ ],
+ NODE_B: [
+ DownloadCompleted(
+ shard_metadata=shard2, node_id=NODE_B, total_bytes=Memory()
+ )
+ ],
}
result = plan_mod.plan(
@@ -140,7 +151,9 @@ def test_plan_does_not_request_download_when_shard_already_downloaded():
# Local status claims the shard is downloaded already
local_download_status = {
- MODEL_A_ID: DownloadCompleted(shard_metadata=shard, node_id=NODE_A)
+ MODEL_A_ID: DownloadCompleted(
+ shard_metadata=shard, node_id=NODE_A, total_bytes=Memory()
+ )
}
# Global view hasn't caught up yet (no completed shards recorded for NODE_A)
@@ -192,10 +205,16 @@ def test_plan_does_not_load_model_until_all_shards_downloaded_globally():
# Only NODE_A's shard is recorded as downloaded globally
local_download_status = {
- MODEL_A_ID: DownloadCompleted(shard_metadata=shard1, node_id=NODE_A)
+ MODEL_A_ID: DownloadCompleted(
+ shard_metadata=shard1, node_id=NODE_A, total_bytes=Memory()
+ )
}
global_download_status = {
- NODE_A: [DownloadCompleted(shard_metadata=shard1, node_id=NODE_A)],
+ NODE_A: [
+ DownloadCompleted(
+ shard_metadata=shard1, node_id=NODE_A, total_bytes=Memory()
+ )
+ ],
NODE_B: [], # NODE_B has no downloads completed yet
}
@@ -212,9 +231,15 @@ def test_plan_does_not_load_model_until_all_shards_downloaded_globally():
assert result is None
global_download_status = {
- NODE_A: [DownloadCompleted(shard_metadata=shard1, node_id=NODE_A)],
+ NODE_A: [
+ DownloadCompleted(
+ shard_metadata=shard1, node_id=NODE_A, total_bytes=Memory()
+ )
+ ],
NODE_B: [
- DownloadCompleted(shard_metadata=shard2, node_id=NODE_B)
+ DownloadCompleted(
+ shard_metadata=shard2, node_id=NODE_B, total_bytes=Memory()
+ )
], # NODE_B has no downloads completed yet
}
← 3c5b7ea6 ci: add workflow_dispatch trigger to build-app
·
back to Exo
·
nix: enable cachix 007eb800 →