This commit is contained in:
Alex Cheema
2025-10-08 17:40:15 +01:00
parent 8b23361d05
commit 882378954d
10 changed files with 164 additions and 124 deletions

View File

@@ -1148,10 +1148,12 @@
}
}
function renderNodes(nodesData) {
function renderNodes(topologyData) {
if (!topologyGraphContainer) return;
topologyGraphContainer.innerHTML = ''; // Clear previous SVG content
const nodesData = (topologyData && topologyData.nodes) ? topologyData.nodes : {};
const edgesData = (topologyData && Array.isArray(topologyData.edges)) ? topologyData.edges : [];
const nodeIds = Object.keys(nodesData);
if (nodeIds.length === 0) {
@@ -1186,21 +1188,97 @@
};
});
// Create group for links (drawn first, so they are behind nodes)
// Add arrowhead definition (supports bidirectional arrows on a single line)
const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
const marker = document.createElementNS('http://www.w3.org/2000/svg', 'marker');
marker.setAttribute('id', 'arrowhead');
marker.setAttribute('viewBox', '0 0 10 10');
marker.setAttribute('refX', '10');
marker.setAttribute('refY', '5');
marker.setAttribute('markerWidth', '14');
marker.setAttribute('markerHeight', '14');
marker.setAttribute('orient', 'auto-start-reverse');
const markerPath = document.createElementNS('http://www.w3.org/2000/svg', 'path');
markerPath.setAttribute('d', 'M 0 0 L 10 5 L 0 10 z');
markerPath.setAttribute('fill', 'var(--exo-light-gray)');
markerPath.setAttribute('stroke', '#FFFFFF');
markerPath.setAttribute('stroke-width', '2');
markerPath.setAttribute('stroke-linejoin', 'round');
markerPath.setAttribute('stroke-dasharray', 'none');
markerPath.setAttribute('stroke-dashoffset', '0');
markerPath.setAttribute('style', 'animation: none;');
marker.appendChild(markerPath);
defs.appendChild(marker);
topologyGraphContainer.appendChild(defs);
// Create groups for links and separate arrow markers (so arrows are not affected by line animations)
const linksGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g');
linksGroup.setAttribute('class', 'links-group');
linksGroup.setAttribute('style', 'pointer-events: none;');
const arrowsGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g');
arrowsGroup.setAttribute('class', 'arrows-group');
arrowsGroup.setAttribute('style', 'pointer-events: none;');
for (let i = 0; i < numNodes; i++) {
for (let j = i + 1; j < numNodes; j++) {
const link = document.createElementNS('http://www.w3.org/2000/svg', 'line');
link.setAttribute('x1', nodesWithPositions[i].x);
link.setAttribute('y1', nodesWithPositions[i].y);
link.setAttribute('x2', nodesWithPositions[j].x);
link.setAttribute('y2', nodesWithPositions[j].y);
link.setAttribute('class', 'graph-link');
linksGroup.appendChild(link);
}
}
// Build quick lookup for node positions
const positionById = {};
nodesWithPositions.forEach(n => { positionById[n.id] = { x: n.x, y: n.y }; });
// Group directed edges into undirected pairs to support single line with two arrows
const pairMap = new Map(); // key: "a|b" with a<b, value: { a, b, aToB, bToA }
edgesData.forEach(edge => {
if (!edge || !edge.source || !edge.target) return;
if (!positionById[edge.source] || !positionById[edge.target]) return;
if (edge.source === edge.target) return;
const a = edge.source < edge.target ? edge.source : edge.target;
const b = edge.source < edge.target ? edge.target : edge.source;
const key = `${a}|${b}`;
const entry = pairMap.get(key) || { a, b, aToB: false, bToA: false };
if (edge.source === a && edge.target === b) entry.aToB = true; else entry.bToA = true;
pairMap.set(key, entry);
});
// Draw one line per undirected pair with separate arrow carrier lines
pairMap.forEach(entry => {
const posA = positionById[entry.a];
const posB = positionById[entry.b];
if (!posA || !posB) return;
// Compute shortened endpoints so arrows are not hidden behind node icons
const dx = posB.x - posA.x;
const dy = posB.y - posA.y;
const len = Math.hypot(dx, dy) || 1;
const ux = dx / len;
const uy = dy / len;
const baseMargin = nodeRadius * 0.9; // approximate icon half-extent
const maxMargin = Math.max(0, (len / 2) - 6); // avoid crossing for very short links
const margin = Math.min(baseMargin, maxMargin);
const x1 = posA.x + ux * margin;
const y1 = posA.y + uy * margin;
const x2 = posB.x - ux * margin;
const y2 = posB.y - uy * margin;
// Base animated dashed line (no markers)
const baseLine = document.createElementNS('http://www.w3.org/2000/svg', 'line');
baseLine.setAttribute('x1', x1);
baseLine.setAttribute('y1', y1);
baseLine.setAttribute('x2', x2);
baseLine.setAttribute('y2', y2);
baseLine.setAttribute('class', 'graph-link');
linksGroup.appendChild(baseLine);
// Separate arrow carrier line (no stroke, only markers so it doesn't animate)
const arrowLine = document.createElementNS('http://www.w3.org/2000/svg', 'line');
arrowLine.setAttribute('x1', x1);
arrowLine.setAttribute('y1', y1);
arrowLine.setAttribute('x2', x2);
arrowLine.setAttribute('y2', y2);
arrowLine.setAttribute('stroke', 'none');
arrowLine.setAttribute('fill', 'none');
if (entry.aToB) arrowLine.setAttribute('marker-end', 'url(#arrowhead)');
if (entry.bToA) arrowLine.setAttribute('marker-start', 'url(#arrowhead)');
arrowsGroup.appendChild(arrowLine);
});
topologyGraphContainer.appendChild(linksGroup);
// Create group for nodes
@@ -1711,6 +1789,9 @@
nodesGroup.appendChild(nodeG);
});
topologyGraphContainer.appendChild(nodesGroup);
// Bring edges then arrows to the very front
topologyGraphContainer.appendChild(linksGroup);
topologyGraphContainer.appendChild(arrowsGroup);
}
function showNodeDetails(selectedNodeId, allNodesData) {
@@ -1858,13 +1939,13 @@
throw new Error(`HTTP error! status: ${response.status} ${response.statusText}`);
}
const clusterState = await response.json();
const nodesData = transformClusterStateToTopology(clusterState);
renderNodes(nodesData);
const topologyData = transformClusterStateToTopology(clusterState);
renderNodes(topologyData);
// If a node was selected, and it still exists, refresh its details
if (currentlySelectedNodeId && nodesData[currentlySelectedNodeId]) {
showNodeDetails(currentlySelectedNodeId, nodesData);
} else if (currentlySelectedNodeId && !nodesData[currentlySelectedNodeId]) {
if (currentlySelectedNodeId && topologyData.nodes[currentlySelectedNodeId]) {
showNodeDetails(currentlySelectedNodeId, topologyData.nodes);
} else if (currentlySelectedNodeId && !topologyData.nodes[currentlySelectedNodeId]) {
// If selected node is gone, close panel and clear selection
nodeDetailPanel.classList.remove('visible');
currentlySelectedNodeId = null;
@@ -1910,8 +1991,9 @@
}
function transformClusterStateToTopology(clusterState) {
const result = {};
if (!clusterState) return result;
const resultNodes = {};
const resultEdges = [];
if (!clusterState) return { nodes: resultNodes, edges: resultEdges };
// Helper: get numeric bytes from various shapes (number | {in_bytes}|{inBytes})
function getBytes(value) {
@@ -2019,7 +2101,7 @@
timestamp: new Date().toISOString()
};
result[nodeId] = {
resultNodes[nodeId] = {
mem: memBytesTotal,
addrs: addrList,
last_addr_update: Date.now() / 1000,
@@ -2033,7 +2115,21 @@
};
}
return result;
// Extract directed edges from topology.connections if present
const connections = clusterState.topology && Array.isArray(clusterState.topology.connections)
? clusterState.topology.connections
: [];
connections.forEach(conn => {
if (!conn) return;
const src = conn.local_node_id ?? conn.localNodeId;
const dst = conn.send_back_node_id ?? conn.sendBackNodeId;
if (!src || !dst) return;
if (!resultNodes[src] || !resultNodes[dst]) return; // only draw edges between known nodes
if (src === dst) return; // skip self loops for now
resultEdges.push({ source: src, target: dst });
});
return { nodes: resultNodes, edges: resultEdges };
}
// --- Conditional Data Handling ---
@@ -2173,11 +2269,12 @@
mi.timestamp = new Date().toISOString();
}
}
renderNodes(mockData);
const mockTopology = { nodes: mockData, edges: [] };
renderNodes(mockTopology);
lastUpdatedElement.textContent = `Last updated: ${new Date().toLocaleTimeString()} (Mock Data)`;
if (currentlySelectedNodeId && mockData[currentlySelectedNodeId]) {
showNodeDetails(currentlySelectedNodeId, mockData);
showNodeDetails(currentlySelectedNodeId, mockTopology.nodes);
} else if (currentlySelectedNodeId && !mockData[currentlySelectedNodeId]) {
nodeDetailPanel.classList.remove('visible');
currentlySelectedNodeId = null;

View File

@@ -1,5 +1,4 @@
import argparse
import os
from dataclasses import dataclass
from typing import Self
@@ -20,14 +19,8 @@ from exo.utils.channels import Receiver, channel
from exo.utils.pydantic_ext import CamelCaseModel
from exo.worker.download.impl_shard_downloader import exo_shard_downloader
from exo.worker.main import Worker
from exo.utils.chainlit_ui import (
ChainlitConfig,
ChainlitLaunchError,
launch_chainlit,
terminate_process,
)
from exo.utils.browser import open_url_in_browser_when_ready
from exo.utils.chainlit_ui import start_chainlit, chainlit_cleanup
# TODO: Entrypoint refactor
# I marked this as a dataclass as I want trivial constructors.
@@ -163,7 +156,6 @@ class Node:
if self.api:
self.api.reset()
def main():
args = Args.parse()
# TODO: Refactor the current verbosity system
@@ -172,31 +164,19 @@ def main():
node = anyio.run(Node.create, args)
ui_proc = None
if args.with_chainlit:
cfg = ChainlitConfig(
port=args.chainlit_port,
host=args.chainlit_host,
ui_dir=os.path.abspath(os.path.join(os.path.dirname(__file__), "ui")),
)
try:
ui_proc = launch_chainlit(cfg, wait_ready=False)
logger.info(
f"Launching Chainlit (non-blocking) at http://{cfg.host}:{cfg.port} (UI -> API http://localhost:8000/v1)"
)
except ChainlitLaunchError as e:
logger.warning(f"Chainlit not started: {e}")
# Open the dashboard once the API is reachable.
if args.spawn_api:
chainlit_proc = (
start_chainlit(args.chainlit_port, args.chainlit_host, args.headless)
if args.with_chainlit
else None
)
if args.spawn_api and not args.headless:
open_url_in_browser_when_ready(f"http://localhost:{args.api_port}")
try:
anyio.run(node.run)
finally:
if ui_proc is not None:
terminate_process(ui_proc)
logger_cleanup()
chainlit_cleanup(chainlit_proc)
logger_cleanup()
class Args(CamelCaseModel):
@@ -209,6 +189,7 @@ class Args(CamelCaseModel):
with_chainlit: bool = True
chainlit_port: PositiveInt = 8001
chainlit_host: str = "127.0.0.1"
headless: bool = False
@classmethod
def parse(cls) -> Self:
@@ -269,6 +250,12 @@ class Args(CamelCaseModel):
dest="chainlit_host",
default="127.0.0.1",
)
parser.add_argument(
"--headless",
action="store_true",
dest="headless",
help="Prevents the app from opening in the browser."
)
args = parser.parse_args()
return cls(**vars(args)) # pyright: ignore[reportAny] - We are intentionally validating here, we can't do it statically

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

