Compare commits

...

2 Commits

Author SHA1 Message Date
ciaranbor
635118ef24 Support trace deletion in dashboard (#1628)
## Motivation

Enable using the dashboard to delete traces

## Changes

- Backend (src/exo/master/api.py): Added POST /v1/traces/delete endpoint
that deletes trace files by task ID, with path traversal protection
- API types (src/exo/shared/types/api.py): Added DeleteTracesRequest and
DeleteTracesResponse models
  - Dashboard store (app.svelte.ts): Added deleteTraces() method
- Traces page (+page.svelte): Added multi-select UI (click to select,
select all/deselect all) with a bulk delete button and confirmation
dialog

## Why It Works

- Uses existing _get_trace_path helper (now hardened against path
traversal) to resolve and delete trace files
- Dashboard selection state is reactive via Svelte 5 runes; deleted
traces refresh the list automatically

## Test Plan

### Manual Testing

- Select individual traces, select all, delete with confirmation dialog
  - Verify traces are removed from disk and list refreshes
2026-02-27 17:29:57 +00:00
Jake Hillion
dc0bb5e13b fmt: add taplo TOML formatter to treefmt configuration 2026-02-27 17:19:48 +00:00
13 changed files with 225 additions and 93 deletions

View File

@@ -1,10 +1,6 @@
[workspace]
resolver = "3"
members = [
"rust/networking",
"rust/exo_pyo3_bindings",
"rust/util",
]
members = ["rust/networking", "rust/exo_pyo3_bindings", "rust/util"]
[workspace.package]
version = "0.0.1"

View File

@@ -2,6 +2,4 @@
#
# Lists the suite files to include. Each file defines benchmarks
# with shared constraints, topology, and default args.
include = [
"single-m3-ultra.toml",
]
include = ["single-m3-ultra.toml"]

View File

@@ -4,13 +4,13 @@ version = "0.1.0"
description = "Benchmarking tool for exo distributed inference"
requires-python = ">=3.13"
dependencies = [
"httpx>=0.27.0",
"loguru>=0.7.3",
"transformers>=5.0.0",
"huggingface-hub>=0.33.4",
"tiktoken>=0.12.0",
"jinja2>=3.1.0",
"protobuf>=5.29.0",
"httpx>=0.27.0",
"loguru>=0.7.3",
"transformers>=5.0.0",
"huggingface-hub>=0.33.4",
"tiktoken>=0.12.0",
"jinja2>=3.1.0",
"protobuf>=5.29.0",
]
[build-system]

View File

@@ -2,10 +2,10 @@
#
# Shared constraints applied to ALL benchmarks in this file.
constraints = [
"All(MacOsBuild(=25D125))",
"Hosts(=1)",
"All(Chip(m3_ultra))",
"All(GpuCores(=80))",
"All(MacOsBuild(=25D125))",
"Hosts(=1)",
"All(Chip(m3_ultra))",
"All(GpuCores(=80))",
]
[topology]

View File

@@ -3158,6 +3158,23 @@ class AppStore {
return (await response.json()) as TraceStatsResponse;
}
/**
* Delete traces by task IDs
*/
async deleteTraces(
taskIds: string[],
): Promise<{ deleted: string[]; notFound: string[] }> {
const response = await fetch("/v1/traces/delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ taskIds }),
});
if (!response.ok) {
throw new Error(`Failed to delete traces: ${response.status}`);
}
return await response.json();
}
/**
* Get the URL for the raw trace file (for Perfetto)
*/
@@ -3301,3 +3318,5 @@ export const fetchTraceStats = (taskId: string) =>
appStore.fetchTraceStats(taskId);
export const getTraceRawUrl = (taskId: string) =>
appStore.getTraceRawUrl(taskId);
export const deleteTraces = (taskIds: string[]) =>
appStore.deleteTraces(taskIds);

View File

