mirror of
https://github.com/exo-explore/exo.git
synced 2025-10-23 02:57:14 +03:00
yum
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
BIN
src/exo/ui/public/avatars/assistant.jpg
Normal file
BIN
src/exo/ui/public/avatars/assistant.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 99 KiB |
BIN
src/exo/ui/public/avatars/assistant.png
Normal file
BIN
src/exo/ui/public/avatars/assistant.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
BIN
src/exo/ui/public/avatars/assistant.webp
Normal file
BIN
src/exo/ui/public/avatars/assistant.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
BIN
src/exo/ui/public/avatars/default.png
Normal file
BIN
src/exo/ui/public/avatars/default.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
BIN
src/exo/ui/public/favicon.png
Normal file
BIN
src/exo/ui/public/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
1
src/exo/ui/public/logo_dark.png
Symbolic link
1
src/exo/ui/public/logo_dark.png
Symbolic link
@@ -0,0 +1 @@
|
||||
/Users/gary/exo-v2/dashboard/exo-logo-hq-square-black-bg.png
|
||||
1
src/exo/ui/public/logo_light.png
Symbolic link
1
src/exo/ui/public/logo_light.png
Symbolic link
@@ -0,0 +1 @@
|
||||
/Users/gary/exo-v2/dashboard/exo-logo-hq-square-black-bg.png
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user