← back to Exo
dashboard: add placement filter by clicking topology nodes (#1248)
b783a21399def29ddade7a82e988f66eb0e641c9 · 2026-01-22 22:12:57 +0000 · Alex Cheema
## Motivation
When selecting a model for placement, users often want to see placements
that utilize specific nodes in their cluster. Currently there's no way
to filter the placement previews to focus on configurations that include
particular machines.
## Changes
- **Backend**: Added `node_ids` query parameter to the
`/placement-previews` API endpoint. When provided, the endpoint filters
the topology to only include the specified nodes before generating
placements using the new `Topology.filter_to_nodes()` method.
- **Topology class**: Added `filter_to_nodes(node_ids)` method that
creates a new topology containing only the specified nodes and edges
between them.
- **App store**: Added `previewNodeFilter` state to track selected
nodes, with methods to toggle/clear the filter. Automatically cleans up
filter when nodes are removed from the cluster and re-fetches previews
when topology changes.
- **TopologyGraph component**: Added click handlers to toggle node
filter selection, hover effects to indicate clickable nodes, and visual
styling (yellow highlight for selected, dimmed for filtered-out nodes).
- **Main page**: Added filter indicator in top-right corner of topology
showing active filter count with a clear button.
## Why It Works
The filtering happens at the backend/placement generation level rather
than just filtering the results. This ensures we see all valid placement
combinations for the selected nodes, not just a subset that happened to
be generated for the full topology.
The visual feedback uses the same rendering approach as the existing
highlight system - state is tracked in Svelte and applied during render,
so it persists across data updates without flickering.
## Test Plan
### Manual Testing
<!-- Hardware: (e.g., MacBook Pro M1 Max 32GB, Mac Mini M2 16GB,
connected via Thunderbolt 4) -->
<!-- What you did: -->
- Click a node in topology → should show yellow highlight and filter
indicator
- Click another node → indicator shows "2 nodes", previews update to
show only placements using both
- Hover over nodes → subtle yellow highlight indicates they're clickable
- Click X on filter indicator → clears filter, shows all placements
again
- Disconnect a node while it's in filter → filter auto-removes that node
### Automated Testing
- Existing tests cover the Topology class; the new `filter_to_nodes`
method follows the same patterns
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Files touched
M dashboard/src/lib/components/TopologyGraph.svelteM dashboard/src/lib/stores/app.svelte.tsM dashboard/src/routes/+page.svelteM src/exo/master/api.py
Diff
commit b783a21399def29ddade7a82e988f66eb0e641c9
Author: Alex Cheema <41707476+AlexCheema@users.noreply.github.com>
Date: Thu Jan 22 22:12:57 2026 +0000
dashboard: add placement filter by clicking topology nodes (#1248)
## Motivation
When selecting a model for placement, users often want to see placements
that utilize specific nodes in their cluster. Currently there's no way
to filter the placement previews to focus on configurations that include
particular machines.
## Changes
- **Backend**: Added `node_ids` query parameter to the
`/placement-previews` API endpoint. When provided, the endpoint filters
the topology to only include the specified nodes before generating
placements using the new `Topology.filter_to_nodes()` method.
- **Topology class**: Added `filter_to_nodes(node_ids)` method that
creates a new topology containing only the specified nodes and edges
between them.
- **App store**: Added `previewNodeFilter` state to track selected
nodes, with methods to toggle/clear the filter. Automatically cleans up
filter when nodes are removed from the cluster and re-fetches previews
when topology changes.
- **TopologyGraph component**: Added click handlers to toggle node
filter selection, hover effects to indicate clickable nodes, and visual
styling (yellow highlight for selected, dimmed for filtered-out nodes).
- **Main page**: Added filter indicator in top-right corner of topology
showing active filter count with a clear button.
## Why It Works
The filtering happens at the backend/placement generation level rather
than just filtering the results. This ensures we see all valid placement
combinations for the selected nodes, not just a subset that happened to
be generated for the full topology.
The visual feedback uses the same rendering approach as the existing
highlight system - state is tracked in Svelte and applied during render,
so it persists across data updates without flickering.
## Test Plan
### Manual Testing
<!-- Hardware: (e.g., MacBook Pro M1 Max 32GB, Mac Mini M2 16GB,
connected via Thunderbolt 4) -->
<!-- What you did: -->
- Click a node in topology → should show yellow highlight and filter
indicator
- Click another node → indicator shows "2 nodes", previews update to
show only placements using both
- Hover over nodes → subtle yellow highlight indicates they're clickable
- Click X on filter indicator → clears filter, shows all placements
again
- Disconnect a node while it's in filter → filter auto-removes that node
### Automated Testing
- Existing tests cover the Topology class; the new `filter_to_nodes`
method follows the same patterns
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
---
dashboard/src/lib/components/TopologyGraph.svelte | 114 ++++++++++++++++------
dashboard/src/lib/stores/app.svelte.ts | 84 +++++++++++++++-
dashboard/src/routes/+page.svelte | 45 ++++++++-
src/exo/master/api.py | 23 +++--
4 files changed, 223 insertions(+), 43 deletions(-)
diff --git a/dashboard/src/lib/components/TopologyGraph.svelte b/dashboard/src/lib/components/TopologyGraph.svelte
index 7d1f6696..c0acc39d 100644
--- a/dashboard/src/lib/components/TopologyGraph.svelte
+++ b/dashboard/src/lib/components/TopologyGraph.svelte
@@ -12,12 +12,20 @@
interface Props {
class?: string;
highlightedNodes?: Set<string>;
+ filteredNodes?: Set<string>;
+ onNodeClick?: (nodeId: string) => void;
}
- let { class: className = "", highlightedNodes = new Set() }: Props = $props();
+ let {
+ class: className = "",
+ highlightedNodes = new Set(),
+ filteredNodes = new Set(),
+ onNodeClick,
+ }: Props = $props();
let svgContainer: SVGSVGElement | undefined = $state();
let resizeObserver: ResizeObserver | undefined;
+ let hoveredNodeId = $state<string | null>(null);
const isMinimized = $derived(isTopologyMinimized());
const data = $derived(topologyData());
@@ -524,39 +532,80 @@
}
}
- const nodeG = nodesGroup
- .append("g")
- .attr("class", "graph-node")
- .style("cursor", "pointer");
-
- // Add tooltip
- nodeG
- .append("title")
- .text(
- `${friendlyName}\nID: ${nodeInfo.id.slice(-8)}\nMemory: ${formatBytes(ramUsed)}/${formatBytes(ramTotal)}`,
- );
-
let iconBaseWidth = nodeRadius * 1.2;
let iconBaseHeight = nodeRadius * 1.0;
const clipPathId = `clip-${nodeInfo.id.replace(/[^a-zA-Z0-9]/g, "-")}`;
const modelLower = modelId.toLowerCase();
- // Check if this node should be highlighted (from hovered instance)
+ // Check node states for styling
const isHighlighted = highlightedNodes.has(nodeInfo.id);
-
- // Holographic wireframe colors - yellow border when highlighted
- const wireColor = isHighlighted
- ? "rgba(255,215,0,0.9)"
- : "rgba(179,179,179,0.8)";
+ const isInFilter =
+ filteredNodes.size > 0 && filteredNodes.has(nodeInfo.id);
+ const isFilteredOut =
+ filteredNodes.size > 0 && !filteredNodes.has(nodeInfo.id);
+ const isHovered = hoveredNodeId === nodeInfo.id && !isInFilter;
+
+ // Holographic wireframe colors - bright yellow for filter, subtle yellow for hover, grey for filtered out
+ const wireColor = isInFilter
+ ? "rgba(255,215,0,1)" // Bright yellow for filter selection
+ : isHovered
+ ? "rgba(255,215,0,0.7)" // Subtle yellow for hover
+ : isHighlighted
+ ? "rgba(255,215,0,0.9)" // Yellow for instance highlight
+ : isFilteredOut
+ ? "rgba(140,140,140,0.6)" // Grey for filtered out
+ : "rgba(179,179,179,0.8)"; // Default
const wireColorBright = "rgba(255,255,255,0.9)";
- const fillColor = isHighlighted
- ? "rgba(255,215,0,0.15)"
- : "rgba(255,215,0,0.08)";
- const strokeWidth = isHighlighted ? 2.5 : 1.5;
+ const fillColor = isInFilter
+ ? "rgba(255,215,0,0.25)"
+ : isHovered
+ ? "rgba(255,215,0,0.12)"
+ : isHighlighted
+ ? "rgba(255,215,0,0.15)"
+ : "rgba(255,215,0,0.08)";
+ const strokeWidth = isInFilter
+ ? 3
+ : isHovered
+ ? 2
+ : isHighlighted
+ ? 2.5
+ : 1.5;
const screenFill = "rgba(0,20,40,0.9)";
const glowColor = "rgba(255,215,0,0.3)";
+ const nodeG = nodesGroup
+ .append("g")
+ .attr("class", "graph-node")
+ .style("cursor", onNodeClick ? "pointer" : "default")
+ .style("opacity", isFilteredOut ? 0.5 : 1);
+
+ // Add click and hover handlers - hover just updates state, styling is applied during render
+ nodeG
+ .on("click", (event: MouseEvent) => {
+ if (onNodeClick) {
+ event.stopPropagation();
+ onNodeClick(nodeInfo.id);
+ }
+ })
+ .on("mouseenter", () => {
+ if (onNodeClick) {
+ hoveredNodeId = nodeInfo.id;
+ }
+ })
+ .on("mouseleave", () => {
+ if (hoveredNodeId === nodeInfo.id) {
+ hoveredNodeId = null;
+ }
+ });
+
+ // Add tooltip
+ nodeG
+ .append("title")
+ .text(
+ `${friendlyName}\nID: ${nodeInfo.id.slice(-8)}\nMemory: ${formatBytes(ramUsed)}/${formatBytes(ramTotal)}`,
+ );
+
if (modelLower === "mac studio") {
// Mac Studio - classic cube with memory fill
iconBaseWidth = nodeRadius * 1.25;
@@ -581,6 +630,7 @@
// Main body (uniform color)
nodeG
.append("rect")
+ .attr("class", "node-outline")
.attr("x", x)
.attr("y", y)
.attr("width", iconBaseWidth)
@@ -663,6 +713,7 @@
// Main body (uniform color)
nodeG
.append("rect")
+ .attr("class", "node-outline")
.attr("x", x)
.attr("y", y)
.attr("width", iconBaseWidth)
@@ -740,6 +791,7 @@
// Screen outer frame
nodeG
.append("rect")
+ .attr("class", "node-outline")
.attr("x", screenX)
.attr("y", y)
.attr("width", screenWidth)
@@ -848,6 +900,7 @@
// Main shape
nodeG
.append("polygon")
+ .attr("class", "node-outline")
.attr("points", hexPoints)
.attr("fill", fillColor)
.attr("stroke", wireColor)
@@ -1095,7 +1148,12 @@
}
$effect(() => {
- if (data) {
+ // Track all reactive dependencies that affect rendering
+ const _data = data;
+ const _hoveredNodeId = hoveredNodeId;
+ const _filteredNodes = filteredNodes;
+ const _highlightedNodes = highlightedNodes;
+ if (_data) {
renderGraph();
}
});
@@ -1118,12 +1176,8 @@
<style>
:global(.graph-node) {
- transition:
- transform 0.2s ease,
- opacity 0.2s ease;
- }
- :global(.graph-node:hover) {
- filter: brightness(1.1);
+ /* Only transition opacity for filtered-out nodes, no transition on hover stroke changes */
+ transition: opacity 0.2s ease;
}
:global(.graph-link) {
stroke: var(--exo-light-gray, #b3b3b3);
diff --git a/dashboard/src/lib/stores/app.svelte.ts b/dashboard/src/lib/stores/app.svelte.ts
index 8d9b089b..d1aff1a1 100644
--- a/dashboard/src/lib/stores/app.svelte.ts
+++ b/dashboard/src/lib/stores/app.svelte.ts
@@ -426,6 +426,7 @@ class AppStore {
placementPreviews = $state<PlacementPreview[]>([]);
selectedPreviewModelId = $state<string | null>(null);
isLoadingPreviews = $state(false);
+ previewNodeFilter = $state<Set<string>>(new Set());
lastUpdate = $state<number | null>(null);
thunderboltBridgeCycles = $state<string[][]>([]);
nodeThunderboltBridge = $state<
@@ -453,6 +454,7 @@ class AppStore {
private fetchInterval: ReturnType<typeof setInterval> | null = null;
private previewsInterval: ReturnType<typeof setInterval> | null = null;
private lastConversationPersistTs = 0;
+ private previousNodeIds: Set<string> = new Set();
constructor() {
if (browser) {
@@ -1011,6 +1013,8 @@ class AppStore {
nodeSystem: data.nodeSystem,
nodeNetwork: data.nodeNetwork,
});
+ // Handle topology changes for preview filter
+ this.handleTopologyChange();
}
if (data.instances) {
this.instances = data.instances;
@@ -1041,9 +1045,14 @@ class AppStore {
this.selectedPreviewModelId = modelId;
try {
- const response = await fetch(
- `/instance/previews?model_id=${encodeURIComponent(modelId)}`,
- );
+ let url = `/instance/previews?model_id=${encodeURIComponent(modelId)}`;
+ // Add node filter if active
+ if (this.previewNodeFilter.size > 0) {
+ for (const nodeId of this.previewNodeFilter) {
+ url += `&node_ids=${encodeURIComponent(nodeId)}`;
+ }
+ }
+ const response = await fetch(url);
if (!response.ok) {
throw new Error(
`Failed to fetch placement previews: ${response.status}`,
@@ -1093,6 +1102,71 @@ class AppStore {
}
}
+ /**
+ * Toggle a node in the preview filter and re-fetch placements
+ */
+ togglePreviewNodeFilter(nodeId: string) {
+ const next = new Set(this.previewNodeFilter);
+ if (next.has(nodeId)) {
+ next.delete(nodeId);
+ } else {
+ next.add(nodeId);
+ }
+ this.previewNodeFilter = next;
+ // Re-fetch with new filter if we have a selected model
+ if (this.selectedPreviewModelId) {
+ this.fetchPlacementPreviews(this.selectedPreviewModelId, false);
+ }
+ }
+
+ /**
+ * Clear the preview node filter and re-fetch placements
+ */
+ clearPreviewNodeFilter() {
+ this.previewNodeFilter = new Set();
+ // Re-fetch with no filter if we have a selected model
+ if (this.selectedPreviewModelId) {
+ this.fetchPlacementPreviews(this.selectedPreviewModelId, false);
+ }
+ }
+
+ /**
+ * Handle topology changes - clean up filter and re-fetch if needed
+ */
+ private handleTopologyChange() {
+ if (!this.topologyData) return;
+
+ const currentNodeIds = new Set(Object.keys(this.topologyData.nodes));
+
+ // Check if nodes have changed
+ const nodesAdded = [...currentNodeIds].some(
+ (id) => !this.previousNodeIds.has(id),
+ );
+ const nodesRemoved = [...this.previousNodeIds].some(
+ (id) => !currentNodeIds.has(id),
+ );
+
+ if (nodesAdded || nodesRemoved) {
+ // Clean up filter - remove any nodes that no longer exist
+ if (this.previewNodeFilter.size > 0) {
+ const validFilterNodes = new Set(
+ [...this.previewNodeFilter].filter((id) => currentNodeIds.has(id)),
+ );
+ if (validFilterNodes.size !== this.previewNodeFilter.size) {
+ this.previewNodeFilter = validFilterNodes;
+ }
+ }
+
+ // Re-fetch previews if we have a selected model (topology changed)
+ if (this.selectedPreviewModelId) {
+ this.fetchPlacementPreviews(this.selectedPreviewModelId, false);
+ }
+ }
+
+ // Update tracked node IDs for next comparison
+ this.previousNodeIds = currentNodeIds;
+ }
+
/**
* Starts a chat conversation - triggers the topology minimization animation
* Creates a new conversation if none is active
@@ -2116,6 +2190,10 @@ export const setSelectedChatModel = (modelId: string) =>
appStore.setSelectedModel(modelId);
export const selectPreviewModel = (modelId: string | null) =>
appStore.selectPreviewModel(modelId);
+export const togglePreviewNodeFilter = (nodeId: string) =>
+ appStore.togglePreviewNodeFilter(nodeId);
+export const clearPreviewNodeFilter = () => appStore.clearPreviewNodeFilter();
+export const previewNodeFilter = () => appStore.previewNodeFilter;
export const deleteMessage = (messageId: string) =>
appStore.deleteMessage(messageId);
export const editMessage = (messageId: string, newContent: string) =>
diff --git a/dashboard/src/routes/+page.svelte b/dashboard/src/routes/+page.svelte
index c6a997de..9edadb62 100644
--- a/dashboard/src/routes/+page.svelte
+++ b/dashboard/src/routes/+page.svelte
@@ -19,6 +19,9 @@
selectedPreviewModelId,
isLoadingPreviews,
selectPreviewModel,
+ togglePreviewNodeFilter,
+ clearPreviewNodeFilter,
+ previewNodeFilter,
createConversation,
setSelectedChatModel,
selectedChatModel,
@@ -53,6 +56,7 @@
const sidebarVisible = $derived(chatSidebarVisible());
const tbBridgeCycles = $derived(thunderboltBridgeCycles());
const tbBridgeData = $derived(nodeThunderboltBridge());
+ const nodeFilter = $derived(previewNodeFilter());
// Helper to get friendly node name from node ID
function getNodeName(nodeId: string): string {
@@ -217,6 +221,9 @@
// Preview card hover state for highlighting nodes in topology
let hoveredPreviewNodes = $state<Set<string>>(new Set());
+ // Computed: Check if filter is active (from store)
+ const isFilterActive = $derived(() => nodeFilter.size > 0);
+
// Helper to unwrap tagged instance for hover highlighting
function unwrapInstanceNodes(instanceWrapped: unknown): Set<string> {
if (!instanceWrapped || typeof instanceWrapped !== "object")
@@ -1517,7 +1524,7 @@
// Get ALL filtered previews based on current settings (matching minimum nodes)
// Note: previewsData already contains previews for the selected model (fetched via API)
- // We filter by sharding/instance type and min nodes, returning ALL eligible previews
+ // Backend handles node_ids filtering, we filter by sharding/instance type and min nodes
const filteredPreviews = $derived(() => {
if (!selectedModelId || previewsData.length === 0) return [];
@@ -1650,6 +1657,8 @@
<TopologyGraph
class="w-full h-full"
highlightedNodes={highlightedNodes()}
+ filteredNodes={nodeFilter}
+ onNodeClick={togglePreviewNodeFilter}
/>
<!-- Thunderbolt Bridge Cycle Warning -->
@@ -1767,6 +1776,8 @@
<TopologyGraph
class="w-full h-full"
highlightedNodes={highlightedNodes()}
+ filteredNodes={nodeFilter}
+ onNodeClick={togglePreviewNodeFilter}
/>
<!-- Thunderbolt Bridge Cycle Warning -->
@@ -1844,6 +1855,32 @@
</div>
</div>
{/if}
+
+ <!-- Node Filter Indicator (top-right corner) -->
+ {#if isFilterActive()}
+ <button
+ onclick={clearPreviewNodeFilter}
+ class="absolute top-2 right-2 flex items-center gap-1.5 px-2 py-1 bg-exo-dark-gray/80 border border-exo-yellow/40 rounded text-exo-yellow hover:border-exo-yellow/60 transition-colors cursor-pointer backdrop-blur-sm"
+ title="Clear filter"
+ >
+ <span class="text-[10px] font-mono tracking-wider">
+ FILTER: {nodeFilter.size}
+ </span>
+ <svg
+ class="w-3 h-3"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke="currentColor"
+ stroke-width="2"
+ >
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="M6 18L18 6M6 6l12 12"
+ />
+ </svg>
+ </button>
+ {/if}
</div>
<!-- Chat Input - Below topology -->
@@ -2790,7 +2827,11 @@
<div
class="relative aspect-square bg-exo-dark-gray rounded-lg overflow-hidden"
>
- <TopologyGraph highlightedNodes={highlightedNodes()} />
+ <TopologyGraph
+ highlightedNodes={highlightedNodes()}
+ filteredNodes={nodeFilter}
+ onNodeClick={togglePreviewNodeFilter}
+ />
<!-- Thunderbolt Bridge Cycle Warning (compact) -->
{#if tbBridgeCycles.length > 0}
diff --git a/src/exo/master/api.py b/src/exo/master/api.py
index cdcd30d0..a436f53e 100644
--- a/src/exo/master/api.py
+++ b/src/exo/master/api.py
@@ -3,13 +3,13 @@ import json
import time
from collections.abc import AsyncGenerator
from http import HTTPStatus
-from typing import Literal, cast
+from typing import Annotated, Literal, cast
from uuid import uuid4
import anyio
from anyio import BrokenResourceError, create_task_group
from anyio.abc import TaskGroup
-from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile
+from fastapi import FastAPI, File, Form, HTTPException, Query, Request, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
@@ -337,11 +337,20 @@ class API:
return placements[new_ids[0]]
async def get_placement_previews(
- self, model_id: ModelId
+ self,
+ model_id: ModelId,
+ node_ids: Annotated[list[NodeId] | None, Query()] = None,
) -> PlacementPreviewResponse:
seen: set[tuple[ModelId, Sharding, InstanceMeta, int]] = set()
previews: list[PlacementPreview] = []
- if len(list(self.state.topology.list_nodes())) == 0:
+
+ # Create filtered topology if node_ids specified
+ if node_ids and len(node_ids) > 0:
+ topology = self.state.topology.get_subgraph_from_nodes(node_ids)
+ else:
+ topology = self.state.topology
+
+ if len(list(topology.list_nodes())) == 0:
return PlacementPreviewResponse(previews=[])
cards = [card for card in MODEL_CARDS.values() if card.model_id == model_id]
@@ -354,9 +363,7 @@ class API:
instance_combinations.extend(
[
(sharding, instance_meta, i)
- for i in range(
- 1, len(list(self.state.topology.list_nodes())) + 1
- )
+ for i in range(1, len(list(topology.list_nodes())) + 1)
]
)
# TODO: PDD
@@ -374,7 +381,7 @@ class API:
),
node_memory=self.state.node_memory,
node_network=self.state.node_network,
- topology=self.state.topology,
+ topology=topology,
current_instances=self.state.instances,
)
except ValueError as exc:
← 43f12f5d Replace LaunchDaemon with dynamic Thunderbolt Bridge loop de
·
back to Exo
·
Use icon for image editing models (#1252) cd125b3b →