@@ -3,6 +3,7 @@
import {
listTraces,
getTraceRawUrl,
deleteTraces,
type TraceListItem,
} from "$lib/stores/app.svelte";
import HeaderNav from "$lib/components/HeaderNav.svelte";
@@ -10,6 +11,51 @@
let traces = $state<TraceListItem[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
let selectedIds = $state<Set<string>>(new Set());
let deleting = $state(false);
let allSelected = $derived(
traces.length > 0 && selectedIds.size === traces.length,
);
function toggleSelect(taskId: string) {
const next = new Set(selectedIds);
if (next.has(taskId)) {
next.delete(taskId);
} else {
next.add(taskId);
}
selectedIds = next;
}
function toggleSelectAll() {
if (allSelected) {
selectedIds = new Set();
} else {
selectedIds = new Set(traces.map((t) => t.taskId));
}
}
async function handleDelete() {
if (selectedIds.size === 0) return;
const count = selectedIds.size;
if (
!confirm(
`Delete ${count} trace${count === 1 ? "" : "s"}? This cannot be undone.`,
)
)
return;
deleting = true;
try {
await deleteTraces([...selectedIds]);
selectedIds = new Set();
await refresh();
} catch (e) {
error = e instanceof Error ? e.message : "Failed to delete traces";
} finally {
deleting = false;
}
}
function formatBytes(bytes: number): string {
if (!bytes || bytes <= 0) return "0B";
@@ -109,6 +155,16 @@
</h1>
</div>
<div class="flex items-center gap-3">
{#if selectedIds.size > 0}
<button
type="button"
class="text-xs font-mono text-red-400 hover:text-red-300 transition-colors uppercase border border-red-500/40 px-2 py-1 rounded"
onclick={handleDelete}
disabled={deleting}
>
{deleting ? "Deleting..." : `Delete (${selectedIds.size})`}
</button>
{/if}
<button
type="button"
class="text-xs font-mono text-exo-light-gray hover:text-exo-yellow transition-colors uppercase border border-exo-medium-gray/40 px-2 py-1 rounded"
@@ -143,14 +199,41 @@
</div>
{:else}
<div class="space-y-3">
<div class="flex items-center gap-2 px-1">
<button
type="button"
class="text-xs font-mono uppercase transition-colors {allSelected
? 'text-exo-yellow'
: 'text-exo-light-gray hover:text-exo-yellow'}"
onclick={toggleSelectAll}
>
{allSelected ? "Deselect all" : "Select all"}
</button>
</div>
{#each traces as trace}
{@const isSelected = selectedIds.has(trace.taskId)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="rounded border border-exo-medium-gray/30 bg-exo-black/30 p-4 flex items-center justify-between gap-4"
role="button"
tabindex="0"
class="w-full text-left rounded border-l-2 border-r border-t border-b transition-all p-4 flex items-center justify-between gap-4 cursor-pointer {isSelected
? 'bg-exo-yellow/10 border-l-exo-yellow border-r-exo-medium-gray/30 border-t-exo-medium-gray/30 border-b-exo-medium-gray/30'
: 'bg-exo-black/30 border-l-transparent border-r-exo-medium-gray/30 border-t-exo-medium-gray/30 border-b-exo-medium-gray/30 hover:bg-white/[0.03]'}"
onclick={() => toggleSelect(trace.taskId)}
onkeydown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
toggleSelect(trace.taskId);
}
}}
>
<div class="min-w-0 flex-1">
<a
href="#/traces/{trace.taskId}"
class="text-sm font-mono text-white hover:text-exo-yellow transition-colors truncate block"
class="text-sm font-mono transition-colors truncate block {isSelected
? 'text-exo-yellow'
: 'text-white hover:text-exo-yellow'}"
onclick={(e) => e.stopPropagation()}
>
{trace.taskId}
</a>
@@ -160,7 +243,11 @@
)}
</div>
</div>
<div class="flex items-center gap-2 shrink-0">
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="flex items-center gap-2 shrink-0"
onclick={(e) => e.stopPropagation()}
>
<a
href="#/traces/{trace.taskId}"
class="text-xs font-mono text-exo-light-gray hover:text-exo-yellow transition-colors uppercase border border-exo-medium-gray/40 px-2 py-1 rounded"