View File

@@ -0,0 +1 @@
/Users/gary/exo-v2/dashboard/exo-logo-hq-square-black-bg.png

View File

@@ -0,0 +1 @@
/Users/gary/exo-v2/dashboard/exo-logo-hq-square-black-bg.png

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
import contextlib
import os
import shutil
import socket
@@ -11,6 +12,9 @@ import urllib.request
from dataclasses import dataclass
from typing import final
from loguru import logger
from pydantic import PositiveInt
@final
class ChainlitLaunchError(RuntimeError):
@@ -24,6 +28,22 @@ class ChainlitConfig:
app_path: str | None = None
ui_dir: str | None = None
def start_chainlit(port: PositiveInt, host: str, headless: bool = False) -> subprocess.Popen[bytes] | None:
cfg = ChainlitConfig(
port=port,
host=host,
ui_dir=os.path.abspath(os.path.join(os.path.dirname(__file__), "ui")),
)
try:
return launch_chainlit(cfg, wait_ready=False, headless=headless)
except ChainlitLaunchError as e:
logger.warning(f"Chainlit not started: {e}")
return None
def chainlit_cleanup(chainlit_proc: subprocess.Popen[bytes] | None) -> None:
if chainlit_proc is None:
return
terminate_process(chainlit_proc)
def _is_port_open(host: str, port: int, timeout_s: float = 0.5) -> bool:
with contextlib.closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s:
@@ -53,16 +73,16 @@ def _find_chainlit_executable() -> list[str]:
def _default_app_path() -> str:
# Resolve the packaged chainlit app location
here = os.path.dirname(__file__)
app = os.path.abspath(os.path.join(here, "../ui/chainlit_app.py"))
app = os.path.abspath(os.path.join(here, "../../ui/chainlit_app.py"))
return app
def launch_chainlit(
cfg: ChainlitConfig,
*,
foreground: bool = False,
wait_ready: bool = True,
ready_timeout_s: float = 20.0,
headless: bool = False,
) -> subprocess.Popen[bytes]:
if _is_port_open(cfg.host, cfg.port):
raise ChainlitLaunchError(f"Port {cfg.port} already in use on {cfg.host}")
@@ -72,77 +92,13 @@ def launch_chainlit(
raise ChainlitLaunchError(f"Chainlit app not found at {app_path}")
env = os.environ.copy()
# Resolve APP_ROOT (directory of the Chainlit app) and ensure assets there
try:
app_dir = os.path.dirname(app_path)
# Also prepare public/ under optional ui_dir for convenience
target_dirs = [os.path.join(app_dir, "public")]
if cfg.ui_dir:
target_dirs.append(os.path.join(cfg.ui_dir, "public"))
# Resolve the repo root from this file to locate dashboard/exo-logo.png
repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../.."))
src_logo_png = os.path.join(repo_root, "dashboard", "exo-logo-hq-square-black-bg.png")
src_logo_jpg = os.path.join(repo_root, "dashboard", "exo-logo-hq-square-black-bg.jpg")
src_logo_favicon = os.path.join(repo_root, "dashboard", "favicon.ico")
src_logo_webp = os.path.join(repo_root, "dashboard", "exo-logo-hq-square-black-bg.webp")
def _ensure(src: str, dst: str) -> None:
# if os.path.exists(src) and not os.path.exists(dst):
try:
os.symlink(src, dst)
except Exception:
import shutil as _shutil
_shutil.copyfile(src, dst)
def _ensure_local_copy(src: str, dst: str) -> None:
# if not os.path.exists(src):
# return
try:
os.makedirs(os.path.dirname(dst), exist_ok=True)
# Replace existing symlink or file with a local copy
if os.path.islink(dst) or os.path.exists(dst):
try:
os.unlink(dst)
except Exception:
pass
import shutil as _shutil
_shutil.copyfile(src, dst)
except Exception:
pass
for pub_dir in target_dirs:
os.makedirs(pub_dir, exist_ok=True)
# Logos per docs
_ensure(src_logo_png, os.path.join(pub_dir, "logo_dark.png"))
_ensure(src_logo_png, os.path.join(pub_dir, "logo_light.png"))
# Favicon (serve a real local file; avoid symlinks for server path checks)
_ensure_local_copy(src_logo_png, os.path.join(pub_dir, "favicon.png"))
# Provide a .ico fallback
_ensure_local_copy(src_logo_favicon, os.path.join(pub_dir, "favicon.ico"))
# Avatars
avatars_dir = os.path.join(pub_dir, "avatars")
os.makedirs(avatars_dir, exist_ok=True)
# Always local copies for avatars to satisfy is_path_inside checks
_ensure_local_copy(src_logo_png, os.path.join(avatars_dir, "assistant.png"))
_ensure_local_copy(src_logo_png, os.path.join(avatars_dir, "default.png"))
# Extra avatar formats as fallback
_ensure_local_copy(src_logo_jpg, os.path.join(avatars_dir, "assistant.jpg"))
_ensure_local_copy(src_logo_webp, os.path.join(avatars_dir, "assistant.webp"))
except Exception:
# Non-fatal; logo absence shouldn't block UI
pass
cmd = [*_find_chainlit_executable(), "run", app_path, "--host", cfg.host, "--port", str(cfg.port)]
if headless:
cmd.append("--headless")
cwd = None
if cfg.ui_dir:
cwd = cfg.ui_dir
if foreground:
if cwd is not None:
os.chdir(cwd)
os.execvpe(cmd[0], cmd, env)
raise AssertionError("os.execvpe should not return")
proc = subprocess.Popen(
cmd,
env=env,
@@ -180,5 +136,3 @@ def terminate_process(proc: subprocess.Popen[bytes], *, timeout_s: float = 5.0)
proc.kill()
except Exception:
return