← back to Exo
Support trace deletion in dashboard (#1628)
635118ef24e485a10e14d8cc91586bc970c9780c · 2026-02-27 17:29:57 +0000 · ciaranbor
## 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
Files touched
M dashboard/src/lib/stores/app.svelte.tsM dashboard/src/routes/traces/+page.svelteM src/exo/master/api.pyM src/exo/shared/types/api.py
Diff
commit 635118ef24e485a10e14d8cc91586bc970c9780c
Author: ciaranbor <81697641+ciaranbor@users.noreply.github.com>
Date: Fri Feb 27 17:29:57 2026 +0000
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
---
dashboard/src/lib/stores/app.svelte.ts | 19 +++++++
dashboard/src/routes/traces/+page.svelte | 93 ++++++++++++++++++++++++++++++--
src/exo/master/api.py | 23 +++++++-
src/exo/shared/types/api.py | 9 ++++
4 files changed, 139 insertions(+), 5 deletions(-)
diff --git a/dashboard/src/lib/stores/app.svelte.ts b/dashboard/src/lib/stores/app.svelte.ts
index 64f96fea..ed7719b2 100644
--- a/dashboard/src/lib/stores/app.svelte.ts
+++ b/dashboard/src/lib/stores/app.svelte.ts
@@ -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);
diff --git a/dashboard/src/routes/traces/+page.svelte b/dashboard/src/routes/traces/+page.svelte
index dae70ff5..afcf63ad 100644
--- a/dashboard/src/routes/traces/+page.svelte
+++ b/dashboard/src/routes/traces/+page.svelte
@@ -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"
diff --git a/src/exo/master/api.py b/src/exo/master/api.py
index eb878fc2..60a7311f 100644
--- a/src/exo/master/api.py
+++ b/src/exo/master/api.py
@@ -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()})
diff --git a/src/exo/shared/types/api.py b/src/exo/shared/types/api.py
index 23ca9b7b..034414a5 100644
--- a/src/exo/shared/types/api.py
+++ b/src/exo/shared/types/api.py
@@ -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]
← dc0bb5e1 fmt: add taplo TOML formatter to treefmt configuration
·
back to Exo
·
fix: replace Master event_sender after EventRouter recreatio 858dc808 →