View File

@@ -108,6 +108,7 @@
package = pkgsSwift.swiftPackages.swift-format;
};
shfmt.enable = true;
taplo.enable = true;
};
};

View File

@@ -5,31 +5,31 @@ description = "Exo"
readme = "README.md"
requires-python = ">=3.13"
dependencies = [
"aiofiles>=24.1.0",
"aiohttp>=3.12.14",
"types-aiofiles>=24.1.0.20250708",
"pydantic>=2.11.7",
"fastapi>=0.116.1",
"filelock>=3.18.0",
"rustworkx>=0.17.1",
"huggingface-hub>=0.33.4",
"psutil>=7.0.0",
"loguru>=0.7.3",
"exo_pyo3_bindings", # rust bindings
"anyio==4.11.0",
"mlx; sys_platform == 'darwin'",
"mlx[cpu]==0.30.6; sys_platform == 'linux'",
"mlx-lm==0.30.7",
"tiktoken>=0.12.0", # required for kimi k2 tokenizer
"hypercorn>=0.18.0",
"openai-harmony>=0.0.8",
"httpx>=0.28.1",
"tomlkit>=0.14.0",
"pillow>=11.0,<12.0", # compatibility with mflux
"mflux==0.15.5",
"python-multipart>=0.0.21",
"msgspec>=0.19.0",
"zstandard>=0.23.0",
"aiofiles>=24.1.0",
"aiohttp>=3.12.14",
"types-aiofiles>=24.1.0.20250708",
"pydantic>=2.11.7",
"fastapi>=0.116.1",
"filelock>=3.18.0",
"rustworkx>=0.17.1",
"huggingface-hub>=0.33.4",
"psutil>=7.0.0",
"loguru>=0.7.3",
"exo_pyo3_bindings", # rust bindings
"anyio==4.11.0",
"mlx; sys_platform == 'darwin'",
"mlx[cpu]==0.30.6; sys_platform == 'linux'",
"mlx-lm==0.30.7",
"tiktoken>=0.12.0", # required for kimi k2 tokenizer
"hypercorn>=0.18.0",
"openai-harmony>=0.0.8",
"httpx>=0.28.1",
"tomlkit>=0.14.0",
"pillow>=11.0,<12.0", # compatibility with mflux
"mflux==0.15.5",
"python-multipart>=0.0.21",
"msgspec>=0.19.0",
"zstandard>=0.23.0",
]
[project.scripts]
@@ -38,12 +38,12 @@ exo = "exo.main:main"
# dependencies only required for development
[dependency-groups]
dev = [
"basedpyright>=1.29.0",
"pyinstaller>=6.17.0",
"pytest>=8.4.0",
"pytest-asyncio>=1.0.0",
"pytest-env",
"ruff>=0.11.13",
"basedpyright>=1.29.0",
"pyinstaller>=6.17.0",
"pytest>=8.4.0",
"pytest-asyncio>=1.0.0",
"pytest-env",
"ruff>=0.11.13",
]
# mlx[cuda] requires a newer version of mlx. the ideal on linux is: default to mlx[cpu] unless[cuda] specified.
@@ -57,10 +57,7 @@ dev = [
###
[tool.uv.workspace]
members = [
"rust/exo_pyo3_bindings",
"bench",
]
members = ["rust/exo_pyo3_bindings", "bench"]
[tool.uv.sources]
exo_pyo3_bindings = { workspace = true }
@@ -95,7 +92,15 @@ reportUnnecessaryTypeIgnoreComment = "error"
pythonVersion = "3.13"
pythonPlatform = "Darwin"
exclude = ["**/.venv", "**/venv", "**/__pycache__", "**/exo_scripts", "**/.direnv", "**/rust", "**/.github"]
exclude = [
"**/.venv",
"**/venv",
"**/__pycache__",
"**/exo_scripts",
"**/.direnv",
"**/rust",
"**/.github",
]
stubPath = ".mlx_typings"
[[tool.basedpyright.executionEnvironments]]
@@ -109,17 +114,18 @@ root = "src"
[tool.uv]
required-version = ">=0.8.6"
prerelease = "allow"
environments = [
"sys_platform == 'darwin'",
"sys_platform == 'linux'",
]
environments = ["sys_platform == 'darwin'", "sys_platform == 'linux'"]
###
# ruff configuration
###
[tool.ruff]
extend-exclude = ["shared/protobufs/**", "*mlx_typings/**", "rust/exo_pyo3_bindings/**"]
extend-exclude = [
"shared/protobufs/**",
"*mlx_typings/**",
"rust/exo_pyo3_bindings/**",
]
[tool.ruff.lint]
extend-select = ["I", "N", "B", "A", "PIE", "SIM"]
@@ -127,13 +133,7 @@ extend-select = ["I", "N", "B", "A", "PIE", "SIM"]
[tool.pytest.ini_options]
pythonpath = "."
asyncio_mode = "auto"
markers = [
"slow: marks tests as slow (deselected by default)"
]
env = [
"EXO_TESTS=1"
]
markers = ["slow: marks tests as slow (deselected by default)"]
env = ["EXO_TESTS=1"]
addopts = "-m 'not slow' --ignore=tests/start_distributed_test.py"
filterwarnings = [
"ignore:builtin type Swig:DeprecationWarning",
]
filterwarnings = ["ignore:builtin type Swig:DeprecationWarning"]

View File

@@ -26,20 +26,24 @@ networking = { workspace = true }
# interop
pyo3 = { version = "0.27.2", features = [
# "abi3-py313", # tells pyo3 (and maturin) to build using the stable ABI with minimum Python version 3.13
# "nightly", # enables better-supported GIL integration
"experimental-async", # async support in #[pyfunction] & #[pymethods]
#"experimental-inspect", # inspection of generated binary => easier to automate type-hint generation
#"py-clone", # adding Clone-ing of `Py<T>` without GIL (may cause panics - remove if panics happen)
# "multiple-pymethods", # allows multiple #[pymethods] sections per class
# "abi3-py313", # tells pyo3 (and maturin) to build using the stable ABI with minimum Python version 3.13
# "nightly", # enables better-supported GIL integration
"experimental-async", # async support in #[pyfunction] & #[pymethods]
#"experimental-inspect", # inspection of generated binary => easier to automate type-hint generation
#"py-clone", # adding Clone-ing of `Py<T>` without GIL (may cause panics - remove if panics happen)
# "multiple-pymethods", # allows multiple #[pymethods] sections per class
# integrations with other libraries
# "arc_lock", "bigdecimal", "either", "hashbrown", "indexmap", "num-bigint", "num-complex", "num-rational",
# "ordered-float", "rust_decimal", "smallvec",
# "anyhow", "chrono", "chrono-local", "chrono-tz", "eyre", "jiff-02", "lock_api", "parking-lot", "time", "serde",
# integrations with other libraries
# "arc_lock", "bigdecimal", "either", "hashbrown", "indexmap", "num-bigint", "num-complex", "num-rational",
# "ordered-float", "rust_decimal", "smallvec",
# "anyhow", "chrono", "chrono-local", "chrono-tz", "eyre", "jiff-02", "lock_api", "parking-lot", "time", "serde",
] }
pyo3-stub-gen = { version = "0.17.2" }
pyo3-async-runtimes = { version = "0.27.0", features = ["attributes", "tokio-runtime", "testing"] }
pyo3-async-runtimes = { version = "0.27.0", features = [
"attributes",
"tokio-runtime",
"testing",
] }
pyo3-log = "0.13.2"
# macro dependencies

View File

@@ -8,18 +8,14 @@ version = "0.2.0"
description = "Add your description here"
readme = "README.md"
authors = [
{ name = "Andrei Cravtov", email = "the.andrei.cravtov@gmail.com" },
{ name = "Evan Quiney", email = "evanev7@gmail.com" }
{ name = "Andrei Cravtov", email = "the.andrei.cravtov@gmail.com" },
{ name = "Evan Quiney", email = "evanev7@gmail.com" },
]
requires-python = ">=3.13"
dependencies = []
[dependency-groups]
dev = [
"exo_pyo3_bindings",
"pytest>=8.4.0",
"pytest-asyncio>=1.0.0",
]
dev = ["exo_pyo3_bindings", "pytest>=8.4.0", "pytest-asyncio>=1.0.0"]
[tool.maturin]
#purelib = true

View File

@@ -28,7 +28,10 @@ tokio = { workspace = true, features = ["full"] }
# utility dependencies
util = { workspace = true }
tracing-subscriber = { version = "0.3.19", features = ["default", "env-filter"] }
tracing-subscriber = { version = "0.3.19", features = [
"default",
"env-filter",
] }
keccak-const = { workspace = true }
# tracing/logging

View File

@@ -81,6 +81,8 @@ from exo.shared.types.api import (
CreateInstanceResponse,
DeleteDownloadResponse,
DeleteInstanceResponse,
DeleteTracesRequest,
DeleteTracesResponse,
ErrorInfo,
ErrorResponse,
FinishReason,
@@ -340,6 +342,7 @@ class API:
self.app.post("/download/start")(self.start_download)
self.app.delete("/download/{node_id}/{model_id:path}")(self.delete_download)
self.app.get("/v1/traces")(self.list_traces)
self.app.post("/v1/traces/delete")(self.delete_traces)
self.app.get("/v1/traces/{task_id}")(self.get_trace)
self.app.get("/v1/traces/{task_id}/stats")(self.get_trace_stats)
self.app.get("/v1/traces/{task_id}/raw")(self.get_trace_raw)
@@ -1707,8 +1710,12 @@ class API:
await self._send_download(command)
return DeleteDownloadResponse(command_id=command.command_id)
def _get_trace_path(self, task_id: str) -> Path:
return EXO_TRACING_CACHE_DIR / f"trace_{task_id}.json"
@staticmethod
def _get_trace_path(task_id: str) -> Path:
trace_path = EXO_TRACING_CACHE_DIR / f"trace_{task_id}.json"
if not trace_path.resolve().is_relative_to(EXO_TRACING_CACHE_DIR.resolve()):
raise HTTPException(status_code=400, detail=f"Invalid task ID: {task_id}")
return trace_path
async def list_traces(self) -> TraceListResponse:
traces: list[TraceListItem] = []
@@ -1807,6 +1814,18 @@ class API:
filename=f"trace_{task_id}.json",
)
async def delete_traces(self, request: DeleteTracesRequest) -> DeleteTracesResponse:
deleted: list[str] = []
not_found: list[str] = []
for task_id in request.task_ids:
trace_path = self._get_trace_path(task_id)
if trace_path.exists():
trace_path.unlink()
deleted.append(task_id)
else:
not_found.append(task_id)
return DeleteTracesResponse(deleted=deleted, not_found=not_found)
async def get_onboarding(self) -> JSONResponse:
return JSONResponse({"completed": ONBOARDING_COMPLETE_FILE.exists()})

View File

@@ -437,3 +437,12 @@ class TraceListItem(CamelCaseModel):
class TraceListResponse(CamelCaseModel):
traces: list[TraceListItem]
class DeleteTracesRequest(CamelCaseModel):
task_ids: list[str]
class DeleteTracesResponse(CamelCaseModel):
deleted: list[str]
not_found: list[str]