← back to Exo
backport the dashboard to staging
09593c5e85595af72ab10e76b105899cdaf7c8e1 · 2025-12-17 12:22:22 +0000 · Evan Quiney
Files touched
M .gitignoreD dashboard/index.htmlA dashboard/package-lock.jsonA dashboard/package.jsonA dashboard/src/app.cssA dashboard/src/app.d.tsA dashboard/src/app.htmlA dashboard/src/lib/components/ChatAttachments.svelteA dashboard/src/lib/components/ChatForm.svelteA dashboard/src/lib/components/ChatMessages.svelteA dashboard/src/lib/components/ChatSidebar.svelteA dashboard/src/lib/components/HeaderNav.svelteA dashboard/src/lib/components/ModelCard.svelteA dashboard/src/lib/components/TopologyGraph.svelteA dashboard/src/lib/components/index.tsA dashboard/src/lib/stores/app.svelte.tsA dashboard/src/lib/types/files.tsA dashboard/src/routes/+layout.svelteA dashboard/src/routes/+page.svelteA dashboard/src/routes/downloads/+page.svelteA dashboard/static/exo-logo.pngA dashboard/static/favicon.icoA dashboard/svelte.config.jsA dashboard/tsconfig.jsonA dashboard/vite.config.tsM flake.nixM justfileM src/exo/master/api.pyM src/exo/master/main.pyM src/exo/master/placement.pyM src/exo/master/tests/test_master.pyM src/exo/master/tests/test_placement.pyM src/exo/shared/models/model_cards.pyM src/exo/shared/types/api.pyM src/exo/shared/types/commands.pyM src/exo/shared/types/memory.pyA src/exo/utils/dashboard_path.py
Diff
commit 09593c5e85595af72ab10e76b105899cdaf7c8e1
Author: Evan Quiney <evanev7@gmail.com>
Date: Wed Dec 17 12:22:22 2025 +0000
backport the dashboard to staging
---
.gitignore | 31 +-
dashboard/index.html | 3343 --------------------
dashboard/package-lock.json | 3058 ++++++++++++++++++
dashboard/package.json | 33 +
dashboard/src/app.css | 322 ++
dashboard/src/app.d.ts | 14 +
dashboard/src/app.html | 14 +
.../src/lib/components/ChatAttachments.svelte | 75 +
dashboard/src/lib/components/ChatForm.svelte | 398 +++
dashboard/src/lib/components/ChatMessages.svelte | 462 +++
dashboard/src/lib/components/ChatSidebar.svelte | 430 +++
dashboard/src/lib/components/HeaderNav.svelte | 57 +
dashboard/src/lib/components/ModelCard.svelte | 660 ++++
dashboard/src/lib/components/TopologyGraph.svelte | 971 ++++++
dashboard/src/lib/components/index.ts | 7 +
dashboard/src/lib/stores/app.svelte.ts | 1395 ++++++++
dashboard/src/lib/types/files.ts | 169 +
dashboard/src/routes/+layout.svelte | 15 +
dashboard/src/routes/+page.svelte | 1840 +++++++++++
dashboard/src/routes/downloads/+page.svelte | 441 +++
dashboard/static/exo-logo.png | Bin 0 -> 1655 bytes
dashboard/static/favicon.ico | Bin 0 -> 4286 bytes
dashboard/svelte.config.js | 28 +
dashboard/tsconfig.json | 15 +
dashboard/vite.config.ts | 16 +
flake.nix | 4 +-
justfile | 12 +
src/exo/master/api.py | 228 +-
src/exo/master/main.py | 21 +-
src/exo/master/placement.py | 21 +-
src/exo/master/tests/test_master.py | 4 +-
src/exo/master/tests/test_placement.py | 32 +-
src/exo/shared/models/model_cards.py | 297 +-
src/exo/shared/types/api.py | 38 +-
src/exo/shared/types/commands.py | 9 +-
src/exo/shared/types/memory.py | 5 +
src/exo/utils/dashboard_path.py | 45 +
37 files changed, 10975 insertions(+), 3535 deletions(-)
diff --git a/.gitignore b/.gitignore
index feae4364..befc8b3b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,31 +1,24 @@
-*/__pycache__
-__pycache__
-*.so
+# gitingest
+digest.txt
-hosts.json
-hosts*.json
-nodes.json
+# python
+**/__pycache__
-# hide direnv stuff
+# nix
.direnv/
-build/
-dist/
+# xcode / macos
*.xcuserstate
-.DS_Store
-*/.DS_Store
+**/.DS_Store
-# for the gitingest enthusiasts
-digest.txt
-# Rust
+# rust
target/
-## These are backup files generated by rustfmt
**/*.rs.bk
-## MSVC Windows builds of rustc generate these, which store debugging information
*.pdb
-## Generated by cargo mutants
-## Contains mutation testing data
-**/mutants.out*/
+# svelte
+dashboard/build/
+dashboard/node_modules/
+dashboard/.svelte-kit/
diff --git a/dashboard/index.html b/dashboard/index.html
deleted file mode 100644
index 896b79a9..00000000
--- a/dashboard/index.html
+++ /dev/null
@@ -1,3343 +0,0 @@
-<!DOCTYPE html>
-<html lang="en">
-<head>
- <meta charset="UTF-8">
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
- <title>EXO</title>
- <style>
- :root {
- --exo-black: #121212;
- --exo-dark-gray: #1E1E1E;
- --exo-medium-gray: #2C2C2C;
- --exo-light-gray: #B3B3B3;
- --exo-yellow: #FFD700;
- --exo-yellow-darker: #cca300;
- --font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
- }
-
- body {
- background-color: var(--exo-black);
- color: var(--exo-light-gray);
- font-family: var(--font-family);
- margin: 0;
- padding: 40px 20px 20px 20px;
- display: flex;
- flex-direction: column;
- align-items: center;
- }
-
- .dashboard-header {
- width: 100%;
- max-width: 1200px;
- margin-bottom: 15px;
- margin-top: 20px;
- display: grid;
- grid-template-columns: 1fr auto 1fr;
- align-items: flex-end;
- gap: 20px;
- }
-
- .dashboard-header h1 {
- font-size: 2.5em;
- color: var(--exo-yellow);
- margin: 0;
- font-weight: 600;
- line-height: 1;
- }
- .dashboard-header h1 .logo-text {
- font-weight: bold;
- }
- .dashboard-header p {
- font-size: 1em;
- color: var(--exo-light-gray);
- margin-top: 5px;
- margin-bottom: 0;
- line-height: 1;
- }
- .dashboard-header .last-updated {
- font-size: 0.8em;
- color: var(--exo-medium-gray);
- margin-top: 10px;
- margin-bottom: 0;
- line-height: 1;
- }
-
- .header-left {
- display: flex;
- flex-direction: column;
- }
-
- .header-center {
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- }
-
- .header-right {
- display: flex;
- justify-content: flex-end;
- }
-
- .header-instances-button {
- background-color: transparent;
- border: 1px solid var(--exo-medium-gray);
- color: var(--exo-light-gray);
- font-family: var(--font-family);
- font-size: 14px;
- padding: 8px 16px;
- cursor: pointer;
- border-radius: 4px;
- transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease;
- line-height: 1;
- }
-
- .header-instances-button:hover {
- background-color: var(--exo-medium-gray);
- color: var(--exo-yellow);
- border-color: var(--exo-yellow);
- }
-
- .header-instances-button.active {
- background-color: var(--exo-yellow);
- color: var(--exo-black);
- border-color: var(--exo-yellow);
- }
-
- /* Removed .node-grid and related card styles as we move to SVG graph */
- /* Styles for the new topology graph */
- #topologyGraphContainer {
- width: 100%;
- max-width: 1200px; /* Max width consistent with header */
- height: 70vh; /* Viewport height, can be adjusted */
- min-height: 500px; /* Minimum height */
- background-color: var(--exo-dark-gray); /* Dark background for the graph area */
- border-radius: 8px;
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
- margin-top: 5px;
- position: relative; /* For potential absolute positioning of elements within */
- }
-
- .graph-node {
- cursor: pointer;
- }
- .graph-node circle {
- stroke: var(--exo-medium-gray);
- stroke-width: 1.5px;
- fill: var(--exo-medium-gray);
- cursor: pointer;
- transition: fill 0.2s ease, stroke-width 0.2s ease;
- }
- .graph-node circle:hover {
- fill: var(--exo-yellow-darker);
- stroke-width: 3px;
- }
- .graph-node text {
- fill: var(--exo-light-gray);
- font-size: 10px;
- text-anchor: middle;
- pointer-events: none; /* So text doesn't interfere with circle hover */
- font-family: var(--font-family);
- }
- .graph-node .node-info-text {
- font-size: 8px;
- fill: var(--exo-light-gray);
- text-anchor: middle;
- pointer-events: none;
- }
- .graph-node .node-memory-text {
- font-size: 15px;
- fill: #FFFFFF;
- text-anchor: middle;
- pointer-events: none;
- font-weight: 500;
- }
- .graph-node .gpu-temp-on-bar {
- font-size: 16px; /* Increased from 14px */
- fill: #FFFFFF; /* Changed to white */
- text-anchor: middle;
- dominant-baseline: central;
- pointer-events: none;
- font-weight: bold;
- }
- .graph-link {
- stroke: var(--exo-light-gray);
- stroke-width: 1px;
- opacity: 0.8;
- stroke-dasharray: 4, 4; /* 4px dash, 4px gap */
- animation: flowAnimation 0.75s linear infinite;
- }
-
- @keyframes flowAnimation {
- from {
- stroke-dashoffset: 0;
- }
- to {
- stroke-dashoffset: -8; /* Negative of (dash + gap) for forward flow */
- }
- }
-
- .edge-label {
- font-size: 10px;
- fill: var(--exo-light-gray);
- text-anchor: middle;
- pointer-events: none;
- font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
- opacity: 0.95;
- }
-
- .edge-label-bg {
- fill: var(--exo-dark-gray);
- opacity: 0.85;
- }
-
- .node-info-grid {
- display: grid;
- grid-template-columns: repeat(2, 1fr);
- gap: 10px 15px;
- }
-
- .info-item {
- font-size: 0.9em;
- }
-
- .info-item .label {
- display: block;
- font-size: 0.8em;
- color: var(--exo-light-gray);
- margin-bottom: 3px;
- opacity: 0.7;
- }
-
- .info-item .value {
- font-weight: 500;
- color: #E0E0E0;
- }
-
- .info-item .value.accent {
- color: var(--exo-yellow);
- font-weight: bold;
- }
-
- .progress-bar-container {
- width: 100%;
- background-color: var(--exo-medium-gray);
- border-radius: 4px;
- height: 10px;
- overflow: hidden;
- margin-top: 4px;
- }
-
- .progress-bar {
- height: 100%;
- background-color: var(--exo-yellow);
- border-radius: 4px;
- transition: width 0.3s ease; /* Keep transition for smoothness */
- }
-
- .node-footer {
- font-size: 0.8em;
- color: var(--exo-light-gray);
- opacity: 0.6;
- margin-top: auto;
- padding-top: 10px;
- border-top: 1px solid var(--exo-medium-gray);
- text-align: right;
- }
-
- .loading-message, .error-message {
- text-align: center;
- font-size: 1.2em;
- padding: 20px;
- color: var(--exo-light-gray);
- }
- .error-message {
- color: #F44336;
- }
-
- /* Styles for Node Detail Panel */
- #nodeDetailPanel {
- position: fixed;
- right: -400px; /* Start off-screen */
- top: 0;
- width: 380px;
- height: 100vh;
- background-color: var(--exo-dark-gray);
- box-shadow: -5px 0px 15px rgba(0,0,0,0.3);
- padding: 20px;
- overflow-y: auto;
- transition: right 0.3s ease-in-out;
- z-index: 1000;
- border-left: 1px solid var(--exo-medium-gray);
- transform: translateX(100%);
- opacity: 0;
- visibility: hidden;
- }
- #nodeDetailPanel.visible {
- right: 0;
- transform: translateX(0);
- opacity: 1;
- visibility: visible;
- }
- #nodeDetailPanel h2 {
- color: var(--exo-yellow);
- margin-top: 0;
- margin-bottom: 5px;
- font-size: 1.6em;
- }
- #nodeDetailPanel .detail-node-id {
- font-size: 0.7em;
- color: var(--exo-medium-gray);
- margin-bottom: 15px;
- word-break: break-all;
- opacity: 0.7;
- }
- #nodeDetailPanel .info-item {
- margin-bottom: 12px;
- font-size: 0.95em;
- }
- #nodeDetailPanel .info-item .label {
- display: block;
- font-size: 0.8em;
- color: var(--exo-light-gray);
- margin-bottom: 4px;
- opacity: 0.7;
- }
- #nodeDetailPanel .info-item .value {
- font-weight: 500;
- color: #E0E0E0;
- }
- #nodeDetailPanel .info-item .value.accent {
- color: var(--exo-yellow);
- font-weight: bold;
- }
- #nodeDetailPanel .progress-bar-container {
- width: 100%;
- background-color: var(--exo-medium-gray);
- border-radius: 4px;
- height: 10px;
- overflow: hidden;
- margin-top: 4px;
- }
- #nodeDetailPanel .progress-bar {
- height: 100%;
- background-color: var(--exo-yellow);
- border-radius: 4px;
- transition: width 0.3s ease;
- }
- #closeDetailPanel {
- position: absolute;
- top: 15px;
- right: 15px;
- font-size: 1.5em;
- color: var(--exo-light-gray);
- cursor: pointer;
- padding: 5px;
- line-height: 1;
- }
- #closeDetailPanel:hover {
- color: var(--exo-yellow);
- }
-
-
-
- /* Sidebar styles */
- .sidebar {
- position: fixed;
- top: 0;
- left: -350px;
- width: 350px;
- height: 100vh;
- background-color: var(--exo-dark-gray);
- border-right: 1px solid var(--exo-medium-gray);
- box-shadow: 2px 0 8px rgba(0, 0, 0, 0.3);
- transition: left 0.3s ease;
- z-index: 999;
- overflow-y: auto;
- visibility: hidden;
- opacity: 0;
- }
-
- .sidebar.open {
- left: 0;
- visibility: visible;
- opacity: 1;
- }
-
- .sidebar-header {
- padding: 20px;
- border-bottom: 1px solid var(--exo-medium-gray);
- background-color: var(--exo-medium-gray);
- }
-
- .sidebar-header h3 {
- margin: 0;
- color: var(--exo-yellow);
- font-size: 18px;
- font-weight: 600;
- }
-
- .sidebar-content {
- padding: 20px;
- }
-
- /* Instance list styles */
- .instance-item {
- background-color: var(--exo-medium-gray);
- border-radius: 6px;
- padding: 15px;
- margin-bottom: 12px;
- border-left: 4px solid var(--exo-yellow);
- transition: background-color 0.2s ease;
- position: relative;
- }
-
- .instance-color-indicator {
- position: absolute;
- top: 15px;
- left: -4px;
- width: 4px;
- height: calc(100% - 30px);
- border-radius: 0 2px 2px 0;
- }
-
- .instance-item:hover {
- background-color: #353535;
- }
-
- .instance-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-bottom: 8px;
- }
-
- .instance-id {
- font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
- font-size: 12px;
- color: var(--exo-light-gray);
- background-color: var(--exo-black);
- padding: 2px 6px;
- border-radius: 3px;
- margin-right: 10px;
- }
-
- .instance-status {
- font-size: 12px;
- padding: 2px 8px;
- border-radius: 12px;
- font-weight: 500;
- }
-
- .instance-status.active {
- background-color: #4ade80;
- color: var(--exo-black);
- }
-
- .instance-status.inactive {
- background-color: #ef4444;
- color: white;
- }
-
- .instance-status.downloading {
- background-color: #f59e0b;
- color: var(--exo-black);
- }
- /* New runner-status aware pills */
- .instance-status.starting {
- background-color: #3b82f6; /* blue */
- color: var(--exo-black);
- }
-
- .instance-status.loaded {
- background-color: #2dd4bf; /* teal */
- color: var(--exo-black);
- }
-
- .instance-status.running {
- background-color: #4ade80; /* green */
- color: var(--exo-black);
- }
-
- .instance-status.failed {
- background-color: #ef4444; /* red */
- color: white;
- }
-
- .instance-delete-button {
- background-color: #ef4444;
- color: white;
- border: none;
- border-radius: 4px;
- padding: 4px 8px;
- font-size: 11px;
- cursor: pointer;
- transition: background-color 0.2s ease;
- margin-left: 8px;
- }
-
- .instance-delete-button:hover {
- background-color: #dc2626;
- }
-
- .instance-actions {
- display: flex;
- align-items: center;
- }
-
- .instance-info {
- display: flex;
- align-items: center;
- }
-
- .instance-model {
- font-size: 14px;
- font-weight: 500;
- color: var(--exo-yellow);
- margin-bottom: 8px;
- }
-
- .instance-strategy {
- font-size: 13px;
- color: var(--exo-light-gray);
- margin-bottom: 8px;
- }
-
- .instance-strategy-value {
- font-weight: 600;
- color: var(--exo-yellow);
- }
-
- .instance-details {
- font-size: 12px;
- color: var(--exo-light-gray);
- }
-
-
-
- .progress-bar-container {
- background-color: var(--exo-black);
- border-radius: 8px;
- height: 6px;
- flex-grow: 1;
- overflow: hidden;
- }
-
- .progress-bar {
- background-color: #3b82f6;
- height: 100%;
- border-radius: 8px;
- transition: width 0.3s ease;
- }
-
-
- /* Overall download summary styles */
- .overall-download-summary {
- margin-top: 10px;
- margin-bottom: 8px;
- }
-
- .overall-download-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-bottom: 4px;
- }
-
- .overall-download-label {
- font-size: 11px;
- font-weight: 500;
- color: var(--exo-light-gray);
- opacity: 0.7;
- }
-
- .overall-download-percent {
- font-size: 11px;
- font-weight: 500;
- color: var(--exo-light-gray);
- opacity: 0.7;
- font-variant-numeric: tabular-nums;
- }
-
- .overall-download-stats {
- font-size: 10px;
- color: var(--exo-light-gray);
- margin-top: 4px;
- opacity: 0.6;
- }
-
- /* Per-node download summary styles */
- .node-download-summary {
- margin-top: 12px;
- padding: 10px;
- background-color: rgba(0, 0, 0, 0.2);
- border-radius: 6px;
- border-left: 3px solid #3b82f6;
- }
-
- .node-download-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-bottom: 6px;
- }
-
- .node-download-name {
- font-size: 13px;
- font-weight: 600;
- color: var(--exo-yellow);
- }
-
- .node-download-percent {
- font-size: 13px;
- font-weight: 600;
- color: #3b82f6;
- font-variant-numeric: tabular-nums;
- }
-
- .node-download-stats {
- font-size: 11px;
- color: var(--exo-light-gray);
- margin-top: 6px;
- margin-bottom: 10px;
- opacity: 0.9;
- }
-
- /* File-level download details */
- .download-files-list {
- display: grid;
- gap: 8px;
- margin-top: 10px;
- }
-
- .download-file {
- padding: 8px;
- background-color: rgba(0, 0, 0, 0.3);
- border: 1px solid var(--exo-medium-gray);
- border-radius: 6px;
- box-sizing: border-box;
- width: 100%;
- max-width: 100%;
- }
-
- .download-file-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- gap: 10px;
- font-size: 11px;
- margin-bottom: 6px;
- width: 100%;
- max-width: 100%;
- overflow: hidden;
- }
-
- .download-file-name {
- color: #E0E0E0;
- font-weight: 500;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- min-width: 0;
- flex: 1 1 auto;
- }
-
- .download-file-percent {
- color: var(--exo-light-gray);
- white-space: nowrap;
- font-size: 11px;
- font-variant-numeric: tabular-nums;
- flex: 0 0 auto;
- }
-
- .download-file-subtext {
- color: var(--exo-light-gray);
- font-size: 10px;
- opacity: 0.85;
- margin-bottom: 6px;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- max-width: 100%;
- }
-
- .download-file .progress-bar-container {
- width: 100%;
- max-width: 100%;
- box-sizing: border-box;
- height: 5px;
- }
-
- .completed-files-section {
- margin-top: 12px;
- padding-top: 8px;
- border-top: 1px solid rgba(255, 255, 255, 0.1);
- }
-
- .completed-files-header {
- font-size: 10px;
- color: var(--exo-light-gray);
- opacity: 0.7;
- margin-bottom: 6px;
- font-weight: 500;
- }
-
- .completed-files-list {
- display: flex;
- flex-direction: column;
- gap: 3px;
- }
-
- .completed-file-item {
- font-size: 10px;
- color: var(--exo-light-gray);
- opacity: 0.8;
- padding: 3px 6px;
- background-color: rgba(74, 222, 128, 0.1);
- border-left: 2px solid #4ade80;
- border-radius: 3px;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
-
- /* Launch instance section styles */
- .launch-instance-section {
- display: flex;
- flex-direction: column;
- gap: 12px;
- margin-bottom: 50px;
- }
-
- .launch-label {
- font-size: 14px;
- font-weight: 500;
- color: var(--exo-light-gray);
- margin-bottom: 4px;
- }
-
- .model-select {
- background: linear-gradient(135deg, #2a2a2a 0%, #3c3c3c 50%, #2a2a2a 100%);
- color: var(--exo-light-gray);
- border: 2px solid rgba(255, 215, 0, 0.2);
- border-radius: 12px;
- padding: 14px 20px 14px 16px;
- font-size: 15px;
- font-family: var(--font-family);
- font-weight: 500;
- cursor: pointer;
- transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
- box-shadow:
- 0 4px 12px rgba(0, 0, 0, 0.25),
- inset 0 1px 0 rgba(255, 255, 255, 0.12),
- inset 0 -1px 0 rgba(0, 0, 0, 0.1);
- position: relative;
- appearance: none;
- width: 100%;
- min-height: 48px;
- background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23FFD700' d='M6 8.5L2.5 5h7z'/%3E%3C/svg%3E");
- background-position: calc(100% - 16px) center;
- background-size: 12px 12px;
- background-repeat: no-repeat;
- }
-
- .model-select:hover {
- background: linear-gradient(135deg, #363636 0%, #484848 50%, #363636 100%);
- border-color: rgba(255, 215, 0, 0.5);
- box-shadow:
- 0 6px 20px rgba(0, 0, 0, 0.3),
- inset 0 1px 0 rgba(255, 255, 255, 0.15),
- inset 0 -1px 0 rgba(0, 0, 0, 0.1),
- 0 0 0 1px rgba(255, 215, 0, 0.1);
- transform: translateY(-2px);
- }
-
- .model-select:focus {
- outline: none;
- border-color: var(--exo-yellow);
- box-shadow:
- 0 0 0 4px rgba(255, 215, 0, 0.25),
- 0 8px 24px rgba(0, 0, 0, 0.4),
- inset 0 1px 0 rgba(255, 255, 255, 0.2),
- inset 0 -1px 0 rgba(0, 0, 0, 0.1);
- background: linear-gradient(135deg, #404040 0%, #525252 50%, #404040 100%);
- transform: translateY(-1px);
- }
-
- .model-select:active {
- transform: translateY(0);
- box-shadow:
- 0 2px 8px rgba(0, 0, 0, 0.3),
- inset 0 1px 0 rgba(255, 255, 255, 0.1),
- inset 0 2px 6px rgba(0, 0, 0, 0.2);
- }
-
- .model-select:disabled {
- background: linear-gradient(135deg, #1a1a1a 0%, #222222 100%);
- color: #555555;
- border-color: #333333;
- cursor: not-allowed;
- transform: none;
- box-shadow: inset 0 2px 6px rgba(0, 0, 0, 0.4);
- background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23555555' d='M6 8.5L2.5 5h7z'/%3E%3C/svg%3E");
- }
-
- .model-select option {
- background-color: var(--exo-dark-gray);
- color: var(--exo-light-gray);
- padding: 12px 16px;
- border: none;
- font-size: 14px;
- font-weight: 500;
- }
-
- .model-select option:hover {
- background-color: var(--exo-medium-gray);
- color: var(--exo-yellow);
- }
-
- .model-select option:checked {
- background-color: var(--exo-yellow);
- color: var(--exo-black);
- font-weight: 600;
- }
-
- .launch-button {
- background-color: var(--exo-yellow);
- color: var(--exo-black);
- border: none;
- border-radius: 6px;
- padding: 12px 16px;
- font-size: 14px;
- font-weight: 600;
- font-family: var(--font-family);
- cursor: pointer;
- transition: background-color 0.2s ease;
- }
-
- .launch-button:hover:not(:disabled) {
- background-color: var(--exo-yellow-darker);
- }
-
- .launch-button:disabled {
- background-color: var(--exo-medium-gray);
- color: var(--exo-light-gray);
- cursor: not-allowed;
- }
-
- .strategy-selector {
- display: flex;
- flex-direction: column;
- gap: 8px;
- }
-
- .strategy-options {
- display: flex;
- gap: 12px;
- flex-wrap: wrap;
- }
-
- .strategy-option {
- display: flex;
- align-items: center;
- gap: 6px;
- cursor: pointer;
- padding: 8px 12px;
- border-radius: 6px;
- background-color: var(--exo-dark-gray);
- border: 2px solid var(--exo-medium-gray);
- transition: all 0.2s ease;
- user-select: none;
- }
-
- .strategy-option:hover {
- background-color: var(--exo-medium-gray);
- border-color: rgba(255, 215, 0, 0.5);
- }
-
- .strategy-option input[type="radio"] {
- appearance: none;
- width: 16px;
- height: 16px;
- border: 2px solid var(--exo-light-gray);
- border-radius: 50%;
- cursor: pointer;
- position: relative;
- margin: 0;
- transition: all 0.2s ease;
- }
-
- .strategy-option input[type="radio"]:checked {
- border-color: var(--exo-yellow);
- background-color: var(--exo-yellow);
- }
-
- .strategy-option input[type="radio"]:checked::after {
- content: '';
- position: absolute;
- top: 50%;
- left: 50%;
- transform: translate(-50%, -50%);
- width: 6px;
- height: 6px;
- border-radius: 50%;
- background-color: var(--exo-black);
- }
-
- .strategy-option:has(input[type="radio"]:checked) {
- background-color: rgba(255, 215, 0, 0.15);
- border-color: var(--exo-yellow);
- }
-
- .strategy-option label {
- cursor: pointer;
- font-size: 14px;
- font-weight: 500;
- color: var(--exo-light-gray);
- margin: 0;
- }
-
- .strategy-option:has(input[type="radio"]:checked) label {
- color: var(--exo-yellow);
- }
-
- .launch-status {
- font-size: 12px;
- padding: 8px;
- border-radius: 4px;
- text-align: center;
- display: none;
- }
-
- .launch-status.success {
- background-color: rgba(74, 222, 128, 0.1);
- color: #4ade80;
- border: 1px solid #4ade80;
- display: block;
- }
-
- .launch-status.error {
- background-color: rgba(239, 68, 68, 0.1);
- color: #ef4444;
- border: 1px solid #ef4444;
- display: block;
- }
-
- .launch-status.loading {
- background-color: rgba(255, 215, 0, 0.1);
- color: var(--exo-yellow);
- border: 1px solid var(--exo-yellow);
- display: block;
- }
-
- .instance-hosts {
- margin-top: 8px;
- }
-
- .instance-host {
- display: inline-block;
- background-color: var(--exo-black);
- padding: 2px 6px;
- border-radius: 3px;
- margin-right: 6px;
- margin-bottom: 4px;
- font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
- font-size: 11px;
- }
-
- .no-instances {
- text-align: center;
- color: var(--exo-light-gray);
- font-style: italic;
- margin-top: 40px;
- margin-bottom: 30px;
- }
-
-
-
- </style>
-</head>
-<body>
-
-
-
- <!-- Sidebar -->
- <div class="sidebar" id="instancesSidebar">
- <div class="sidebar-header">
- <h3>Running Instances</h3>
- </div>
- <div class="sidebar-content" id="instancesList">
- <div class="no-instances">Loading instances...</div>
- </div>
-
- <div class="sidebar-header">
- <h3>Launch Instance</h3>
- </div>
- <div class="sidebar-content">
- <div class="launch-instance-section">
- <label for="modelSelect" class="launch-label">Select Model:</label>
- <select id="modelSelect" class="model-select">
- <option value="">Loading models...</option>
- </select>
-
- <div class="strategy-selector">
- <label class="launch-label">Sharding:</label>
- <div class="strategy-options">
- <div class="strategy-option">
- <input type="radio" id="shardingPipeline" name="sharding" value="Pipeline">
- <label for="shardingPipeline">Pipeline</label>
- </div>
- <div class="strategy-option">
- <input type="radio" id="shardingTensor" name="sharding" value="Tensor" checked>
- <label for="shardingTensor">Tensor</label>
- </div>
- </div>
- </div>
-
- <div class="strategy-selector">
- <label class="launch-label">Instance Type:</label>
- <div class="strategy-options">
- <div class="strategy-option">
- <input type="radio" id="instanceMlxRing" name="instance_meta" value="MlxRing">
- <label for="instanceMlxRing">MLX Ring</label>
- </div>
- <div class="strategy-option">
- <input type="radio" id="instanceMlxIbv" name="instance_meta" value="MlxIbv" checked>
- <label for="instanceMlxIbv">MLX IBV</label>
- </div>
- </div>
- </div>
-
- <div class="strategy-selector">
- <label class="launch-label">Minimum Nodes:</label>
- <div class="strategy-options" id="minNodesOptions">
- <div class="strategy-option">
- <input type="radio" id="minNodes1" name="min_nodes" value="1" checked>
- <label for="minNodes1">1</label>
- </div>
- </div>
- </div>
-
- <button id="launchInstanceButton" class="launch-button" disabled>Launch Instance</button>
- <div id="launchStatus" class="launch-status"></div>
- </div>
- </div>
- </div>
-
- <div class="dashboard-header">
- <div class="header-left">
- <!-- Left section: empty or can be used for future content -->
- </div>
- <div class="header-center">
- <h1><img src="exo-logo.png" alt="EXO logo" height="48" /></h1>
- <p class="last-updated" id="lastUpdated">Fetching data...</p>
- </div>
- <div class="header-right">
- <button class="header-instances-button" id="instancesMenuButton">Instances</button>
- </div>
- </div>
-
- <!-- Replaced node-grid with SVG container for topology graph -->
- <svg id="topologyGraphContainer"></svg>
-
- <div id="nodeDetailPanel">
- <span id="closeDetailPanel" title="Close">×</span>
- <h2 id="detailFriendlyName">Node Details</h2>
- <div id="detailNodeId" class="detail-node-id"></div>
- <div id="detailContent">
- <!-- Info items will be populated here -->
- </div>
- </div>
-
- <script>
- // Helper to convert RGB string for box-shadow
- function setRgbVar(cssVarName, hexColor) {
- const r = parseInt(hexColor.slice(1, 3), 16);
- const g = parseInt(hexColor.slice(3, 5), 16);
- const b = parseInt(hexColor.slice(5, 7), 16);
- document.documentElement.style.setProperty(`--${cssVarName}-rgb`, `${r}, ${g}, ${b}`);
- }
- setRgbVar('exo-yellow', getComputedStyle(document.documentElement).getPropertyValue('--exo-yellow').trim());
-
- // Generate a consistent color for an instance ID using a simple hash
- function generateInstanceColor(instanceId) {
- if (!instanceId) return '#888888';
-
- // Simple hash function
- let hash = 0;
- for (let i = 0; i < instanceId.length; i++) {
- hash = instanceId.charCodeAt(i) + ((hash << 5) - hash);
- }
-
- // Convert to HSL for better color distribution
- // Use high saturation and medium lightness for vibrant, distinguishable colors
- const hue = Math.abs(hash % 360);
- const saturation = 65 + (Math.abs(hash >> 8) % 20); // 65-85%
- const lightness = 55 + (Math.abs(hash >> 16) % 15); // 55-70%
-
- return `hsl(${hue}, ${saturation}%, ${lightness}%)`;
- }
-
- const topologyGraphContainer = document.getElementById('topologyGraphContainer');
- const lastUpdatedElement = document.getElementById('lastUpdated');
- const nodeDetailPanel = document.getElementById('nodeDetailPanel');
- const closeDetailPanelButton = document.getElementById('closeDetailPanel');
- const detailFriendlyName = document.getElementById('detailFriendlyName');
- const detailNodeId = document.getElementById('detailNodeId');
- const detailContent = document.getElementById('detailContent');
-
- // Sidebar elements
- const instancesMenuButton = document.getElementById('instancesMenuButton');
- const instancesSidebar = document.getElementById('instancesSidebar');
- const instancesList = document.getElementById('instancesList');
-
- // Launch instance elements
- const modelSelect = document.getElementById('modelSelect');
- const launchInstanceButton = document.getElementById('launchInstanceButton');
- const launchStatus = document.getElementById('launchStatus');
- const minNodesOptions = document.getElementById('minNodesOptions');
-
- const USE_MOCK_DATA = false; // <<< FLAG TO TOGGLE MOCK DATA
- let currentlySelectedNodeId = null; // To store the ID of the currently selected node
- let nodeIdToFriendlyName = {}; // Map nodeId -> friendly name for download sections
- let instanceIdToColor = {}; // Map instanceId -> color for visual coding
- let connectionToInstances = {}; // Map "nodeA|nodeB" -> [instanceIds] using that connection
- let currentNodeCount = 1; // Track the current number of nodes in topology
-
- const API_ENDPOINT = window.location.origin + window.location.pathname.replace(/\/$/, "") + '/state';
- const REFRESH_INTERVAL = 1000; // 1 second
-
- // SVG Path for Apple Logo (scaled to roughly 20x24 units)
- // const APPLE_LOGO_PATH = "M15.36,10.08c0-2.84-1.92-4.48-4.8-4.48-2.36,0-4.44,1.44-5.64,2.84-1.48,1.68-2.44,4-2.44,6.52,0,2.92,1.72,4.6,4.76,4.6,2.48,0,4.64-1.52,5.84-2.96,1.36-1.6,2.32-3.96,2.32-6.52ZM11.48,0c2.4,0,4.24.48,5.48,1.44a5.07,5.07,0,0,1,1.44,3.88c0,2.04-.8,3.56-2.44,4.6-1.68,1.08-3.48,1.6-5.44,1.6-.96,0-2.12-.12-3.48-.32V3.08A11.29,11.29,0,0,1,11.48,0Z M9.04,13.2A5.31,5.31,0,0,0,11.4,14c1.6,0,2.68-.6,2.68-1.8,0-.92-.64-1.48-1.92-1.68l-1.6-.24c-1.8-.28-2.92-1.08-2.92-2.56,0-1.4,1.04-2.44,3-2.44a4.37,4.37,0,0,1,2.88.88l.88-1.72a5.73,5.73,0,0,0-3.8-1.28c-2.48,0-4.04.96-4.04,2.96,0,1.16.68,1.92,2,2.24l1.64.32c2.12.36,3.24,1.12,3.24,2.68,0,1.64-1.24,2.72-3.4,2.72a5.25,5.25,0,0,1-3.48-1.2Z";
- // Replaced with accurate path data from SVG file
- const APPLE_LOGO_PATH_SIMPLE = "M788.1 340.9c-5.8 4.5-108.2 62.2-108.2 190.5 0 148.4 130.3 200.9 134.2 202.2-.6 3.2-20.7 71.9-68.7 141.9-42.8 61.6-87.5 123.1-155.5 123.1s-85.5-39.5-164-39.5c-76.5 0-103.7 40.8-165.9 40.8s-105.6-57-155.5-127C46.7 790.7 0 663 0 541.8c0-194.4 126.4-297.5 250.8-297.5 66.1 0 121.2 43.4 162.7 43.4 39.5 0 101.1-46 176.3-46 28.5 0 130.9 2.6 198.3 99.2zm-234-181.5c31.1-36.9 53.1-88.1 53.1-139.3 0-7.1-.6-14.3-1.9-20.1-50.6 1.9-110.8 33.7-147.1 75.8-28.5 32.4-55.1 83.6-55.1 135.5 0 7.8 1.3 15.6 1.9 18.1 3.2.6 8.4 1.3 13.6 1.3 45.4 0 102.5-30.4 135.5-71.3z";
-
- // Native dimensions of the logo path approx
- const LOGO_NATIVE_WIDTH = 814;
- const LOGO_NATIVE_HEIGHT = 1000;
-
- // Helper function to describe an SVG arc (pie slice)
- function describePieSlice(cx, cy, radius, startAngleDeg, endAngleDeg) {
- const startAngleRad = (startAngleDeg - 90) * Math.PI / 180.0; // Subtract 90 to start from top
- const endAngleRad = (endAngleDeg - 90) * Math.PI / 180.0;
- const largeArcFlag = endAngleDeg - startAngleDeg <= 180 ? "0" : "1";
-
- const startX = cx + radius * Math.cos(startAngleRad);
- const startY = cy + radius * Math.sin(startAngleRad);
- const endX = cx + radius * Math.cos(endAngleRad);
- const endY = cy + radius * Math.sin(endAngleRad);
-
- // For a full circle, a single arc won't work perfectly due to start/end points being the same.
- // So, if it's very close to a full circle, we make it a tiny bit less to ensure it renders.
- if (Math.abs(endAngleDeg - startAngleDeg) >= 359.99) {
- // Draw two semi-circles instead for a full circle effect if needed,
- // or simply use a circle element. For now, let's cap it.
- // This pie slice is for partial fill, so full isn't the primary goal here.
- }
-
-
- const d = [
- "M", cx, cy, // Move to center
- "L", startX, startY, // Line to start of arc
- "A", radius, radius, 0, largeArcFlag, 1, endX, endY, // Arc
- "Z" // Close path (back to center)
- ].join(" ");
-
- return d;
- }
-
- // Helper function to determine GPU bar color based on temperature (continuous gradient)
- function getGpuBarColor(temp) {
- if (isNaN(temp) || temp === null) return 'var(--exo-light-gray)'; // Default for N/A temp
-
- const coolTemp = 45; // Temp for pure blue (Changed from 30)
- const midTemp = 57.5; // Temp for pure yellow (approx mid of 40-65)
- const hotTemp = 75; // Temp for pure red
-
- const coolColor = { r: 93, g: 173, b: 226 }; // #5DADE2 (Blue)
- const midColor = { r: 255, g: 215, b: 0 }; // var(--exo-yellow) #FFD700
- const hotColor = { r: 244, g: 67, b: 54 }; // #F44336 (Red)
-
- let r, g, b;
-
- if (temp <= coolTemp) {
- ({ r, g, b } = coolColor);
- } else if (temp > coolTemp && temp <= midTemp) {
- const ratio = (temp - coolTemp) / (midTemp - coolTemp);
- r = Math.round(coolColor.r * (1 - ratio) + midColor.r * ratio);
- g = Math.round(coolColor.g * (1 - ratio) + midColor.g * ratio);
- b = Math.round(coolColor.b * (1 - ratio) + midColor.b * ratio);
- } else if (temp > midTemp && temp < hotTemp) {
- const ratio = (temp - midTemp) / (hotTemp - midTemp);
- r = Math.round(midColor.r * (1 - ratio) + hotColor.r * ratio);
- g = Math.round(midColor.g * (1 - ratio) + hotColor.g * ratio);
- b = Math.round(midColor.b * (1 - ratio) + hotColor.b * ratio);
- } else { // temp >= hotTemp
- ({ r, g, b } = hotColor);
- }
-
- return `rgb(${r}, ${g}, ${b})`;
- }
-
- function formatBytes(bytes, decimals = 2) {
- if (!bytes || bytes === 0) return '0 Bytes';
- const k = 1024;
- const dm = decimals < 0 ? 0 : decimals;
- const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB'];
- const i = Math.floor(Math.log(bytes) / Math.log(k));
- return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
- }
-
- function timeSince(timestampSeconds) {
- const now = Date.now() / 1000;
- const secondsPast = Math.floor(now - timestampSeconds);
-
- if (secondsPast < 5) {
- return 'just now';
- }
- if (secondsPast < 60) {
- return `${secondsPast}s ago`;
- }
- if (secondsPast < 3600) {
- return `${Math.floor(secondsPast / 60)}m ago`;
- }
- if (secondsPast <= 86400) {
- return `${Math.floor(secondsPast / 3600)}h ago`;
- }
- const days = Math.floor(secondsPast / 86400);
- return days + (days === 1 ? ' day ago' : ' days ago');
- }
-
- // --- Download formatting helpers ---
- function bytesFromValue(value) {
- if (typeof value === 'number') return value;
- if (!value || typeof value !== 'object') return 0;
- if (typeof value.in_bytes === 'number') return value.in_bytes;
- if (typeof value.inBytes === 'number') return value.inBytes;
- return 0;
- }
-
- function formatDurationMs(ms) {
- if (ms == null || isNaN(ms) || ms < 0) return '—';
- const totalSeconds = Math.round(ms / 1000);
- const s = totalSeconds % 60;
- const m = Math.floor(totalSeconds / 60) % 60;
- const h = Math.floor(totalSeconds / 3600);
- if (h > 0) return `${h}h ${m}m ${s}s`;
- if (m > 0) return `${m}m ${s}s`;
- return `${s}s`;
- }
-
- function formatPercent(value, digits = 2) {
- if (value == null || isNaN(value)) return '0.00%';
- return `${value.toFixed(digits)}%`;
- }
-
- function formatBytesPerSecond(bps) {
- if (bps == null || isNaN(bps) || bps < 0) return '0 B/s';
- return `${formatBytes(bps)}/s`;
- }
-
- // Sidebar toggle functionality
- let sidebarOpen = false;
-
- function toggleSidebar() {
- sidebarOpen = !sidebarOpen;
- instancesSidebar.classList.toggle('open', sidebarOpen);
- instancesMenuButton.classList.toggle('active', sidebarOpen);
- }
-
- // Edge IP display flag (can be toggled from console)
- window.exoShowEdgeIPs = false;
-
- // Debug flag for download tracking (can be toggled from console)
- window.exoDebugDownloads = false;
-
- // Helper function to toggle IP display (accessible from console)
- window.toggleEdgeIPs = function() {
- window.exoShowEdgeIPs = !window.exoShowEdgeIPs;
- console.log(`Edge IP display ${window.exoShowEdgeIPs ? 'enabled' : 'disabled'}`);
- return window.exoShowEdgeIPs;
- };
-
- // Helper function to toggle download debugging (accessible from console)
- window.toggleDownloadDebug = function() {
- window.exoDebugDownloads = !window.exoDebugDownloads;
- console.log(`Download debugging ${window.exoDebugDownloads ? 'enabled' : 'disabled'}`);
- return window.exoDebugDownloads;
- };
-
- // Fetch available models and populate dropdown
- async function fetchAndPopulateModels() {
- try {
- const response = await fetch(window.location.origin + '/models');
- if (!response.ok) {
- throw new Error(`Failed to fetch models: ${response.status}`);
- }
- const data = await response.json();
-
- // Clear existing options
- modelSelect.innerHTML = '';
-
- if (data.data && data.data.length > 0) {
- // Add default option
- const defaultOption = document.createElement('option');
- defaultOption.value = '';
- defaultOption.textContent = 'Select a model...';
- modelSelect.appendChild(defaultOption);
-
- // Add models
- data.data.forEach(model => {
- const option = document.createElement('option');
- option.value = model.id;
- option.textContent = model.name || model.id;
- modelSelect.appendChild(option);
- });
-
- launchInstanceButton.disabled = false;
- } else {
- const noModelsOption = document.createElement('option');
- noModelsOption.value = '';
- noModelsOption.textContent = 'No models available';
- modelSelect.appendChild(noModelsOption);
- }
- } catch (error) {
- console.error('Error fetching models:', error);
- modelSelect.innerHTML = '<option value="">Error loading models</option>';
- }
- }
-
- // Show launch status message
- function showLaunchStatus(message, type) {
- launchStatus.textContent = message;
- launchStatus.className = `launch-status ${type}`;
-
- if (type !== 'loading') {
- setTimeout(() => {
- launchStatus.className = 'launch-status';
- }, 5000);
- }
- }
-
- // Launch instance
- async function launchInstance() {
- const selectedModelId = modelSelect.value;
- console.log("selectedModelId", selectedModelId);
- if (!selectedModelId) {
- showLaunchStatus('Please select a model', 'error');
- return;
- }
-
- const selectedSharding = document.querySelector('input[name="sharding"]:checked').value;
- const selectedInstanceMeta = document.querySelector('input[name="instance_meta"]:checked').value;
- const minNodesRadio = document.querySelector('input[name="min_nodes"]:checked');
- const minNodes = minNodesRadio ? parseInt(minNodesRadio.value, 10) : 1;
- console.log("selectedSharding", selectedSharding);
- console.log("selectedInstanceMeta", selectedInstanceMeta);
- console.log("minNodes", minNodes);
-
- try {
- showLaunchStatus('Launching instance...', 'loading');
- launchInstanceButton.disabled = true;
-
- const response = await fetch(window.location.origin + '/instance', {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify({
- model_id: selectedModelId,
- sharding: selectedSharding,
- instance_meta: selectedInstanceMeta,
- min_nodes: minNodes
- })
- });
-
- if (!response.ok) {
- const errorText = await response.text();
- throw new Error(`Failed to launch instance: ${response.status} - ${errorText}`);
- }
-
- const result = await response.json();
- showLaunchStatus('Instance launched successfully');
-
- // Reset form
- modelSelect.value = '';
-
- // Refresh instances list
- fetchAndRenderInstances();
-
- } catch (error) {
- console.error('Error launching instance:', error);
- showLaunchStatus(`Error: ${error.message}`, 'error');
- } finally {
- launchInstanceButton.disabled = false;
- }
- }
-
- // Fetch instances data and render
- async function fetchAndRenderInstances() {
- try {
- const response = await fetch(API_ENDPOINT);
- if (!response.ok) {
- throw new Error(`Failed to fetch state: ${response.status}`);
- }
- const data = await response.json();
-
- if (window.exoDebugDownloads && data.downloads) {
- console.log('[Download Debug] State downloads:', data.downloads);
- console.log('[Download Debug] Number of nodes with downloads:', Object.keys(data.downloads).length);
- }
-
- renderInstances(data.instances || {}, data.runners || {}, data.downloads || {});
- } catch (error) {
- console.error('Error fetching instances:', error);
- instancesList.innerHTML = '<div class="no-instances">Error loading instances</div>';
- }
- }
-
- // Calculate download status for an instance based on the new downloads structure
- function calculateInstanceDownloadStatus(instanceWrapped, runners, downloads) {
- // Unwrap tagged Instance union (MlxRingInstance or MlxIbvInstance)
- const [_instanceTag, instance] = getTagged(instanceWrapped);
- if (!instance || typeof instance !== 'object') {
- return { isDownloading: false, progress: 0, details: [] };
- }
-
- if (!instance.shardAssignments?.runnerToShard) {
- return { isDownloading: false, progress: 0, details: [] };
- }
-
- if (!downloads || Object.keys(downloads).length === 0) {
- return { isDownloading: false, progress: 0, details: [] };
- }
-
- const pick = (obj, snake, camel, fallback = undefined) => {
- if (!obj) return fallback;
- if (obj[snake] !== undefined) return obj[snake];
- if (obj[camel] !== undefined) return obj[camel];
- return fallback;
- };
-
- function normalizeProgress(progressRaw) {
- if (!progressRaw) return null;
- const totalBytes = bytesFromValue(pick(progressRaw, 'total_bytes', 'totalBytes', 0));
- const downloadedBytes = bytesFromValue(pick(progressRaw, 'downloaded_bytes', 'downloadedBytes', 0));
- const downloadedBytesThisSession = bytesFromValue(pick(progressRaw, 'downloaded_bytes_this_session', 'downloadedBytesThisSession', 0));
- const completedFiles = Number(pick(progressRaw, 'completed_files', 'completedFiles', 0)) || 0;
- const totalFiles = Number(pick(progressRaw, 'total_files', 'totalFiles', 0)) || 0;
- const speed = Number(pick(progressRaw, 'speed', 'speed', 0)) || 0;
- const etaMs = Number(pick(progressRaw, 'eta_ms', 'etaMs', 0)) || 0;
- const filesObj = pick(progressRaw, 'files', 'files', {}) || {};
- const files = [];
- Object.keys(filesObj).forEach(name => {
- const f = filesObj[name];
- if (!f || typeof f !== 'object') return;
- const fTotal = bytesFromValue(pick(f, 'total_bytes', 'totalBytes', 0));
- const fDownloaded = bytesFromValue(pick(f, 'downloaded_bytes', 'downloadedBytes', 0));
- const fSpeed = Number(pick(f, 'speed', 'speed', 0)) || 0;
- const fEta = Number(pick(f, 'eta_ms', 'etaMs', 0)) || 0;
- const fPct = fTotal > 0 ? (fDownloaded / fTotal) * 100 : 0;
- files.push({ name, totalBytes: fTotal, downloadedBytes: fDownloaded, speed: fSpeed, etaMs: fEta, percentage: fPct });
- });
- const percentage = totalBytes > 0 ? (downloadedBytes / totalBytes) * 100 : 0;
- return { totalBytes, downloadedBytes, downloadedBytesThisSession, completedFiles, totalFiles, speed, etaMs, files, percentage };
- }
-
- // Build reverse mapping from runnerId to nodeId
- const nodeToRunner = instance.shardAssignments.nodeToRunner || {};
- const runnerToNode = {};
- Object.entries(nodeToRunner).forEach(([nodeId, runnerId]) => {
- runnerToNode[runnerId] = nodeId;
- });
-
- const runnerToShard = instance.shardAssignments.runnerToShard || {};
- const runnerIds = Object.keys(runnerToShard);
- const details = [];
- let totalBytes = 0;
- let downloadedBytes = 0;
-
- if (window.exoDebugDownloads) {
- console.log('[Download Debug] Checking downloads for instance:', {
- runnerIds,
- availableDownloads: Object.keys(downloads),
- nodeToRunner
- });
- }
-
- for (const runnerId of runnerIds) {
- const nodeId = runnerToNode[runnerId];
- if (!nodeId) {
- if (window.exoDebugDownloads) console.log('[Download Debug] No nodeId for runner:', runnerId);
- continue;
- }
-
- const nodeDownloads = downloads[nodeId];
- if (!nodeDownloads || !Array.isArray(nodeDownloads)) {
- if (window.exoDebugDownloads) console.log('[Download Debug] No downloads for node:', nodeId);
- continue;
- }
-
- if (window.exoDebugDownloads) {
- console.log('[Download Debug] Found downloads for node:', nodeId, nodeDownloads);
- }
-
- // Get the shard metadata for this runner to match against downloads
- const shardWrapped = runnerToShard[runnerId];
- if (!shardWrapped) continue;
-
- // Extract the shard metadata from the wrapped shard
- const [_shardTag, shardMetadata] = getTagged(shardWrapped);
- if (!shardMetadata) continue;
-
- // Find matching download entry for this shard
- for (const downloadWrapped of nodeDownloads) {
- const [downloadKind, downloadPayload] = getTagged(downloadWrapped);
-
- if (window.exoDebugDownloads) {
- console.log('[Download Debug] Processing download:', { downloadKind, downloadPayload });
- }
-
- // Check for any ongoing download
- if (downloadKind !== 'DownloadOngoing') {
- if (window.exoDebugDownloads) console.log('[Download Debug] Skipping non-ongoing download:', downloadKind);
- continue;
- }
-
- // Match by shard metadata - compare the actual shard metadata objects
- const downloadShardMetadata = pick(downloadPayload, 'shard_metadata', 'shardMetadata', null);
- if (!downloadShardMetadata) {
- if (window.exoDebugDownloads) console.log('[Download Debug] No shard metadata in download');
- continue;
- }
-
- // Extract the actual shard data from tagged union if needed
- let actualDownloadShard = downloadShardMetadata;
- if (typeof downloadShardMetadata === 'object') {
- const [_downloadShardTag, downloadShardData] = getTagged(downloadShardMetadata);
- if (downloadShardData) {
- actualDownloadShard = downloadShardData;
- }
- }
-
- // Get modelId from modelMeta (nested structure: shard.modelMeta.modelId)
- const downloadModelMeta = pick(actualDownloadShard, 'model_meta', 'modelMeta', null);
- const shardModelMeta = pick(shardMetadata, 'model_meta', 'modelMeta', null);
- const downloadModelId = downloadModelMeta ? pick(downloadModelMeta, 'model_id', 'modelId', null) : null;
- const shardModelId = shardModelMeta ? pick(shardModelMeta, 'model_id', 'modelId', null) : null;
-
- if (window.exoDebugDownloads) {
- console.log('[Download Debug] Comparing models:', {
- downloadModelId,
- shardModelId,
- downloadModelMeta,
- shardModelMeta
- });
- }
-
- if (downloadModelId && shardModelId && downloadModelId === shardModelId) {
- const rawProg = pick(downloadPayload, 'download_progress', 'downloadProgress', null);
- const normalized = normalizeProgress(rawProg);
-
- if (normalized) {
- if (window.exoDebugDownloads) {
- console.log('[Download Debug] Found matching download progress:', normalized);
- }
- details.push({ runnerId, nodeId, progress: normalized });
- totalBytes += normalized.totalBytes || 0;
- downloadedBytes += normalized.downloadedBytes || 0;
- }
- break;
- }
- }
- }
-
- const isDownloadingAny = details.length > 0;
- const progress = totalBytes > 0 ? ((downloadedBytes / totalBytes) * 100) : 0;
- return { isDownloading: isDownloadingAny, progress, details };
- }
-
-
- // Helper function to unwrap tagged unions (defined globally for reuse)
- function getTagged(obj) {
- if (!obj || typeof obj !== 'object') return [null, null];
- const keys = Object.keys(obj);
- if (keys.length === 1 && typeof keys[0] === 'string') {
- return [keys[0], obj[keys[0]]];
- }
- return [null, null];
- }
-
- // Derive a display status for an instance from its runners.
- // Priority: FAILED > DOWNLOADING > LOADING > STARTING > RUNNING > READY > LOADED > WAITING > INACTIVE
- function deriveInstanceStatus(instanceWrapped, runners = {}, downloads = {}) {
- // Unwrap tagged Instance union
- const [_instanceTag, instance] = getTagged(instanceWrapped);
- if (!instance || typeof instance !== 'object') {
- return { statusText: 'UNKNOWN', statusClass: 'inactive' };
- }
-
- const runnerIds = Object.keys(instance.shardAssignments?.runnerToShard || {});
-
- function canonicalStatusFromKind(kind) {
- const map = {
- RunnerWaitingForModel: 'WaitingForModel',
- RunnerLoading: 'Loading',
- RunnerLoaded: 'Loaded',
- RunnerWarmingUp: 'WarmingUp',
- RunnerReady: 'Ready',
- RunnerRunning: 'Running',
- RunnerFailed: 'Failed',
- };
- return map[kind] || null;
- }
-
- const statuses = runnerIds
- .map(rid => {
- const r = runners[rid];
- if (!r) return null;
- const [kind] = getTagged(r);
- if (kind) return canonicalStatusFromKind(kind);
- const s = r.runnerStatus;
- return (typeof s === 'string') ? s : null;
- })
- .filter(s => typeof s === 'string');
-
- const has = (s) => statuses.includes(s);
-
- if (statuses.length === 0) {
- return { statusText: 'UNKNOWN', statusClass: 'inactive' };
- }
-
- if (has('Failed')) return { statusText: 'FAILED', statusClass: 'failed' };
- if (has('Loading')) return { statusText: 'LOADING', statusClass: 'starting' };
- if (has('WarmingUp')) return { statusText: 'WARMING UP', statusClass: 'starting' };
- if (has('Running')) return { statusText: 'RUNNING', statusClass: 'running' };
- if (has('Ready')) return { statusText: 'READY', statusClass: 'loaded' };
- if (has('Loaded')) return { statusText: 'LOADED', statusClass: 'loaded' };
- if (has('WaitingForModel')) return { statusText: 'WAITING FOR MODEL', statusClass: 'starting' };
-
- return { statusText: 'UNKNOWN', statusClass: 'inactive' };
- }
-
- function renderInstances(instances, runners = {}, downloads = {}) {
- const instanceEntries = Object.entries(instances || {});
-
- if (instanceEntries.length === 0) {
- instancesList.innerHTML = '<div class="no-instances">No instances running</div>';
- return;
- }
-
- // Build maps for instance colors and connection usage
- instanceIdToColor = {};
- connectionToInstances = {};
-
- instanceEntries.forEach(([instanceId, instanceWrapped]) => {
- // Validate instanceId
- if (!instanceId || typeof instanceId !== 'string') {
- return;
- }
-
- // Unwrap tagged Instance union
- const [_instanceTag, instance] = getTagged(instanceWrapped);
- if (!instance || typeof instance !== 'object') {
- return;
- }
- instanceIdToColor[instanceId] = generateInstanceColor(instanceId);
-
- // Determine which nodes this instance uses
- const nodeToRunner = instance.shardAssignments?.nodeToRunner || {};
- const nodesUsed = Object.keys(nodeToRunner);
-
- // For each pair of nodes, record that this instance uses that connection
- for (let i = 0; i < nodesUsed.length; i++) {
- for (let j = i + 1; j < nodesUsed.length; j++) {
- const nodeA = nodesUsed[i];
- const nodeB = nodesUsed[j];
- const key = nodeA < nodeB ? `${nodeA}|${nodeB}` : `${nodeB}|${nodeA}`;
-
- if (!connectionToInstances[key]) {
- connectionToInstances[key] = [];
- }
- connectionToInstances[key].push(instanceId);
- }
- }
- });
-
- const instancesHTML = instanceEntries.map(([instanceId, instanceWrapped]) => {
- // Validate instanceId
- if (!instanceId || typeof instanceId !== 'string') {
- return '';
- }
-
- // Unwrap tagged Instance union
- const [_instanceTag, instance] = getTagged(instanceWrapped);
- if (!instance || typeof instance !== 'object') {
- return '';
- }
-
- const modelId = instance.shardAssignments?.modelId || 'Unknown Model';
- const truncatedInstanceId = instanceId.length > 8
- ? instanceId.substring(0, 8) + '...'
- : instanceId;
-
- // Create reverse mapping from runnerId to nodeId using nodeToRunner
- const nodeToRunner = instance.shardAssignments?.nodeToRunner || {};
- const runnerToNode = {};
- Object.entries(nodeToRunner).forEach(([nodeId, runnerId]) => {
- runnerToNode[runnerId] = nodeId;
- });
-
- // Extract sharding strategy from the first shard
- // Shards are tagged unions: {"PipelineShardMetadata": {...}} or {"TensorShardMetadata": {...}}
- const runnerToShard = instance.shardAssignments?.runnerToShard || {};
- const firstShardWrapped = Object.values(runnerToShard)[0];
- let shardingType = 'Unknown';
- if (firstShardWrapped) {
- const [shardTag, _shardData] = getTagged(firstShardWrapped);
- if (shardTag === 'PipelineShardMetadata') {
- shardingType = 'Pipeline';
- } else if (shardTag === 'TensorShardMetadata') {
- shardingType = 'Tensor';
- }
- }
-
- // Extract instance type from the tagged union
- // Instance is tagged as {"MlxRingInstance": {...}} or {"MlxIbvInstance": {...}}
- let instanceType = 'Unknown';
- if (_instanceTag === 'MlxRingInstance') {
- instanceType = 'MLX Ring';
- } else if (_instanceTag === 'MlxIbvInstance') {
- instanceType = 'MLX IBV';
- }
-
- const parallelizationStrategy = `${shardingType} (${instanceType})`;
-
- // Generate hosts HTML using runner IDs and friendly names
- const runnerIds = Object.keys(runnerToShard);
- const hostsHTML = runnerIds.map(runnerId => {
- const nodeId = runnerToNode[runnerId];
- const friendlyName = nodeId && nodeIdToFriendlyName[nodeId]
- ? nodeIdToFriendlyName[nodeId]
- : 'Unknown Node';
- const shortId = runnerId.slice(-4);
- return `<span class="instance-host">${friendlyName} (${shortId})</span>`;
- }).join('') || '';
-
- // Calculate download status for this instance (pass wrapped instance)
- const downloadStatus = calculateInstanceDownloadStatus(instanceWrapped, runners, downloads);
-
- let statusText, statusClass;
- if (downloadStatus.isDownloading) {
- ({ statusText, statusClass } = { statusText: 'DOWNLOADING', statusClass: 'downloading' });
- } else {
- ({ statusText, statusClass } = deriveInstanceStatus(instanceWrapped, runners, downloads));
- }
-
- // Generate download progress HTML - overall + per node with file details
- let downloadProgressHTML = '';
- if (downloadStatus.isDownloading) {
- // Calculate overall progress across all nodes
- const overallPct = (downloadStatus.progress || 0).toFixed(2);
- const totalBytesAll = downloadStatus.details.reduce((sum, d) => sum + (d.progress.totalBytes || 0), 0);
- const downloadedBytesAll = downloadStatus.details.reduce((sum, d) => sum + (d.progress.downloadedBytes || 0), 0);
- const nodeCount = downloadStatus.details.length;
-
- // Overall progress section
- const overallHTML = `
- <div class="overall-download-summary">
- <div class="overall-download-header">
- <span class="overall-download-label">Overall</span>
- <span class="overall-download-percent">${overallPct}%</span>
- </div>
- <div class="progress-bar-container">
- <div class="progress-bar" style="width: ${overallPct}%;"></div>
- </div>
- <div class="overall-download-stats">${formatBytes(downloadedBytesAll)} / ${formatBytes(totalBytesAll)} • ${nodeCount} runner${nodeCount !== 1 ? 's' : ''}</div>
- </div>
- `;
-
- const perNodeHTML = (downloadStatus.details || []).map(({ runnerId, nodeId, progress }) => {
- const nodeName = (nodeId && nodeIdToFriendlyName[nodeId])
- ? nodeIdToFriendlyName[nodeId]
- : (nodeIdToFriendlyName[runnerId] || 'Unknown Node');
- const pctText = (progress.percentage || 0).toFixed(2);
- const etaStr = formatDurationMs(progress.etaMs);
- const bytesStr = `${formatBytes(progress.downloadedBytes)} / ${formatBytes(progress.totalBytes)}`;
- const speedStr = formatBytesPerSecond(progress.speed);
- const filesSummary = `${progress.completedFiles}/${progress.totalFiles} files`;
-
- // Separate files into in-progress and completed
- const allFiles = progress.files || [];
- const inProgressFiles = allFiles.filter(f => (f.percentage || 0) < 100);
- const completedFiles = allFiles.filter(f => (f.percentage || 0) >= 100);
-
- // Generate HTML for in-progress files
- const inProgressHTML = inProgressFiles.map(f => {
- const fPct = f.percentage || 0;
- const fBytes = `${formatBytes(f.downloadedBytes)} / ${formatBytes(f.totalBytes)}`;
- const fEta = formatDurationMs(f.etaMs);
- const fSpeed = formatBytesPerSecond(f.speed);
- const pctFormatted = formatPercent(fPct, 2);
- return `
- <div class="download-file">
- <div class="download-file-header">
- <span class="download-file-name" title="${f.name}">${f.name}</span>
- <span class="download-file-percent">${pctFormatted}</span>
- </div>
- <div class="download-file-subtext">${fBytes} • ETA ${fEta} • ${fSpeed}</div>
- <div class="progress-bar-container"><div class="progress-bar" style="width: ${Math.max(0, Math.min(100, fPct)).toFixed(2)}%;"></div></div>
- </div>
- `;
- }).join('');
-
- // Generate HTML for completed files
- const completedHTML = completedFiles.length > 0 ? `
- <div class="completed-files-section">
- <div class="completed-files-header">Completed (${completedFiles.length})</div>
- <div class="completed-files-list">
- ${completedFiles.map(f => `<div class="completed-file-item" title="${f.name}">${f.name}</div>`).join('')}
- </div>
- </div>
- ` : '';
-
- return `
- <div class="node-download-summary">
- <div class="node-download-header">
- <span class="node-download-name">${nodeName}</span>
- <span class="node-download-percent">${pctText}%</span>
- </div>
- <div class="progress-bar-container">
- <div class="progress-bar" style="width: ${pctText}%;"></div>
- </div>
- <div class="node-download-stats">${etaStr} · ${bytesStr} · ${speedStr} · ${filesSummary}</div>
- <div class="download-files-list">
- ${inProgressHTML}
- </div>
- ${completedHTML}
- </div>
- `;
- }).join('');
-
- downloadProgressHTML = overallHTML + perNodeHTML;
- }
-
- const shardCount = Object.keys(runnerToShard).length;
-
- // Use the instance's color for the indicator
- const instanceColor = instanceIdToColor[instanceId] || 'var(--exo-yellow)';
- const borderStyle = `background-color: ${instanceColor};`;
-
- return `
- <div class="instance-item">
- <div class="instance-color-indicator" style="${borderStyle}"></div>
- <div class="instance-header">
- <div class="instance-info">
- <span class="instance-id">${truncatedInstanceId}</span>
- <span class="instance-status ${statusClass}">${statusText}</span>
- </div>
- <div class="instance-actions">
- <button class="instance-delete-button" data-instance-id="${instanceId}" title="Delete Instance">
- Delete
- </button>
- </div>
- </div>
- <div class="instance-model">${modelId} <span style="color: var(--exo-light-gray); opacity: 0.8;">(${shardCount} runner${shardCount !== 1 ? 's' : ''})</span></div>
- <div class="instance-strategy">Strategy: <span class="instance-strategy-value">${parallelizationStrategy}</span></div>
-
- ${downloadProgressHTML}
- ${hostsHTML ? `<div class="instance-hosts">${hostsHTML}</div>` : ''}
- </div>
- `;
- }).join('');
-
- instancesList.innerHTML = instancesHTML;
-
- // Add event listeners to delete buttons using event delegation
- instancesList.removeEventListener('click', handleInstanceListClick);
- instancesList.addEventListener('click', handleInstanceListClick);
- }
-
- // Handle clicks on the instances list (event delegation)
- function handleInstanceListClick(event) {
- if (event.target.classList.contains('instance-delete-button')) {
- const instanceId = event.target.getAttribute('data-instance-id');
- if (instanceId) {
- deleteInstance(instanceId);
- }
- }
- }
-
- // Delete instance with confirmation
- async function deleteInstance(instanceId) {
- const confirmMessage = `Are you sure you want to delete instance ${instanceId}?\n\nThis action cannot be undone.`;
-
- if (!confirm(confirmMessage)) {
- return;
- }
-
- try {
- const response = await fetch(`${window.location.origin}/instance/${instanceId}`, {
- method: 'DELETE',
- headers: {
- 'Content-Type': 'application/json',
- },
- });
-
- if (!response.ok) {
- const errorData = await response.json().catch(() => ({}));
- throw new Error(errorData.detail || `Failed to delete instance: ${response.status}`);
- }
-
- const result = await response.json();
- console.log('Instance deletion initiated:', result);
-
- // Refresh instances list immediately to show the change
- fetchAndRenderInstances();
-
- } catch (error) {
- console.error('Error deleting instance:', error);
- alert(`Error deleting instance: ${error.message}`);
- }
- }
-
- // Helper function to create edge labels with optional colored indicators for instances
- function createEdgeLabel(labelLines, labelX, labelY, parentGroup, instanceColors = []) {
- if (!labelLines || labelLines.length === 0) return;
-
- const colorStripWidth = 3; // Narrow strip width
- const colorStripHeight = 12; // Taller for visibility
- const colorStripSpacing = 1.5; // Small gap between strips
- const paddingBetweenStripsAndText = 8; // Space between strips and text
- const hasColorBoxes = instanceColors.length > 0;
-
- // Create color indicator strips if colors are provided
- let totalColorBoxWidth = 0;
- if (hasColorBoxes) {
- totalColorBoxWidth = instanceColors.length * (colorStripWidth + colorStripSpacing) - colorStripSpacing;
- const stripsStartX = labelX - totalColorBoxWidth - paddingBetweenStripsAndText - 30; // Move 30px further left
-
- instanceColors.forEach((color, idx) => {
- const colorStrip = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
- // Position strips well to the left of the text
- const stripX = stripsStartX + idx * (colorStripWidth + colorStripSpacing);
- colorStrip.setAttribute('x', stripX);
- colorStrip.setAttribute('y', labelY - colorStripHeight / 2);
- colorStrip.setAttribute('width', colorStripWidth);
- colorStrip.setAttribute('height', colorStripHeight);
- colorStrip.setAttribute('fill', color);
- colorStrip.setAttribute('stroke', 'var(--exo-light-gray)');
- colorStrip.setAttribute('stroke-width', '0.5');
- colorStrip.setAttribute('rx', 1);
- parentGroup.appendChild(colorStrip);
- });
- }
-
- // Create text element
- const labelText = document.createElementNS('http://www.w3.org/2000/svg', 'text');
- labelText.setAttribute('class', 'edge-label');
- labelText.setAttribute('x', labelX);
- labelText.setAttribute('y', labelY);
-
- // Add background for better readability
- const labelBg = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
- labelBg.setAttribute('class', 'edge-label-bg');
-
- // Add each line as a tspan
- labelLines.forEach((line, idx) => {
- const tspan = document.createElementNS('http://www.w3.org/2000/svg', 'tspan');
- tspan.setAttribute('x', labelX);
- tspan.setAttribute('dy', idx === 0 ? '0' : '1.1em');
- tspan.textContent = line;
- labelText.appendChild(tspan);
- });
-
- // Add text first to get bounding box, then add background
- parentGroup.appendChild(labelText);
-
- // Get text bounding box and create background rect
- try {
- const bbox = labelText.getBBox();
- const padding = 3;
- const extraLeft = hasColorBoxes ? totalColorBoxWidth : 0;
-
- // Background should cover text area only, strips are separate
- labelBg.setAttribute('x', bbox.x - padding);
- labelBg.setAttribute('y', bbox.y - padding);
- labelBg.setAttribute('width', bbox.width + 2 * padding);
- labelBg.setAttribute('height', bbox.height + 2 * padding);
- labelBg.setAttribute('rx', 2);
- parentGroup.insertBefore(labelBg, labelText);
- } catch (e) {
- console.error('Failed to get bbox for label:', e);
- }
- }
-
- 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);
-
- // Update min nodes radio buttons based on current topology
- currentNodeCount = Math.max(1, nodeIds.length);
- if (minNodesOptions) {
- // Get currently selected value before regenerating
- const currentlySelected = document.querySelector('input[name="min_nodes"]:checked');
- const hasOnlyDefaultOption = minNodesOptions.children.length === 1;
- // Default to maximum nodes on initial load, otherwise preserve user selection
- const selectedValue = (currentlySelected && !hasOnlyDefaultOption) ? parseInt(currentlySelected.value, 10) : currentNodeCount;
-
- // Clear and regenerate radio buttons
- minNodesOptions.innerHTML = '';
- for (let i = 1; i <= currentNodeCount; i++) {
- const optionDiv = document.createElement('div');
- optionDiv.className = 'strategy-option';
-
- const radio = document.createElement('input');
- radio.type = 'radio';
- radio.id = `minNodes${i}`;
- radio.name = 'min_nodes';
- radio.value = i.toString();
- // Check if this should be selected (preserve selection or default to maximum)
- radio.checked = (i === Math.min(selectedValue, currentNodeCount));
-
- const label = document.createElement('label');
- label.htmlFor = `minNodes${i}`;
- label.textContent = i.toString();
-
- optionDiv.appendChild(radio);
- optionDiv.appendChild(label);
- minNodesOptions.appendChild(optionDiv);
- }
- }
-
- if (nodeIds.length === 0) {
- const textEl = document.createElementNS('http://www.w3.org/2000/svg', 'text');
- textEl.setAttribute('x', '50%');
- textEl.setAttribute('y', '50%');
- textEl.setAttribute('alignment-baseline', 'middle');
- textEl.setAttribute('text-anchor', 'middle');
- textEl.setAttribute('fill', 'var(--exo-light-gray)');
- textEl.setAttribute('font-size', '16');
- textEl.textContent = 'No nodes discovered yet.';
- topologyGraphContainer.appendChild(textEl);
- return;
- }
-
- const svgRect = topologyGraphContainer.getBoundingClientRect();
- const width = svgRect.width;
- const height = svgRect.height;
- const centerX = width / 2;
- const centerY = height / 2;
- const numNodes = nodeIds.length;
- const radius = Math.min(width, height) * 0.28; // Radius of the circle for node placement
- const nodeRadius = 72; // Base size for overall node element, influences icon/bar sizes
-
- const nodesWithPositions = nodeIds.map((id, index) => {
- const angle = (index / numNodes) * 2 * Math.PI - (Math.PI / 2); // Start from top
- return {
- id: id,
- data: nodesData[id],
- x: centerX + radius * Math.cos(angle),
- y: centerY + radius * Math.sin(angle)
- };
- });
-
- // 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', '11');
- marker.setAttribute('markerHeight', '11');
- marker.setAttribute('orient', 'auto-start-reverse');
- // Draw a subtle V-tip (no filled body)
- const markerTip = document.createElementNS('http://www.w3.org/2000/svg', 'path');
- markerTip.setAttribute('d', 'M 0 0 L 10 5 L 0 10');
- markerTip.setAttribute('fill', 'none');
- markerTip.setAttribute('stroke', 'var(--exo-light-gray)');
- markerTip.setAttribute('stroke-width', '1.6');
- markerTip.setAttribute('stroke-linecap', 'round');
- markerTip.setAttribute('stroke-linejoin', 'round');
- markerTip.setAttribute('stroke-dasharray', 'none');
- markerTip.setAttribute('stroke-dashoffset', '0');
- markerTip.setAttribute('style', 'animation: none; pointer-events: none;');
- marker.appendChild(markerTip);
- 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;');
- const edgeLabelsGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g');
- edgeLabelsGroup.setAttribute('class', 'edge-labels-group');
- edgeLabelsGroup.setAttribute('style', 'pointer-events: none;');
-
- // 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, aToBEdges, bToAEdges }
- 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, aToBEdges: [], bToAEdges: [] };
- if (edge.source === a && edge.target === b) {
- entry.aToB = true;
- entry.aToBEdges.push(edge); // Store all A->B edges
- } else {
- entry.bToA = true;
- entry.bToAEdges.push(edge); // Store all B->A edges
- }
- 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;
-
- // Full-length center-to-center lines
- const x1 = posA.x;
- const y1 = posA.y;
- const x2 = posB.x;
- const y2 = posB.y;
-
- // 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);
-
- // Arrowheads centered on the line (tip lies exactly on the line),
- // offset along the tangent so opposite directions straddle the center.
- const dx = x2 - x1;
- const dy = y2 - y1;
- const len = Math.hypot(dx, dy) || 1;
- const ux = dx / len;
- const uy = dy / len;
- const mx = (x1 + x2) / 2;
- const my = (y1 + y2) / 2;
- const tipOffset = 16; // shift arrow tips away from the exact center along the line
- const carrier = 2; // short carrier segment length to define orientation
-
- if (entry.aToB) {
- // Arrow pointing A -> B: place tip slightly before center along +tangent
- const tipX = mx - ux * tipOffset;
- const tipY = my - uy * tipOffset;
- const sx = tipX - ux * carrier;
- const sy = tipY - uy * carrier;
- const ex = tipX;
- const ey = tipY;
- const arrowSeg = document.createElementNS('http://www.w3.org/2000/svg', 'line');
- arrowSeg.setAttribute('x1', sx);
- arrowSeg.setAttribute('y1', sy);
- arrowSeg.setAttribute('x2', ex);
- arrowSeg.setAttribute('y2', ey);
- arrowSeg.setAttribute('stroke', 'none');
- arrowSeg.setAttribute('fill', 'none');
- arrowSeg.setAttribute('marker-end', 'url(#arrowhead)');
- arrowsGroup.appendChild(arrowSeg);
-
- // Add label for A->B direction (show all connections)
- if (window.exoShowEdgeIPs && entry.aToBEdges && entry.aToBEdges.length > 0) {
- // Count occurrences of each IP/interface combination
- const connectionCounts = new Map();
-
- entry.aToBEdges.forEach(edgeData => {
- if (edgeData.sendBackIp) {
- let ipLabel = edgeData.sendBackIp;
- if (edgeData.sendBackInterface) {
- ipLabel = `${edgeData.sendBackInterface}: ${ipLabel}`;
- }
- connectionCounts.set(ipLabel, (connectionCounts.get(ipLabel) || 0) + 1);
- }
- });
-
- // Build label lines with counts for duplicates
- const labelLines = [];
- connectionCounts.forEach((count, ipLabel) => {
- if (count > 1) {
- labelLines.push(`${ipLabel} (${count})`);
- } else {
- labelLines.push(ipLabel);
- }
- });
-
- if (labelLines.length > 0) {
- // Position label before the A->B arrow (toward A side, away from arrow tip)
- // Move further back from center along the line toward A
- const labelPosX = mx - ux * (tipOffset * 2.5);
- const labelPosY = my - uy * (tipOffset * 2.5);
- // Offset perpendicular to the line (to the side)
- const perpX = -uy;
- const perpY = ux;
- const labelOffset = 25; // Increased offset to be clearly beside the line
- const labelX = labelPosX + perpX * labelOffset;
- const labelY = labelPosY + perpY * labelOffset;
-
- // Get colors for instances using this connection
- const connectionKey = `${entry.a}|${entry.b}`;
- const instancesUsingConnection = connectionToInstances[connectionKey] || [];
- const instanceColors = instancesUsingConnection.map(id => instanceIdToColor[id]).filter(c => c);
-
- createEdgeLabel(labelLines, labelX, labelY, edgeLabelsGroup, instanceColors);
- }
- }
- }
-
- if (entry.bToA) {
- // Arrow pointing B -> A: place tip slightly after center along -tangent
- const tipX = mx + ux * tipOffset;
- const tipY = my + uy * tipOffset;
- const sx = tipX + ux * carrier; // start ahead so the segment points toward tip
- const sy = tipY + uy * carrier;
- const ex = tipX;
- const ey = tipY;
- const arrowSeg = document.createElementNS('http://www.w3.org/2000/svg', 'line');
- arrowSeg.setAttribute('x1', sx);
- arrowSeg.setAttribute('y1', sy);
- arrowSeg.setAttribute('x2', ex);
- arrowSeg.setAttribute('y2', ey);
- arrowSeg.setAttribute('stroke', 'none');
- arrowSeg.setAttribute('fill', 'none');
- arrowSeg.setAttribute('marker-end', 'url(#arrowhead)');
- arrowsGroup.appendChild(arrowSeg);
-
- // Add label for B->A direction (show all connections)
- if (window.exoShowEdgeIPs && entry.bToAEdges && entry.bToAEdges.length > 0) {
- // Count occurrences of each IP/interface combination
- const connectionCounts = new Map();
-
- entry.bToAEdges.forEach(edgeData => {
- if (edgeData.sendBackIp) {
- let ipLabel = edgeData.sendBackIp;
- if (edgeData.sendBackInterface) {
- ipLabel = `${edgeData.sendBackInterface}: ${ipLabel}`;
- }
- connectionCounts.set(ipLabel, (connectionCounts.get(ipLabel) || 0) + 1);
- }
- });
-
- // Build label lines with counts for duplicates
- const labelLines = [];
- connectionCounts.forEach((count, ipLabel) => {
- if (count > 1) {
- labelLines.push(`${ipLabel} (${count})`);
- } else {
- labelLines.push(ipLabel);
- }
- });
-
- if (labelLines.length > 0) {
- // Position label before the B->A arrow (toward B side, away from arrow tip)
- // Move further back from center along the line toward B
- const labelPosX = mx + ux * (tipOffset * 2.5);
- const labelPosY = my + uy * (tipOffset * 2.5);
- // Offset perpendicular to the line (to the side)
- const perpX = -uy;
- const perpY = ux;
- const labelOffset = 25; // Increased offset to be clearly beside the line
- const labelX = labelPosX + perpX * labelOffset;
- const labelY = labelPosY + perpY * labelOffset;
-
- // Get colors for instances using this connection
- const connectionKey = `${entry.a}|${entry.b}`;
- const instancesUsingConnection = connectionToInstances[connectionKey] || [];
- const instanceColors = instancesUsingConnection.map(id => instanceIdToColor[id]).filter(c => c);
-
- createEdgeLabel(labelLines, labelX, labelY, edgeLabelsGroup, instanceColors);
- }
- }
- }
- });
- // Create group for nodes
- const nodesGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g');
- nodesGroup.setAttribute('class', 'nodes-group');
-
- nodesWithPositions.forEach(nodeInfo => {
- const node = nodeInfo.data;
- const nodeId = nodeInfo.id;
-
- // --- Calculate additional metrics for display ---
- let ramUsagePercent = 0;
- let gpuTempDisplay = 'N/A';
- let ramUsedFormatted = '0 Bytes';
- let ramTotalFormatted = 'N/A';
- let gpuUsagePercent = 0;
- let gpuTempForColor = NaN;
- let sysPower = 'N/A';
-
- const macmon = node.macmon_info;
- if (macmon) {
- if (macmon.memory && macmon.memory.ram_total > 0) {
- ramUsagePercent = (macmon.memory.ram_usage / macmon.memory.ram_total) * 100;
- ramUsedFormatted = formatBytes(macmon.memory.ram_usage, 1);
- ramTotalFormatted = formatBytes(macmon.memory.ram_total, 1);
- }
- if (macmon.temp && typeof macmon.temp.gpu_temp_avg === 'number') {
- let originalGpuTemp = macmon.temp.gpu_temp_avg;
- let clampedGpuTemp = Math.max(30, originalGpuTemp);
- gpuTempDisplay = `${clampedGpuTemp.toFixed(0)}°C`;
- gpuTempForColor = clampedGpuTemp;
- } else {
- gpuTempDisplay = 'N/A';
- gpuTempForColor = NaN;
- }
- if (macmon.gpu_usage && typeof macmon.gpu_usage[1] === 'number') { // Calculate GPU Usage Percentage
- gpuUsagePercent = macmon.gpu_usage[1] * 100;
- }
- if (macmon.sys_power) sysPower = `${macmon.sys_power.toFixed(1)} W`;
- } else { // If no macmon_info, indicate data is missing for these
- ramUsagePercent = 0;
- gpuTempDisplay = 'N/A';
- ramUsedFormatted = 'N/A';
- ramTotalFormatted = 'N/A';
- gpuUsagePercent = 0;
- sysPower = 'N/A'; // Ensure sysPower is handled
- }
- // --- End Calculate additional metrics ---
-
- let fillCol = 'var(--exo-medium-gray)'; // Default status color for the circle
- const lastSeenSeconds = (Date.now() / 1000) - node.last_macmon_update;
- if (lastSeenSeconds > 60 * 60) { // Over 1 hour
- fillCol = '#F44336'; // Error - red
- } else if (lastSeenSeconds > 60 * 5) { // Over 5 minutes
- fillCol = 'var(--exo-yellow)'; // Warning - yellow
- } else if (!node.macmon_info) {
- fillCol = 'var(--exo-yellow)'; // Warning if no macmon_info but seen recently
- }
-
-
- const nodeG = document.createElementNS('http://www.w3.org/2000/svg', 'g');
- nodeG.setAttribute('class', 'graph-node');
-
- const modelId = node.system_info ? node.system_info.model_id : 'Unknown';
-
- // --- Icon Dimensions & Config ---
- let iconBaseWidth = nodeRadius * 1.2;
- let iconBaseHeight = nodeRadius * 1.0;
- let specificIconGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g');
- const clipPathId = `clipPath-${nodeId.replace(/[^a-zA-Z0-9]/g, '-')}`;
-
- // Add tooltip for node ID (remains on the group)
- const titleEl = document.createElementNS('http://www.w3.org/2000/svg', 'title');
- titleEl.textContent = `ID: ${nodeId}\nLast Seen: ${timeSince(node.last_macmon_update)}`;
- nodeG.appendChild(titleEl);
-
- // --- Device Specific Icon Drawing ---
- if (modelId.toLowerCase() === "mac studio") {
- iconBaseWidth = nodeRadius * 1.25; // Slightly wider based on typical Studio proportions
- iconBaseHeight = nodeRadius * 0.85; // And a bit flatter than a perfect cube
- const x = nodeInfo.x - iconBaseWidth / 2;
- const y = nodeInfo.y - iconBaseHeight / 2;
- const cornerRadius = 4;
- const topSurfaceHeight = iconBaseHeight * 0.15; // Height of the top bevel/surface
-
- // Define ClipPath for the main front body (where memory fill goes)
- let defs = nodeG.querySelector('defs');
- if (!defs) {
- defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
- nodeG.appendChild(defs);
- }
- const clipPath = document.createElementNS('http://www.w3.org/2000/svg', 'clipPath');
- clipPath.setAttribute('id', clipPathId);
- const clipRect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
- // Clip path matches the main front body, excluding the top surface part shown visually
- clipRect.setAttribute('x', x);
- clipRect.setAttribute('y', y + topSurfaceHeight);
- clipRect.setAttribute('width', iconBaseWidth);
- clipRect.setAttribute('height', iconBaseHeight - topSurfaceHeight);
- clipRect.setAttribute('rx', cornerRadius -1 > 0 ? cornerRadius -1 : 0); // Slightly less rounding for internal clip if needed
- clipPath.appendChild(clipRect);
- defs.appendChild(clipPath);
-
- // Top Surface (like the image)
- const topRect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
- topRect.setAttribute('x', x);
- topRect.setAttribute('y', y);
- topRect.setAttribute('width', iconBaseWidth);
- topRect.setAttribute('height', iconBaseHeight); // Full height, front will overlay
- topRect.setAttribute('rx', cornerRadius);
- topRect.setAttribute('ry', cornerRadius);
- topRect.style.fill = 'var(--exo-medium-gray)'; // Color of the top surface
- topRect.setAttribute('stroke', 'var(--exo-light-gray)');
- topRect.setAttribute('stroke-width', '1px');
- specificIconGroup.appendChild(topRect);
-
- // Main Front Body (this is where memory fill will be visible)
- const frontBody = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
- frontBody.setAttribute('x', x);
- frontBody.setAttribute('y', y + topSurfaceHeight);
- frontBody.setAttribute('width', iconBaseWidth);
- frontBody.setAttribute('height', iconBaseHeight - topSurfaceHeight);
- frontBody.style.fill = fillCol; // Node status color
- // No stroke for frontBody, as topRect provides the outline appearance
- specificIconGroup.appendChild(frontBody);
-
- // Memory Fill (clipped to frontBody area)
- if (ramUsagePercent > 0) {
- const memFillTotalHeight = iconBaseHeight - topSurfaceHeight;
- const memFillActualHeight = (ramUsagePercent / 100) * memFillTotalHeight;
- const memoryFillRect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
- memoryFillRect.setAttribute('x', x);
- memoryFillRect.setAttribute('y', y + topSurfaceHeight + (memFillTotalHeight - memFillActualHeight)); // From bottom up
- memoryFillRect.setAttribute('width', iconBaseWidth);
- memoryFillRect.setAttribute('height', memFillActualHeight);
- let memFillColor = 'var(--exo-yellow)';
- if (fillCol === 'var(--exo-yellow)') memFillColor = 'var(--exo-yellow-darker)';
- memoryFillRect.style.fill = memFillColor;
- memoryFillRect.style.opacity = '0.75';
- memoryFillRect.setAttribute('clip-path', `url(#${clipPathId})`);
- specificIconGroup.appendChild(memoryFillRect);
- }
-
- // Front Panel Details (slots, LED)
- const detailColor = 'rgba(0,0,0,0.3)'; // Darker color for cutouts
- const slotHeight = iconBaseHeight * 0.12;
- const slotCornerRadius = 1.5;
-
- // Vertical slots (2)
- const vSlotWidth = iconBaseWidth * 0.05;
- const vSlotY = y + topSurfaceHeight + (iconBaseHeight - topSurfaceHeight) * 0.65 - slotHeight / 2;
- const vSlot1X = x + iconBaseWidth * 0.15;
- const vSlot2X = x + iconBaseWidth * 0.25;
-
- [vSlot1X, vSlot2X].forEach(vx => {
- const vSlot = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
- vSlot.setAttribute('x', vx - vSlotWidth / 2);
- vSlot.setAttribute('y', vSlotY);
- vSlot.setAttribute('width', vSlotWidth);
- vSlot.setAttribute('height', slotHeight);
- vSlot.setAttribute('fill', detailColor);
- vSlot.setAttribute('rx', slotCornerRadius);
- specificIconGroup.appendChild(vSlot);
- });
-
- // Horizontal slot for Mac Studio - RESTORED
- const hSlotWidth = iconBaseWidth * 0.2;
- const hSlotX = x + iconBaseWidth * 0.5 - hSlotWidth / 2;
- const hSlotY = vSlotY; // Align with vertical slots for simplicity
- const hSlot = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
- hSlot.setAttribute('x', hSlotX);
- hSlot.setAttribute('y', hSlotY);
- hSlot.setAttribute('width', hSlotWidth);
- hSlot.setAttribute('height', slotHeight * 0.6); // Thinner horizontal slot
- hSlot.setAttribute('fill', detailColor);
- hSlot.setAttribute('rx', slotCornerRadius * 0.7);
- specificIconGroup.appendChild(hSlot);
-
- // LED indicator
- const ledRadius = iconBaseWidth * 0.025;
- const ledX = x + iconBaseWidth * 0.85;
- const ledY = y + topSurfaceHeight + (iconBaseHeight - topSurfaceHeight) * 0.7;
- const led = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
- led.setAttribute('cx', ledX);
- led.setAttribute('cy', ledY);
- led.setAttribute('r', ledRadius);
- led.setAttribute('fill', 'var(--exo-light-gray)'); // Subtle LED color
- specificIconGroup.appendChild(led);
-
- } else if (modelId.toLowerCase() === "mac mini") {
- iconBaseWidth = nodeRadius * 1.3; // Mini is wide
- iconBaseHeight = nodeRadius * 0.7; // and quite flat
- const x = nodeInfo.x - iconBaseWidth / 2;
- const y = nodeInfo.y - iconBaseHeight / 2;
- const cornerRadius = 3;
- const topSurfaceHeight = iconBaseHeight * 0.20; // Proportionally similar top surface
-
- // Define ClipPath for the main front body
- let defs = nodeG.querySelector('defs');
- if (!defs) {
- defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
- nodeG.appendChild(defs);
- }
- const clipPath = document.createElementNS('http://www.w3.org/2000/svg', 'clipPath');
- clipPath.setAttribute('id', clipPathId); // Use the same clipPathId logic
- const clipRect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
- clipRect.setAttribute('x', x);
- clipRect.setAttribute('y', y + topSurfaceHeight);
- clipRect.setAttribute('width', iconBaseWidth);
- clipRect.setAttribute('height', iconBaseHeight - topSurfaceHeight);
- clipRect.setAttribute('rx', cornerRadius -1 > 0 ? cornerRadius -1 : 0);
- clipPath.appendChild(clipRect);
- defs.appendChild(clipPath);
-
- // Top Surface
- const topRect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
- topRect.setAttribute('x', x);
- topRect.setAttribute('y', y);
- topRect.setAttribute('width', iconBaseWidth);
- topRect.setAttribute('height', iconBaseHeight);
- topRect.setAttribute('rx', cornerRadius);
- topRect.setAttribute('ry', cornerRadius);
- topRect.style.fill = 'var(--exo-medium-gray)';
- topRect.setAttribute('stroke', 'var(--exo-light-gray)');
- topRect.setAttribute('stroke-width', '1px');
- specificIconGroup.appendChild(topRect);
-
- // Main Front Body (for memory fill)
- const frontBody = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
- frontBody.setAttribute('x', x);
- frontBody.setAttribute('y', y + topSurfaceHeight);
- frontBody.setAttribute('width', iconBaseWidth);
- frontBody.setAttribute('height', iconBaseHeight - topSurfaceHeight);
- frontBody.style.fill = fillCol; // Node status color
- specificIconGroup.appendChild(frontBody);
-
- // Memory Fill (clipped to frontBody area)
- if (ramUsagePercent > 0) {
- const memFillTotalHeight = iconBaseHeight - topSurfaceHeight;
- const memFillActualHeight = (ramUsagePercent / 100) * memFillTotalHeight;
- const memoryFillRect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
- memoryFillRect.setAttribute('x', x);
- memoryFillRect.setAttribute('y', y + topSurfaceHeight + (memFillTotalHeight - memFillActualHeight));
- memoryFillRect.setAttribute('width', iconBaseWidth);
- memoryFillRect.setAttribute('height', memFillActualHeight);
- let memFillColor = 'var(--exo-yellow)';
- if (fillCol === 'var(--exo-yellow)') memFillColor = 'var(--exo-yellow-darker)';
- memoryFillRect.style.fill = memFillColor;
- memoryFillRect.style.opacity = '0.75';
- memoryFillRect.setAttribute('clip-path', `url(#${clipPathId})`);
- specificIconGroup.appendChild(memoryFillRect);
- }
-
- // Front Panel Details for Mac Mini (No horizontal slot)
- const detailColor = 'rgba(0,0,0,0.3)';
- const slotHeight = iconBaseHeight * 0.18; // Adjusted for flatter mini
- const slotCornerRadius = 1.2;
-
- // Vertical slots (2)
- const vSlotWidth = iconBaseWidth * 0.045;
- const vSlotY = y + topSurfaceHeight + (iconBaseHeight - topSurfaceHeight) * 0.55 - slotHeight / 2;
- const vSlot1X = x + iconBaseWidth * 0.18; // Adjusted positioning for wider mini
- const vSlot2X = x + iconBaseWidth * 0.28;
-
- [vSlot1X, vSlot2X].forEach(vx => {
- const vSlot = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
- vSlot.setAttribute('x', vx - vSlotWidth / 2);
- vSlot.setAttribute('y', vSlotY);
- vSlot.setAttribute('width', vSlotWidth);
- vSlot.setAttribute('height', slotHeight);
- vSlot.setAttribute('fill', detailColor);
- vSlot.setAttribute('rx', slotCornerRadius);
- specificIconGroup.appendChild(vSlot);
- });
-
- // LED indicator for Mac Mini
- const ledRadius = iconBaseWidth * 0.022;
- const ledX = x + iconBaseWidth * 0.82; // Adjusted positioning
- const ledY = y + topSurfaceHeight + (iconBaseHeight - topSurfaceHeight) * 0.6;
- const led = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
- led.setAttribute('cx', ledX);
- led.setAttribute('cy', ledY);
- led.setAttribute('r', ledRadius);
- led.setAttribute('fill', 'var(--exo-light-gray)');
- specificIconGroup.appendChild(led);
-
- } else if (modelId.toLowerCase() === "macbook pro") {
- iconBaseWidth = nodeRadius * 1.6; // Max width of the base
- iconBaseHeight = nodeRadius * 1.15; // Overall height of the open laptop visual
- const x = nodeInfo.x - iconBaseWidth / 2;
- const y = nodeInfo.y - iconBaseHeight / 2;
-
- const screenCornerRadius = 3;
- const baseCornerRadius = 2;
- const screenBezel = 1.5;
-
- const perspectiveFactor = 0.15; // How much wider the back of the base is than the front
- const frontWidth = iconBaseWidth * (1 - perspectiveFactor);
- const backWidth = iconBaseWidth;
-
- const screenActualHeight = iconBaseHeight * 0.70;
- const baseActualHeight = iconBaseHeight * 0.30;
- const trackpadHeight = baseActualHeight * 0.35;
- const keyboardAreaHeight = baseActualHeight * 0.50;
-
- // Define ClipPath for the screen content area
- let defs = nodeG.querySelector('defs');
- if (!defs) {
- defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
- nodeG.appendChild(defs);
- }
- const clipPath = document.createElementNS('http://www.w3.org/2000/svg', 'clipPath');
- clipPath.setAttribute('id', clipPathId);
- const clipRectScreen = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
- clipRectScreen.setAttribute('x', x + (iconBaseWidth - frontWidth)/2 + screenBezel); // Screen aligned with front of base
- clipRectScreen.setAttribute('y', y + screenBezel);
- clipRectScreen.setAttribute('width', frontWidth - (2 * screenBezel));
- clipRectScreen.setAttribute('height', screenActualHeight - (2 * screenBezel));
- clipRectScreen.setAttribute('rx', screenCornerRadius -1 > 0 ? screenCornerRadius -1 : 0);
- clipPath.appendChild(clipRectScreen);
- defs.appendChild(clipPath);
-
- // Laptop Base (trapezoidal shape for perspective)
- const basePath = document.createElementNS('http://www.w3.org/2000/svg', 'path');
- const baseY = y + screenActualHeight;
- // Recalculate coordinates for correct perspective
- const x_back_left = x;
- const x_back_right = x + backWidth;
- const x_front_left = x + (backWidth - frontWidth) / 2;
- const x_front_right = x + (backWidth + frontWidth) / 2;
- const y_top_base = baseY;
- const y_bottom_base = baseY + baseActualHeight;
-
- const pathData = `
- M ${x_front_left} ${y_top_base}
- L ${x_front_right} ${y_top_base}
- L ${x_back_right} ${y_bottom_base}
- L ${x_back_left} ${y_bottom_base}
- Z
- `; // Correct trapezoid path
- basePath.setAttribute('d', pathData.trim().replace(/\s+/g, ' '));
- basePath.style.fill = fillCol; // Status color for the base
- basePath.setAttribute('stroke', 'var(--exo-light-gray)');
- basePath.setAttribute('stroke-width', '1px');
- specificIconGroup.appendChild(basePath);
-
- // Keyboard Area
- const keyboardX = x + (iconBaseWidth - frontWidth)/2 + frontWidth * 0.05;
- const keyboardY = baseY + baseActualHeight * 0.05;
- const keyboardWidth = frontWidth * 0.9;
- const keyboard = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
- keyboard.setAttribute('x', keyboardX);
- keyboard.setAttribute('y', keyboardY);
- keyboard.setAttribute('width', keyboardWidth);
- keyboard.setAttribute('height', keyboardAreaHeight);
- keyboard.setAttribute('fill', 'rgba(0,0,0,0.1)'); // Darker area for keyboard
- keyboard.setAttribute('rx', baseCornerRadius);
- specificIconGroup.appendChild(keyboard);
-
- // Trackpad Area
- const trackpadWidth = frontWidth * 0.4;
- const trackpadX = nodeInfo.x - trackpadWidth/2; // Centered
- const trackpadY = baseY + keyboardAreaHeight + baseActualHeight * 0.08;
- const trackpad = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
- trackpad.setAttribute('x', trackpadX);
- trackpad.setAttribute('y', trackpadY);
- trackpad.setAttribute('width', trackpadWidth);
- trackpad.setAttribute('height', trackpadHeight);
- trackpad.setAttribute('fill', 'rgba(255,255,255,0.1)'); // Lighter area for trackpad
- trackpad.setAttribute('rx', baseCornerRadius);
- specificIconGroup.appendChild(trackpad);
-
- // Screen Outline & Background (drawn on top of base part that might go under it)
- const screenOuter = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
- screenOuter.setAttribute('x', x + (iconBaseWidth - frontWidth)/2 ); // Screen aligned with front of base
- screenOuter.setAttribute('y', y);
- screenOuter.setAttribute('width', frontWidth);
- screenOuter.setAttribute('height', screenActualHeight);
- screenOuter.setAttribute('rx', screenCornerRadius);
- screenOuter.setAttribute('ry', screenCornerRadius);
- screenOuter.style.fill = 'var(--exo-dark-gray)'; // Dark screen area
- screenOuter.setAttribute('stroke', 'var(--exo-light-gray)');
- screenOuter.setAttribute('stroke-width', '1.5px');
- specificIconGroup.appendChild(screenOuter);
-
- // Memory Fill in Screen Area (Clipped)
- if (ramUsagePercent > 0) {
- const memFillTotalHeight = screenActualHeight - (2 * screenBezel);
- const memFillActualHeight = (ramUsagePercent / 100) * memFillTotalHeight;
- const memoryFillRect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
- memoryFillRect.setAttribute('x', x + (iconBaseWidth - frontWidth)/2 + screenBezel);
- memoryFillRect.setAttribute('y', y + screenBezel + (memFillTotalHeight - memFillActualHeight)); // From bottom up
- memoryFillRect.setAttribute('width', frontWidth - (2 * screenBezel));
- memoryFillRect.setAttribute('height', memFillActualHeight);
- let memFillColor = 'var(--exo-yellow)';
- if (fillCol === 'var(--exo-yellow)') memFillColor = 'var(--exo-yellow-darker)';
- memoryFillRect.style.fill = memFillColor;
- memoryFillRect.style.opacity = '0.9';
- memoryFillRect.setAttribute('clip-path', `url(#${clipPathId})`);
- specificIconGroup.appendChild(memoryFillRect);
- }
-
- // Apple Logo on Screen (on top of memory fill)
- const logoPath = document.createElementNS('http://www.w3.org/2000/svg', 'path');
- logoPath.setAttribute('d', APPLE_LOGO_PATH_SIMPLE);
- const targetLogoHeightMBP = screenActualHeight * 0.18;
- const logoScale = targetLogoHeightMBP / LOGO_NATIVE_HEIGHT; // Correct scale calculation
- const logoX = nodeInfo.x - (LOGO_NATIVE_WIDTH * logoScale / 2);
- const logoY = y + screenActualHeight / 2 - (LOGO_NATIVE_HEIGHT * logoScale / 2); // Centered Y
- logoPath.setAttribute('transform', `translate(${logoX} ${logoY}) scale(${logoScale})`);
- logoPath.setAttribute('fill', '#FFFFFF');
- specificIconGroup.appendChild(logoPath);
-
- } else { // Default/Unknown
- // Default/Unknown Icon - A simple rounded rectangle
- const unknownIconWidth = iconBaseWidth * 0.8;
- const unknownIconHeight = iconBaseHeight * 0.8;
- const unknown = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
- unknown.setAttribute('x', nodeInfo.x - unknownIconWidth / 2);
- unknown.setAttribute('y', nodeInfo.y - unknownIconHeight / 2);
- unknown.setAttribute('width', unknownIconWidth);
- unknown.setAttribute('height', unknownIconHeight);
- unknown.style.fill = fillCol;
- unknown.setAttribute('stroke', 'var(--exo-light-gray)');
- unknown.setAttribute('stroke-width', '1px');
- unknown.setAttribute('rx', '3');
- specificIconGroup.appendChild(unknown);
- }
- nodeG.appendChild(specificIconGroup);
- // --- End Device Specific Icon Drawing ---
-
- // --- Memory Text (Below Icon) ---
- const nodeMemoryText = document.createElementNS('http://www.w3.org/2000/svg', 'text');
- nodeMemoryText.setAttribute('x', nodeInfo.x);
- nodeMemoryText.setAttribute('y', nodeInfo.y + iconBaseHeight / 2 + 14); // Position below icon, increased offset
- nodeMemoryText.setAttribute('class', 'node-memory-text');
- nodeMemoryText.innerHTML = `
- <tspan x="${nodeInfo.x}" dy="1em">${ramUsedFormatted}/${ramTotalFormatted}</tspan>
- <tspan x="${nodeInfo.x}" dy="1.2em">(${ramUsagePercent.toFixed(0)}%)</tspan>
- `;
- nodeG.appendChild(nodeMemoryText);
-
- // --- Friendly Name Text (Above Icon) ---
- const friendlyNameText = document.createElementNS('http://www.w3.org/2000/svg', 'text');
- friendlyNameText.setAttribute('x', nodeInfo.x);
- friendlyNameText.setAttribute('y', nodeInfo.y - iconBaseHeight / 2 - 15); // Position above icon
- friendlyNameText.setAttribute('text-anchor', 'middle');
- friendlyNameText.style.fontSize = '18px';
- friendlyNameText.style.fill = 'var(--exo-light-gray)';
- const friendlyName = node.friendly_name || nodeId.substring(0, 8) + '...';
- friendlyNameText.textContent = friendlyName;
- nodeG.appendChild(friendlyNameText);
-
- // --- Vertical GPU Bar (Next to Icon) ---
- const gpuVerticalBarWidth = 24; // Increased width
- const gpuVerticalBarHeight = iconBaseHeight * 0.95; // Relative to icon height
- const barXOffset = iconBaseWidth / 2 + 18; // Increased offset from icon's right edge
- const gpuBarX = nodeInfo.x + barXOffset;
- const gpuBarY = nodeInfo.y - gpuVerticalBarHeight / 2; // Centered vertically with icon
-
- // GPU Bar Background
- const gpuBarBg = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
- gpuBarBg.setAttribute('x', gpuBarX);
- gpuBarBg.setAttribute('y', gpuBarY);
- gpuBarBg.setAttribute('width', gpuVerticalBarWidth);
- gpuBarBg.setAttribute('height', gpuVerticalBarHeight);
- gpuBarBg.setAttribute('fill', 'var(--exo-medium-gray)');
- gpuBarBg.setAttribute('rx', 2);
- nodeG.appendChild(gpuBarBg);
-
- // GPU Bar Fill
- if (gpuUsagePercent > 0) {
- const fillHeight = (gpuUsagePercent / 100) * gpuVerticalBarHeight;
- const gpuBarFill = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
- gpuBarFill.setAttribute('x', gpuBarX);
- gpuBarFill.setAttribute('y', gpuBarY + (gpuVerticalBarHeight - fillHeight)); // Fills from bottom up
- gpuBarFill.setAttribute('width', gpuVerticalBarWidth);
- gpuBarFill.setAttribute('height', fillHeight);
- gpuBarFill.setAttribute('fill', getGpuBarColor(gpuTempForColor));
- gpuBarFill.setAttribute('rx', 2);
- nodeG.appendChild(gpuBarFill);
- }
-
- // GPU Percentage and Temperature Text on Bar (multiline)
- const gpuInfoOnBarText = document.createElementNS('http://www.w3.org/2000/svg', 'text');
- gpuInfoOnBarText.setAttribute('x', gpuBarX + gpuVerticalBarWidth / 2);
- gpuInfoOnBarText.setAttribute('y', gpuBarY + gpuVerticalBarHeight / 2); // Base Y for centering
- gpuInfoOnBarText.setAttribute('class', 'gpu-temp-on-bar');
- gpuInfoOnBarText.style.dominantBaseline = 'central'; // Helps with overall vertical alignment
-
- const gpuUsageText = gpuUsagePercent > 0 ? `${gpuUsagePercent.toFixed(0)}%` : '-';
- const tempText = gpuTempDisplay === 'N/A' ? '-' : gpuTempDisplay;
- const powerText = sysPower; // Already formatted with " W" or "N/A"
-
- gpuInfoOnBarText.innerHTML = `
- <tspan x="${gpuBarX + gpuVerticalBarWidth / 2}" dy="-1.1em">${gpuUsageText}</tspan>
- <tspan x="${gpuBarX + gpuVerticalBarWidth / 2}" dy="1.2em">${tempText}</tspan>
- <tspan x="${gpuBarX + gpuVerticalBarWidth / 2}" dy="1.2em">${powerText}</tspan>
- `;
- nodeG.appendChild(gpuInfoOnBarText);
-
- // Add click listener to the group
- nodeG.addEventListener('click', () => {
- currentlySelectedNodeId = nodeId; // Set the globally selected node
- showNodeDetails(nodeId, nodesData);
- });
-
- nodesGroup.appendChild(nodeG);
- });
- // Draw order: lines at the very back, then edge labels, then nodes, then mid-line arrows on top
- topologyGraphContainer.appendChild(linksGroup);
- topologyGraphContainer.appendChild(edgeLabelsGroup);
- topologyGraphContainer.appendChild(nodesGroup);
- topologyGraphContainer.appendChild(arrowsGroup);
- }
-
- function showNodeDetails(selectedNodeId, allNodesData) {
- const nodeData = allNodesData[selectedNodeId];
- if (!nodeData) return;
-
- detailFriendlyName.textContent = nodeData.friendly_name || selectedNodeId;
- if (nodeData.friendly_name) {
- detailNodeId.textContent = selectedNodeId;
- detailNodeId.style.display = 'block';
- } else {
- detailNodeId.style.display = 'none';
- }
-
- let ramUsagePercent = 0, cpuUsagePercent = 0, gpuUsagePercent = 0;
- let aneActive = false;
- let sysPower = 'N/A', cpuTemp = 'N/A', gpuTemp = 'N/A';
- let primaryIp = nodeData.addrs && nodeData.addrs.length > 0 ? nodeData.addrs[0] : 'N/A';
- let otherIpCount = nodeData.addrs && nodeData.addrs.length > 1 ? ` (+${nodeData.addrs.length - 1})` : '';
-
- const macmon = nodeData.macmon_info;
- let ramUsageFormatted = '0 Bytes';
- let ramTotalFormatted = '0 Bytes';
-
- if (macmon) {
- if (macmon.memory && macmon.memory.ram_total > 0) {
- ramUsagePercent = (macmon.memory.ram_usage / macmon.memory.ram_total) * 100;
- ramUsageFormatted = formatBytes(macmon.memory.ram_usage, 1);
- ramTotalFormatted = formatBytes(macmon.memory.ram_total, 1);
- }
- if (macmon.pcpu_usage && macmon.ecpu_usage) {
- cpuUsagePercent = ((macmon.pcpu_usage[1] + macmon.ecpu_usage[1]) / 2) * 100;
- }
- if (macmon.gpu_usage) {
- gpuUsagePercent = macmon.gpu_usage[1] * 100;
- }
- aneActive = macmon.ane_power > 0;
- if (macmon.sys_power) sysPower = `${macmon.sys_power.toFixed(1)} W`;
- if (macmon.temp) {
- if (macmon.temp.cpu_temp_avg) cpuTemp = `${macmon.temp.cpu_temp_avg.toFixed(1)}°C`;
- if (typeof macmon.temp.gpu_temp_avg === 'number') { // Check type for robustness
- const actualGpuTemp = macmon.temp.gpu_temp_avg;
- const clampedGpuTemp = Math.max(30, actualGpuTemp);
- gpuTemp = `${clampedGpuTemp.toFixed(1)}°C`;
- } // gpuTemp remains 'N/A' if not set or not a number
- }
- }
-
- detailContent.innerHTML = `
- <div class="info-item">
- <span class="label">IP Address</span>
- <span class="value">${primaryIp}${otherIpCount}</span>
- </div>
- <div class="info-item">
- <span class="label">Total Unified Memory</span>
- <span class="value">${formatBytes(nodeData.mem)}</span>
- </div>
- <div class="info-item">
- <span class="label">System Power</span>
- <span class="value">${sysPower}</span>
- </div>
- <div class="info-item">
- <span class="label">ANE</span>
- <span class="value ${aneActive ? 'accent' : ''}">${aneActive ? 'Active' : 'Idle'}</span>
- </div>
- <div class="info-item">
- <span class="label">CPU Temp</span>
- <span class="value">${cpuTemp}</span>
- </div>
- <div class="info-item">
- <span class="label">GPU Temp</span>
- <span class="value">${gpuTemp}</span>
- </div>
- <hr style="border-color: var(--exo-medium-gray); margin: 15px 0;">
- <div class="info-item">
- <span class="label">Unified Memory Usage (${ramUsageFormatted} / ${ramTotalFormatted})</span>
- <div class="progress-bar-container">
- <div class="progress-bar" style="width: ${ramUsagePercent.toFixed(2)}%;"></div>
- </div>
- </div>
- <div class="info-item">
- <span class="label">CPU Usage (${cpuUsagePercent.toFixed(1)}%)</span>
- <div class="progress-bar-container">
- <div class="progress-bar" style="width: ${cpuUsagePercent.toFixed(2)}%;"></div>
- </div>
- </div>
- <div class="info-item">
- <span class="label">GPU Usage (${gpuUsagePercent.toFixed(1)}%)</span>
- <div class="progress-bar-container">
- <div class="progress-bar" style="width: ${gpuUsagePercent.toFixed(2)}%;"></div>
- </div>
- </div>
- <div class="info-item" style="margin-top: 20px; font-size:0.8em; opacity:0.7;">
- <span class="label">Last macmon Update</span>
- <span class="value">${timeSince(nodeData.last_macmon_update)} (raw: ${nodeData.last_macmon_update})</span>
- </div>
- `;
-
- nodeDetailPanel.classList.add('visible');
- }
-
- if (closeDetailPanelButton) {
- closeDetailPanelButton.addEventListener('click', () => {
- nodeDetailPanel.classList.remove('visible');
- currentlySelectedNodeId = null; // Clear selected node on manual close
- });
- }
-
- // Set up sidebar toggle functionality
- if (instancesMenuButton) {
- instancesMenuButton.addEventListener('click', toggleSidebar);
- }
-
- // Set up launch instance functionality
- if (modelSelect) {
- modelSelect.addEventListener('change', () => {
- launchInstanceButton.disabled = !modelSelect.value;
- });
- }
-
- if (launchInstanceButton) {
- launchInstanceButton.addEventListener('click', launchInstance);
- }
-
- let isFetching = false; // Lock to prevent multiple concurrent fetches
- let fetchIntervalId = null;
-
- async function fetchDataAndRender() {
- if (isFetching) {
- // console.log("Already fetching, skipping this interval.");
- return;
- }
- isFetching = true;
- // console.log(`Fetching data from: ${API_ENDPOINT}`); // Less verbose for 1s interval
- if (lastUpdatedElement.textContent === 'Fetching data...' || !lastUpdatedElement.textContent.startsWith("Last updated:")) {
- lastUpdatedElement.textContent = 'Fetching data...';
- }
-
- try {
- const urlWithCacheBuster = new URL(API_ENDPOINT);
- urlWithCacheBuster.searchParams.set('_cb', new Date().getTime()); // _cb for cache buster
-
- const response = await fetch(urlWithCacheBuster.toString(), { cache: 'no-store' }); // Keep no-store too
- if (!response.ok) {
- throw new Error(`HTTP error! status: ${response.status} ${response.statusText}`);
- }
- const clusterState = await response.json();
- const topologyData = transformClusterStateToTopology(clusterState);
- // Build nodeId -> friendly name map
- nodeIdToFriendlyName = {};
- if (topologyData && topologyData.nodes) {
- Object.keys(topologyData.nodes).forEach(nid => {
- const n = topologyData.nodes[nid];
- const name = (n && (n.friendly_name || (n.system_info && n.system_info.model_id))) || null;
- if (name) nodeIdToFriendlyName[nid] = name;
- });
- }
- renderNodes(topologyData);
-
- // If a node was selected, and it still exists, refresh its details
- 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;
- }
-
- lastUpdatedElement.textContent = `Last updated: ${new Date().toLocaleTimeString()}`;
- } catch (error) {
- console.error("Failed to fetch topology data:", error);
- if (topologyGraphContainer) {
- topologyGraphContainer.innerHTML = ''; // Clear previous content
- const textEl = document.createElementNS('http://www.w3.org/2000/svg', 'text');
- textEl.setAttribute('x', '50%');
- textEl.setAttribute('y', '50%');
- textEl.setAttribute('alignment-baseline', 'middle');
- textEl.setAttribute('text-anchor', 'middle');
- textEl.setAttribute('fill', '#FFD700'); // Yellow color
- textEl.setAttribute('font-size', '14');
- textEl.textContent = `Connect a Mac to start`;
- topologyGraphContainer.appendChild(textEl);
- }
- lastUpdatedElement.textContent = `Update failed: ${new Date().toLocaleTimeString()}`;
- // If fetch fails, also consider hiding the panel if a node was selected
- if (currentlySelectedNodeId) {
- nodeDetailPanel.classList.remove('visible');
- currentlySelectedNodeId = null;
- }
- } finally {
- isFetching = false;
- }
- }
-
- // Transform ClusterState -> nodesData compatible with existing render logic
- function _extractIpFromMultiaddr(ma) {
- if (!ma || typeof ma !== 'string') return null;
- const parts = ma.split('/');
- const ip4Idx = parts.indexOf('ip4');
- const ip6Idx = parts.indexOf('ip6');
- const idx = ip4Idx >= 0 ? ip4Idx : ip6Idx;
- if (idx >= 0 && parts.length > idx + 1) {
- return parts[idx + 1];
- }
- return null;
- }
-
- function transformClusterStateToTopology(clusterState) {
- 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) {
- if (typeof value === 'number') return value;
- if (value && typeof value === 'object') {
- if (typeof value.in_bytes === 'number') return value.in_bytes;
- if (typeof value.inBytes === 'number') return value.inBytes;
- }
- return 0;
- }
-
- // Helper: pick from snake_case or camelCase
- const pick = (obj, snake, camel, fallback = undefined) => {
- if (!obj) return fallback;
- if (obj[snake] !== undefined) return obj[snake];
- if (obj[camel] !== undefined) return obj[camel];
- return fallback;
- };
-
- // Helper: detect API placeholders like "unknown" (case-insensitive)
- const isUnknown = (value) => {
- return typeof value === 'string' && value.trim().toLowerCase() === 'unknown';
- };
-
- // Process nodes from topology or fallback to nodeProfiles directly (support both snake_case and camelCase)
- let nodesToProcess = {};
- if (clusterState.topology && Array.isArray(clusterState.topology.nodes)) {
- clusterState.topology.nodes.forEach(node => {
- const nid = node.nodeId ?? node.node_id;
- const nprof = node.nodeProfile ?? node.node_profile;
- if (nid && nprof) {
- nodesToProcess[nid] = nprof;
- }
- });
- } else if (clusterState.nodeProfiles || clusterState.node_profiles) {
- nodesToProcess = clusterState.nodeProfiles || clusterState.node_profiles;
- }
-
- // Transform each node
- for (const nodeId in nodesToProcess) {
- const nodeProfile = nodesToProcess[nodeId];
- if (!nodeProfile) continue;
-
- // Extract memory information (supports new nested schema and old flat numbers)
- let memBytesTotal = 0;
- let memBytesAvailable = 0;
- const memory = nodeProfile.memory || {};
- const ramTotalVal = pick(memory, 'ram_total', 'ramTotal');
- const ramAvailVal = pick(memory, 'ram_available', 'ramAvailable');
- const swapTotalVal = pick(memory, 'swap_total', 'swapTotal');
- const swapAvailVal = pick(memory, 'swap_available', 'swapAvailable');
-
- memBytesTotal = getBytes(ramTotalVal);
- memBytesAvailable = getBytes(ramAvailVal);
- const memBytesUsed = Math.max(memBytesTotal - memBytesAvailable, 0);
-
- // Extract model information with graceful placeholders while node is loading
- const rawModelId = pick(nodeProfile, 'model_id', 'modelId', 'Unknown');
- const rawChipId = pick(nodeProfile, 'chip_id', 'chipId', '');
- const rawFriendlyName = pick(nodeProfile, 'friendly_name', 'friendlyName', `${nodeId.substring(0, 8)}...`);
-
- // When API has not fully loaded (reports "unknown"), present a nice default
- const modelId = isUnknown(rawModelId) ? 'Mac Studio' : rawModelId;
- const chipId = isUnknown(rawChipId) ? '' : rawChipId;
- const friendlyName = (!rawFriendlyName || isUnknown(rawFriendlyName)) ? 'Mac' : rawFriendlyName;
-
- // Extract network addresses (support snake_case and camelCase)
- const addrList = [];
- const netIfacesSnake = nodeProfile.network_interfaces;
- const netIfacesCamel = nodeProfile.networkInterfaces;
- const interfaces = Array.isArray(netIfacesSnake) ? netIfacesSnake : (Array.isArray(netIfacesCamel) ? netIfacesCamel : []);
- interfaces.forEach(intf => {
- const ip = intf.ip_address ?? intf.ipAddress;
- if (ip && !String(ip).startsWith('fe80::')) {
- addrList.push(ip);
- }
- });
-
- // Transform system metrics to macmon_info format (support snake_case and camelCase)
- const systemInfo = nodeProfile.system || {};
- const gpuUsage = pick(systemInfo, 'gpu_usage', 'gpuUsage', 0);
- const temp = pick(systemInfo, 'temp', 'temp', null);
- const sysPower = pick(systemInfo, 'sys_power', 'sysPower', null);
- const pcpuUsage = pick(systemInfo, 'pcpu_usage', 'pcpuUsage', 0);
- const ecpuUsage = pick(systemInfo, 'ecpu_usage', 'ecpuUsage', 0);
- const anePower = pick(systemInfo, 'ane_power', 'anePower', 0);
- const flopsFp16 = pick(systemInfo, 'flops_fp16', 'flopsFp16', 0);
-
- const macmonInfo = {
- memory: {
- ram_total: memBytesTotal,
- ram_usage: memBytesUsed,
- ram_available: memBytesAvailable,
- swap_total: getBytes(swapTotalVal),
- swap_usage: Math.max(getBytes(swapTotalVal) - getBytes(swapAvailVal), 0)
- },
- gpu_usage: [0, typeof gpuUsage === 'number' ? gpuUsage : 0],
- temp: {
- cpu_temp_avg: typeof temp === 'number' ? temp : null,
- gpu_temp_avg: typeof temp === 'number' ? temp : null
- },
- sys_power: typeof sysPower === 'number' ? sysPower : null,
- pcpu_usage: [0, typeof pcpuUsage === 'number' ? pcpuUsage : 0],
- ecpu_usage: [0, typeof ecpuUsage === 'number' ? ecpuUsage : 0],
- ane_power: typeof anePower === 'number' ? anePower : 0,
- flops_fp16: typeof flopsFp16 === 'number' ? flopsFp16 : 0,
- timestamp: new Date().toISOString()
- };
-
- resultNodes[nodeId] = {
- mem: memBytesTotal,
- addrs: addrList,
- last_addr_update: Date.now() / 1000,
- system_info: {
- model_id: modelId,
- chip_id: chipId
- },
- macmon_info: macmonInfo,
- last_macmon_update: Date.now() / 1000,
- friendly_name: friendlyName
- };
- }
-
- // Extract directed edges from topology.connections if present (support camelCase)
- const connections = clusterState.topology && Array.isArray(clusterState.topology.connections)
- ? clusterState.topology.connections
- : [];
- connections.forEach(conn => {
- if (!conn) return;
- const src = conn.localNodeId ?? conn.local_node_id;
- const dst = conn.sendBackNodeId ?? conn.send_back_node_id;
- 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
-
- // Extract address information from connection
- const sendBackMultiaddr = conn.sendBackMultiaddr ?? conn.send_back_multiaddr;
-
- // Extract IP from sendBackMultiaddr object
- // It might have properties like 'multiaddr' or be serialized differently
- let sendBackAddrString = null;
- if (sendBackMultiaddr) {
- // Try different possible field names
- sendBackAddrString = sendBackMultiaddr.multiaddr ??
- sendBackMultiaddr.address ??
- sendBackMultiaddr.addr ??
- (typeof sendBackMultiaddr === 'string' ? sendBackMultiaddr : null);
-
- // If it's still an object, try to convert to string
- if (!sendBackAddrString && typeof sendBackMultiaddr === 'object') {
- sendBackAddrString = sendBackMultiaddr.toString?.() ?? JSON.stringify(sendBackMultiaddr);
- }
- }
-
- // Extract IP from the multiaddr string
- const sendBackIp = _extractIpFromMultiaddr(sendBackAddrString);
-
- // Try to map IP to interface name on destination node
- let sendBackInterface = null;
-
- if (sendBackIp && resultNodes[dst]) {
- const dstNode = nodesToProcess[dst];
- if (dstNode) {
- const netIfacesSnake = dstNode.network_interfaces;
- const netIfacesCamel = dstNode.networkInterfaces;
- const interfaces = Array.isArray(netIfacesSnake) ? netIfacesSnake : (Array.isArray(netIfacesCamel) ? netIfacesCamel : []);
- const matchingIface = interfaces.find(intf => {
- const ip = intf.ip_address ?? intf.ipAddress;
- return ip === sendBackIp;
- });
- if (matchingIface) {
- sendBackInterface = matchingIface.name ?? matchingIface.interface_name ?? matchingIface.interfaceName;
- }
- }
- }
-
- resultEdges.push({
- source: src,
- target: dst,
- sendBackIp: sendBackIp,
- sendBackInterface: sendBackInterface,
- multiaddr: sendBackAddrString
- });
- });
-
- return { nodes: resultNodes, edges: resultEdges };
- }
-
- // --- Conditional Data Handling ---
- if (!USE_MOCK_DATA) {
- // Initial fetch for live data
- fetchDataAndRender();
- fetchAndRenderInstances();
- fetchAndPopulateModels();
- // Periodic refresh for live data
- fetchIntervalId = setInterval(fetchDataAndRender, REFRESH_INTERVAL);
- setInterval(fetchAndRenderInstances, REFRESH_INTERVAL);
- } else {
- // Use Mock Data
- lastUpdatedElement.textContent = "Using Mock Data";
- const mockData = {
- "mockNodeId1_MacBookPro": {
- "mem": 34359738368, // e.g. 32GB
- "addrs": ["192.168.1.101", "100.64.0.1"],
- "last_addr_update": (Date.now() / 1000) - Math.random() * 60,
- "system_info": { "model_id": "MacBook Pro", "chip_id": "Apple M-Series Ultra", "memory": 32768 },
- "macmon_info": {
- 'all_power': 0.0,
- 'ane_power': 0.0,
- 'cpu_power': 0.0,
- 'ecpu_usage': [2048, 0.1], // [absolute, percentage 0-1]
- 'gpu_power': 0.0,
- 'gpu_ram_power': 0.0,
- 'gpu_usage': [384, 0.05], // [absolute, percentage 0-1]
- 'memory': {
- 'ram_total': 34359738368, // 32GB
- 'ram_usage': 8589934592, // 8GB
- 'swap_total': 4294967296, // 4GB
- 'swap_usage': 0
- },
- 'pcpu_usage': [2048, 0.2], // [absolute, percentage 0-1]
- 'ram_power': 0.0,
- 'sys_power': 15.0, // Watts
- 'temp': {
- 'cpu_temp_avg': 45.0,
- 'gpu_temp_avg': 42.0
- },
- 'timestamp': new Date().toISOString()
- },
- "last_macmon_update": (Date.now() / 1000) - 2,
- "friendly_name": "Dev Alpha MBP"
- },
- "mockNodeId2_MacMini": {
- "mem": 17179869184, // e.g. 16GB
- "addrs": ["192.168.1.102"],
- "last_addr_update": (Date.now() / 1000) - Math.random() * 120,
- "system_info": { "model_id": "Mac Mini", "chip_id": "Apple M-Series Max", "memory": 16384 },
- "macmon_info": {
- 'all_power': 0.0,
- 'ane_power': 0.0,
- 'cpu_power': 0.0,
- 'ecpu_usage': [4096, 0.05],
- 'gpu_power': 0.0,
- 'gpu_ram_power': 0.0,
- 'gpu_usage': [512, 0.02],
- 'memory': {
- 'ram_total': 17179869184,
- 'ram_usage': 4294967296, // 4GB
- 'swap_total': 0,
- 'swap_usage': 0
- },
- 'pcpu_usage': [4096, 0.1],
- 'ram_power': 0.0,
- 'sys_power': 25.0,
- 'temp': {
- 'cpu_temp_avg': 55.0,
- 'gpu_temp_avg': 50.0
- },
- 'timestamp': new Date().toISOString()
- },
- "last_macmon_update": (Date.now() / 1000) - 5,
- "friendly_name": "Build Server Mini"
- },
- "mockNodeId3_MacStudio": { // New Mac Studio Mock Node
- "mem": 137438953472, // 128GB
- "addrs": ["192.168.1.103"],
- "last_addr_update": (Date.now() / 1000) - Math.random() * 30,
- "system_info": { "model_id": "Mac Studio", "chip_id": "Apple M-Ultra Fusion Max Pro", "memory": 131072 },
- "macmon_info": {
- 'all_power': 0.0,
- 'ane_power': 0.0,
- 'cpu_power': 0.0,
- 'ecpu_usage': [8192, 0.02],
- 'gpu_power': 0.0,
- 'gpu_ram_power': 0.0,
- 'gpu_usage': [1024, 0.01],
- 'memory': {
- 'ram_total': 137438953472,
- 'ram_usage': 34359738368, // 32GB used
- 'swap_total': 8589934592,
- 'swap_usage': 0
- },
- 'pcpu_usage': [8192, 0.05],
- 'ram_power': 0.0,
- 'sys_power': 45.0,
- 'temp': {
- 'cpu_temp_avg': 60.0,
- 'gpu_temp_avg': 58.0
- },
- 'timestamp': new Date().toISOString()
- },
- "last_macmon_update": (Date.now() / 1000) - 3,
- "friendly_name": "Graphics Studio Ultra"
- }
- };
-
- function updateMockData() {
- // Build name map for mock nodes
- nodeIdToFriendlyName = {};
- for (const nodeId in mockData) {
- nodeIdToFriendlyName[nodeId] = mockData[nodeId].friendly_name || nodeId;
- }
-
- for (const nodeId in mockData) {
- const node = mockData[nodeId];
- node.last_addr_update = (Date.now() / 1000) - (Math.random() * 10);
- node.last_macmon_update = (Date.now() / 1000) - (Math.random() * 3 + 1); // More recent
-
- if (node.macmon_info) {
- const mi = node.macmon_info;
- mi.ecpu_usage[1] = Math.random() * 0.5; // Random % for E-cores
- mi.pcpu_usage[1] = Math.random() * 0.8; // Random % for P-cores
- mi.gpu_usage[1] = Math.random() * 0.9; // Random % for GPU
-
- mi.memory.ram_usage = Math.random() * mi.memory.ram_total;
- if (mi.memory.swap_total > 0) {
- mi.memory.swap_usage = Math.random() * mi.memory.swap_total * 0.2; // Less swap usage
- }
-
- mi.ane_power = Math.random() > 0.6 ? Math.random() * 5 : 0; // Watts, sometimes active
- mi.cpu_power = (mi.pcpu_usage[1] * 15) + (mi.ecpu_usage[1] * 5) + (Math.random() * 5); // Approx Watts
- mi.gpu_power = (mi.gpu_usage[1] * 20) + (Math.random() * 3); // Approx Watts
- mi.ram_power = (mi.memory.ram_usage / mi.memory.ram_total) * 2 + (Math.random() * 0.5); // Approx Watts
- mi.all_power = mi.ane_power + mi.cpu_power + mi.gpu_power + mi.ram_power + 5; // Base + components
- mi.sys_power = mi.all_power + Math.random() * 2; // sys_power is usually a bit more than all_power sum
-
- mi.temp.cpu_temp_avg = 35 + (mi.pcpu_usage[1] + mi.ecpu_usage[1]) * 30 + (Math.random() * 10 - 5);
- mi.temp.gpu_temp_avg = 30 + mi.gpu_usage[1] * 50 + (Math.random() * 10 - 5);
- mi.timestamp = new Date().toISOString();
- }
- }
- const mockTopology = { nodes: mockData, edges: [] };
- renderNodes(mockTopology);
- lastUpdatedElement.textContent = `Last updated: ${new Date().toLocaleTimeString()} (Mock Data)`;
-
- if (currentlySelectedNodeId && mockData[currentlySelectedNodeId]) {
- showNodeDetails(currentlySelectedNodeId, mockTopology.nodes);
- } else if (currentlySelectedNodeId && !mockData[currentlySelectedNodeId]) {
- nodeDetailPanel.classList.remove('visible');
- currentlySelectedNodeId = null;
- }
- }
- updateMockData(); // Initial render with mock
- setInterval(updateMockData, REFRESH_INTERVAL);
- }
-
- </script>
-</body>
-</html>
\ No newline at end of file
diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json
new file mode 100644
index 00000000..e075d621
--- /dev/null
+++ b/dashboard/package-lock.json
@@ -0,0 +1,3058 @@
+{
+ "name": "exo-dashboard",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "exo-dashboard",
+ "version": "1.0.0",
+ "dependencies": {
+ "highlight.js": "^11.11.1",
+ "mode-watcher": "^1.1.0"
+ },
+ "devDependencies": {
+ "@sveltejs/adapter-static": "^3.0.10",
+ "@sveltejs/kit": "^2.48.4",
+ "@sveltejs/vite-plugin-svelte": "^5.0.0",
+ "@tailwindcss/vite": "^4.0.0",
+ "@types/d3": "^7.4.3",
+ "@types/node": "^22",
+ "d3": "^7.9.0",
+ "svelte": "^5.0.0",
+ "svelte-check": "^4.0.0",
+ "tailwindcss": "^4.0.0",
+ "tw-animate-css": "^1.3.5",
+ "typescript": "^5.0.0",
+ "vite": "^6.0.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
+ "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
+ "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
+ "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
+ "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
+ "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
+ "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
+ "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
+ "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
+ "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
+ "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
+ "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
+ "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
+ "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
+ "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
+ "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
+ "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
+ "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
+ "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
+ "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
+ "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
+ "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@polka/url": {
+ "version": "1.0.0-next.29",
+ "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
+ "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.53.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz",
+ "integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.53.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.3.tgz",
+ "integrity": "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.53.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.3.tgz",
+ "integrity": "sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.53.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.3.tgz",
+ "integrity": "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.53.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.3.tgz",
+ "integrity": "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.53.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.3.tgz",
+ "integrity": "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.53.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.3.tgz",
+ "integrity": "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.53.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.3.tgz",
+ "integrity": "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.53.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.3.tgz",
+ "integrity": "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.53.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.3.tgz",
+ "integrity": "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.53.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.3.tgz",
+ "integrity": "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.53.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.3.tgz",
+ "integrity": "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.53.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.3.tgz",
+ "integrity": "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.53.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.3.tgz",
+ "integrity": "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.53.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.3.tgz",
+ "integrity": "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.53.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz",
+ "integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.53.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.3.tgz",
+ "integrity": "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.53.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.3.tgz",
+ "integrity": "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.53.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.3.tgz",
+ "integrity": "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.53.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.3.tgz",
+ "integrity": "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.53.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.3.tgz",
+ "integrity": "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.53.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.3.tgz",
+ "integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@standard-schema/spec": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
+ "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@sveltejs/acorn-typescript": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.8.tgz",
+ "integrity": "sha512-esgN+54+q0NjB0Y/4BomT9samII7jGwNy/2a3wNZbT2A2RpmXsXwUt24LvLhx6jUq2gVk4cWEvcRO6MFQbOfNA==",
+ "license": "MIT",
+ "peerDependencies": {
+ "acorn": "^8.9.0"
+ }
+ },
+ "node_modules/@sveltejs/adapter-static": {
+ "version": "3.0.10",
+ "resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-3.0.10.tgz",
+ "integrity": "sha512-7D9lYFWJmB7zxZyTE/qxjksvMqzMuYrrsyh1f4AlZqeZeACPRySjbC3aFiY55wb1tWUaKOQG9PVbm74JcN2Iew==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@sveltejs/kit": "^2.0.0"
+ }
+ },
+ "node_modules/@sveltejs/kit": {
+ "version": "2.49.0",
+ "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.49.0.tgz",
+ "integrity": "sha512-oH8tXw7EZnie8FdOWYrF7Yn4IKrqTFHhXvl8YxXxbKwTMcD/5NNCryUSEXRk2ZR4ojnub0P8rNrsVGHXWqIDtA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/spec": "^1.0.0",
+ "@sveltejs/acorn-typescript": "^1.0.5",
+ "@types/cookie": "^0.6.0",
+ "acorn": "^8.14.1",
+ "cookie": "^0.6.0",
+ "devalue": "^5.3.2",
+ "esm-env": "^1.2.2",
+ "kleur": "^4.1.5",
+ "magic-string": "^0.30.5",
+ "mrmime": "^2.0.0",
+ "sade": "^1.8.1",
+ "set-cookie-parser": "^2.6.0",
+ "sirv": "^3.0.0"
+ },
+ "bin": {
+ "svelte-kit": "svelte-kit.js"
+ },
+ "engines": {
+ "node": ">=18.13"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": "^1.0.0",
+ "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0",
+ "svelte": "^4.0.0 || ^5.0.0-next.0",
+ "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0"
+ },
+ "peerDependenciesMeta": {
+ "@opentelemetry/api": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@sveltejs/vite-plugin-svelte": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-5.1.1.tgz",
+ "integrity": "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@sveltejs/vite-plugin-svelte-inspector": "^4.0.1",
+ "debug": "^4.4.1",
+ "deepmerge": "^4.3.1",
+ "kleur": "^4.1.5",
+ "magic-string": "^0.30.17",
+ "vitefu": "^1.0.6"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22"
+ },
+ "peerDependencies": {
+ "svelte": "^5.0.0",
+ "vite": "^6.0.0"
+ }
+ },
+ "node_modules/@sveltejs/vite-plugin-svelte-inspector": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-4.0.1.tgz",
+ "integrity": "sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.3.7"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22"
+ },
+ "peerDependencies": {
+ "@sveltejs/vite-plugin-svelte": "^5.0.0",
+ "svelte": "^5.0.0",
+ "vite": "^6.0.0"
+ }
+ },
+ "node_modules/@tailwindcss/node": {
+ "version": "4.1.17",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.17.tgz",
+ "integrity": "sha512-csIkHIgLb3JisEFQ0vxr2Y57GUNYh447C8xzwj89U/8fdW8LhProdxvnVH6U8M2Y73QKiTIH+LWbK3V2BBZsAg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/remapping": "^2.3.4",
+ "enhanced-resolve": "^5.18.3",
+ "jiti": "^2.6.1",
+ "lightningcss": "1.30.2",
+ "magic-string": "^0.30.21",
+ "source-map-js": "^1.2.1",
+ "tailwindcss": "4.1.17"
+ }
+ },
+ "node_modules/@tailwindcss/oxide": {
+ "version": "4.1.17",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.17.tgz",
+ "integrity": "sha512-F0F7d01fmkQhsTjXezGBLdrl1KresJTcI3DB8EkScCldyKp3Msz4hub4uyYaVnk88BAS1g5DQjjF6F5qczheLA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 10"
+ },
+ "optionalDependencies": {
+ "@tailwindcss/oxide-android-arm64": "4.1.17",
+ "@tailwindcss/oxide-darwin-arm64": "4.1.17",
+ "@tailwindcss/oxide-darwin-x64": "4.1.17",
+ "@tailwindcss/oxide-freebsd-x64": "4.1.17",
+ "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.17",
+ "@tailwindcss/oxide-linux-arm64-gnu": "4.1.17",
+ "@tailwindcss/oxide-linux-arm64-musl": "4.1.17",
+ "@tailwindcss/oxide-linux-x64-gnu": "4.1.17",
+ "@tailwindcss/oxide-linux-x64-musl": "4.1.17",
+ "@tailwindcss/oxide-wasm32-wasi": "4.1.17",
+ "@tailwindcss/oxide-win32-arm64-msvc": "4.1.17",
+ "@tailwindcss/oxide-win32-x64-msvc": "4.1.17"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-android-arm64": {
+ "version": "4.1.17",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.17.tgz",
+ "integrity": "sha512-BMqpkJHgOZ5z78qqiGE6ZIRExyaHyuxjgrJ6eBO5+hfrfGkuya0lYfw8fRHG77gdTjWkNWEEm+qeG2cDMxArLQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-darwin-arm64": {
+ "version": "4.1.17",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.17.tgz",
+ "integrity": "sha512-EquyumkQweUBNk1zGEU/wfZo2qkp/nQKRZM8bUYO0J+Lums5+wl2CcG1f9BgAjn/u9pJzdYddHWBiFXJTcxmOg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-darwin-x64": {
+ "version": "4.1.17",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.17.tgz",
+ "integrity": "sha512-gdhEPLzke2Pog8s12oADwYu0IAw04Y2tlmgVzIN0+046ytcgx8uZmCzEg4VcQh+AHKiS7xaL8kGo/QTiNEGRog==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-freebsd-x64": {
+ "version": "4.1.17",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.17.tgz",
+ "integrity": "sha512-hxGS81KskMxML9DXsaXT1H0DyA+ZBIbyG/sSAjWNe2EDl7TkPOBI42GBV3u38itzGUOmFfCzk1iAjDXds8Oh0g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
+ "version": "4.1.17",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.17.tgz",
+ "integrity": "sha512-k7jWk5E3ldAdw0cNglhjSgv501u7yrMf8oeZ0cElhxU6Y2o7f8yqelOp3fhf7evjIS6ujTI3U8pKUXV2I4iXHQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
+ "version": "4.1.17",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.17.tgz",
+ "integrity": "sha512-HVDOm/mxK6+TbARwdW17WrgDYEGzmoYayrCgmLEw7FxTPLcp/glBisuyWkFz/jb7ZfiAXAXUACfyItn+nTgsdQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm64-musl": {
+ "version": "4.1.17",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.17.tgz",
+ "integrity": "sha512-HvZLfGr42i5anKtIeQzxdkw/wPqIbpeZqe7vd3V9vI3RQxe3xU1fLjss0TjyhxWcBaipk7NYwSrwTwK1hJARMg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-x64-gnu": {
+ "version": "4.1.17",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.17.tgz",
+ "integrity": "sha512-M3XZuORCGB7VPOEDH+nzpJ21XPvK5PyjlkSFkFziNHGLc5d6g3di2McAAblmaSUNl8IOmzYwLx9NsE7bplNkwQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-x64-musl": {
+ "version": "4.1.17",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.17.tgz",
+ "integrity": "sha512-k7f+pf9eXLEey4pBlw+8dgfJHY4PZ5qOUFDyNf7SI6lHjQ9Zt7+NcscjpwdCEbYi6FI5c2KDTDWyf2iHcCSyyQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi": {
+ "version": "4.1.17",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.17.tgz",
+ "integrity": "sha512-cEytGqSSoy7zK4JRWiTCx43FsKP/zGr0CsuMawhH67ONlH+T79VteQeJQRO/X7L0juEUA8ZyuYikcRBf0vsxhg==",
+ "bundleDependencies": [
+ "@napi-rs/wasm-runtime",
+ "@emnapi/core",
+ "@emnapi/runtime",
+ "@tybys/wasm-util",
+ "@emnapi/wasi-threads",
+ "tslib"
+ ],
+ "cpu": [
+ "wasm32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/core": "^1.6.0",
+ "@emnapi/runtime": "^1.6.0",
+ "@emnapi/wasi-threads": "^1.1.0",
+ "@napi-rs/wasm-runtime": "^1.0.7",
+ "@tybys/wasm-util": "^0.10.1",
+ "tslib": "^2.4.0"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
+ "version": "4.1.17",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.17.tgz",
+ "integrity": "sha512-JU5AHr7gKbZlOGvMdb4722/0aYbU+tN6lv1kONx0JK2cGsh7g148zVWLM0IKR3NeKLv+L90chBVYcJ8uJWbC9A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-win32-x64-msvc": {
+ "version": "4.1.17",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.17.tgz",
+ "integrity": "sha512-SKWM4waLuqx0IH+FMDUw6R66Hu4OuTALFgnleKbqhgGU30DY20NORZMZUKgLRjQXNN2TLzKvh48QXTig4h4bGw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/vite": {
+ "version": "4.1.17",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.17.tgz",
+ "integrity": "sha512-4+9w8ZHOiGnpcGI6z1TVVfWaX/koK7fKeSYF3qlYg2xpBtbteP2ddBxiarL+HVgfSJGeK5RIxRQmKm4rTJJAwA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@tailwindcss/node": "4.1.17",
+ "@tailwindcss/oxide": "4.1.17",
+ "tailwindcss": "4.1.17"
+ },
+ "peerDependencies": {
+ "vite": "^5.2.0 || ^6 || ^7"
+ }
+ },
+ "node_modules/@types/cookie": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
+ "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/d3": {
+ "version": "7.4.3",
+ "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz",
+ "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-array": "*",
+ "@types/d3-axis": "*",
+ "@types/d3-brush": "*",
+ "@types/d3-chord": "*",
+ "@types/d3-color": "*",
+ "@types/d3-contour": "*",
+ "@types/d3-delaunay": "*",
+ "@types/d3-dispatch": "*",
+ "@types/d3-drag": "*",
+ "@types/d3-dsv": "*",
+ "@types/d3-ease": "*",
+ "@types/d3-fetch": "*",
+ "@types/d3-force": "*",
+ "@types/d3-format": "*",
+ "@types/d3-geo": "*",
+ "@types/d3-hierarchy": "*",
+ "@types/d3-interpolate": "*",
+ "@types/d3-path": "*",
+ "@types/d3-polygon": "*",
+ "@types/d3-quadtree": "*",
+ "@types/d3-random": "*",
+ "@types/d3-scale": "*",
+ "@types/d3-scale-chromatic": "*",
+ "@types/d3-selection": "*",
+ "@types/d3-shape": "*",
+ "@types/d3-time": "*",
+ "@types/d3-time-format": "*",
+ "@types/d3-timer": "*",
+ "@types/d3-transition": "*",
+ "@types/d3-zoom": "*"
+ }
+ },
+ "node_modules/@types/d3-array": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
+ "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-axis": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz",
+ "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-selection": "*"
+ }
+ },
+ "node_modules/@types/d3-brush": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz",
+ "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-selection": "*"
+ }
+ },
+ "node_modules/@types/d3-chord": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz",
+ "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-color": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
+ "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-contour": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz",
+ "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-array": "*",
+ "@types/geojson": "*"
+ }
+ },
+ "node_modules/@types/d3-delaunay": {
+ "version": "6.0.4",
+ "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
+ "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-dispatch": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz",
+ "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-drag": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
+ "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-selection": "*"
+ }
+ },
+ "node_modules/@types/d3-dsv": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz",
+ "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-ease": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
+ "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-fetch": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz",
+ "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-dsv": "*"
+ }
+ },
+ "node_modules/@types/d3-force": {
+ "version": "3.0.10",
+ "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz",
+ "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-format": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz",
+ "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-geo": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz",
+ "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/geojson": "*"
+ }
+ },
+ "node_modules/@types/d3-hierarchy": {
+ "version": "3.1.7",
+ "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz",
+ "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-interpolate": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
+ "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-color": "*"
+ }
+ },
+ "node_modules/@types/d3-path": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
+ "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-polygon": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz",
+ "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-quadtree": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz",
+ "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-random": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz",
+ "integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-scale": {
+ "version": "4.0.9",
+ "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
+ "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-time": "*"
+ }
+ },
+ "node_modules/@types/d3-scale-chromatic": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
+ "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-selection": {
+ "version": "3.0.11",
+ "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
+ "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-shape": {
+ "version": "3.1.7",
+ "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz",
+ "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-path": "*"
+ }
+ },
+ "node_modules/@types/d3-time": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
+ "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-time-format": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz",
+ "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-timer": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
+ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-transition": {
+ "version": "3.0.9",
+ "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
+ "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-selection": "*"
+ }
+ },
+ "node_modules/@types/d3-zoom": {
+ "version": "3.0.8",
+ "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
+ "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-interpolate": "*",
+ "@types/d3-selection": "*"
+ }
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
+ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+ "license": "MIT"
+ },
+ "node_modules/@types/geojson": {
+ "version": "7946.0.16",
+ "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
+ "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/node": {
+ "version": "22.19.1",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.1.tgz",
+ "integrity": "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~6.21.0"
+ }
+ },
+ "node_modules/acorn": {
+ "version": "8.15.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
+ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
+ "license": "MIT",
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/aria-query": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
+ "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/axobject-query": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
+ "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/chokidar": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
+ "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "readdirp": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 14.16.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/clsx": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
+ "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/commander": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
+ "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/cookie": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
+ "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/d3": {
+ "version": "7.9.0",
+ "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz",
+ "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "d3-array": "3",
+ "d3-axis": "3",
+ "d3-brush": "3",
+ "d3-chord": "3",
+ "d3-color": "3",
+ "d3-contour": "4",
+ "d3-delaunay": "6",
+ "d3-dispatch": "3",
+ "d3-drag": "3",
+ "d3-dsv": "3",
+ "d3-ease": "3",
+ "d3-fetch": "3",
+ "d3-force": "3",
+ "d3-format": "3",
+ "d3-geo": "3",
+ "d3-hierarchy": "3",
+ "d3-interpolate": "3",
+ "d3-path": "3",
+ "d3-polygon": "3",
+ "d3-quadtree": "3",
+ "d3-random": "3",
+ "d3-scale": "4",
+ "d3-scale-chromatic": "3",
+ "d3-selection": "3",
+ "d3-shape": "3",
+ "d3-time": "3",
+ "d3-time-format": "4",
+ "d3-timer": "3",
+ "d3-transition": "3",
+ "d3-zoom": "3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-array": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
+ "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "internmap": "1 - 2"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-axis": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz",
+ "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-brush": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz",
+ "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "d3-dispatch": "1 - 3",
+ "d3-drag": "2 - 3",
+ "d3-interpolate": "1 - 3",
+ "d3-selection": "3",
+ "d3-transition": "3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-chord": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz",
+ "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "d3-path": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-color": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
+ "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-contour": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz",
+ "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "d3-array": "^3.2.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-delaunay": {
+ "version": "6.0.4",
+ "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
+ "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "delaunator": "5"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-dispatch": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
+ "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-drag": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
+ "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "d3-dispatch": "1 - 3",
+ "d3-selection": "3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-dsv": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz",
+ "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "commander": "7",
+ "iconv-lite": "0.6",
+ "rw": "1"
+ },
+ "bin": {
+ "csv2json": "bin/dsv2json.js",
+ "csv2tsv": "bin/dsv2dsv.js",
+ "dsv2dsv": "bin/dsv2dsv.js",
+ "dsv2json": "bin/dsv2json.js",
+ "json2csv": "bin/json2dsv.js",
+ "json2dsv": "bin/json2dsv.js",
+ "json2tsv": "bin/json2dsv.js",
+ "tsv2csv": "bin/dsv2dsv.js",
+ "tsv2json": "bin/dsv2json.js"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-ease": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
+ "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-fetch": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz",
+ "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "d3-dsv": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-force": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz",
+ "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "d3-dispatch": "1 - 3",
+ "d3-quadtree": "1 - 3",
+ "d3-timer": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-format": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
+ "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-geo": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz",
+ "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "d3-array": "2.5.0 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-hierarchy": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz",
+ "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-interpolate": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
+ "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "d3-color": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-path": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
+ "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-polygon": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz",
+ "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-quadtree": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz",
+ "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-random": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz",
+ "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-scale": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
+ "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "d3-array": "2.10.0 - 3",
+ "d3-format": "1 - 3",
+ "d3-interpolate": "1.2.0 - 3",
+ "d3-time": "2.1.1 - 3",
+ "d3-time-format": "2 - 4"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-scale-chromatic": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
+ "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "d3-color": "1 - 3",
+ "d3-interpolate": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-selection": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
+ "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-shape": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
+ "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "d3-path": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-time": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
+ "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "d3-array": "2 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-time-format": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
+ "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "d3-time": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-timer": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
+ "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-transition": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
+ "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "d3-color": "1 - 3",
+ "d3-dispatch": "1 - 3",
+ "d3-ease": "1 - 3",
+ "d3-interpolate": "1 - 3",
+ "d3-timer": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "peerDependencies": {
+ "d3-selection": "2 - 3"
+ }
+ },
+ "node_modules/d3-zoom": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
+ "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "d3-dispatch": "1 - 3",
+ "d3-drag": "2 - 3",
+ "d3-interpolate": "1 - 3",
+ "d3-selection": "2 - 3",
+ "d3-transition": "2 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/deepmerge": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
+ "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/delaunator": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz",
+ "integrity": "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "robust-predicates": "^3.0.2"
+ }
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/devalue": {
+ "version": "5.5.0",
+ "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.5.0.tgz",
+ "integrity": "sha512-69sM5yrHfFLJt0AZ9QqZXGCPfJ7fQjvpln3Rq5+PS03LD32Ost1Q9N+eEnaQwGRIriKkMImXD56ocjQmfjbV3w==",
+ "license": "MIT"
+ },
+ "node_modules/enhanced-resolve": {
+ "version": "5.18.3",
+ "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz",
+ "integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "graceful-fs": "^4.2.4",
+ "tapable": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
+ "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.25.12",
+ "@esbuild/android-arm": "0.25.12",
+ "@esbuild/android-arm64": "0.25.12",
+ "@esbuild/android-x64": "0.25.12",
+ "@esbuild/darwin-arm64": "0.25.12",
+ "@esbuild/darwin-x64": "0.25.12",
+ "@esbuild/freebsd-arm64": "0.25.12",
+ "@esbuild/freebsd-x64": "0.25.12",
+ "@esbuild/linux-arm": "0.25.12",
+ "@esbuild/linux-arm64": "0.25.12",
+ "@esbuild/linux-ia32": "0.25.12",
+ "@esbuild/linux-loong64": "0.25.12",
+ "@esbuild/linux-mips64el": "0.25.12",
+ "@esbuild/linux-ppc64": "0.25.12",
+ "@esbuild/linux-riscv64": "0.25.12",
+ "@esbuild/linux-s390x": "0.25.12",
+ "@esbuild/linux-x64": "0.25.12",
+ "@esbuild/netbsd-arm64": "0.25.12",
+ "@esbuild/netbsd-x64": "0.25.12",
+ "@esbuild/openbsd-arm64": "0.25.12",
+ "@esbuild/openbsd-x64": "0.25.12",
+ "@esbuild/openharmony-arm64": "0.25.12",
+ "@esbuild/sunos-x64": "0.25.12",
+ "@esbuild/win32-arm64": "0.25.12",
+ "@esbuild/win32-ia32": "0.25.12",
+ "@esbuild/win32-x64": "0.25.12"
+ }
+ },
+ "node_modules/esm-env": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
+ "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==",
+ "license": "MIT"
+ },
+ "node_modules/esrap": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.1.tgz",
+ "integrity": "sha512-GiYWG34AN/4CUyaWAgunGt0Rxvr1PTMlGC0vvEov/uOQYWne2bpN03Um+k8jT+q3op33mKouP2zeJ6OlM+qeUg==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.4.15"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/graceful-fs": {
+ "version": "4.2.11",
+ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
+ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/highlight.js": {
+ "version": "11.11.1",
+ "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz",
+ "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
+ "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/inline-style-parser": {
+ "version": "0.2.7",
+ "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz",
+ "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==",
+ "license": "MIT"
+ },
+ "node_modules/internmap": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
+ "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/is-reference": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
+ "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.6"
+ }
+ },
+ "node_modules/jiti": {
+ "version": "2.6.1",
+ "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
+ "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jiti": "lib/jiti-cli.mjs"
+ }
+ },
+ "node_modules/kleur": {
+ "version": "4.1.5",
+ "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
+ "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/lightningcss": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
+ "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.30.2",
+ "lightningcss-darwin-arm64": "1.30.2",
+ "lightningcss-darwin-x64": "1.30.2",
+ "lightningcss-freebsd-x64": "1.30.2",
+ "lightningcss-linux-arm-gnueabihf": "1.30.2",
+ "lightningcss-linux-arm64-gnu": "1.30.2",
+ "lightningcss-linux-arm64-musl": "1.30.2",
+ "lightningcss-linux-x64-gnu": "1.30.2",
+ "lightningcss-linux-x64-musl": "1.30.2",
+ "lightningcss-win32-arm64-msvc": "1.30.2",
+ "lightningcss-win32-x64-msvc": "1.30.2"
+ }
+ },
+ "node_modules/lightningcss-android-arm64": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz",
+ "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-arm64": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz",
+ "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-x64": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz",
+ "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-freebsd-x64": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz",
+ "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz",
+ "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz",
+ "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz",
+ "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz",
+ "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz",
+ "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz",
+ "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz",
+ "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/locate-character": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
+ "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
+ "license": "MIT"
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/mode-watcher": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/mode-watcher/-/mode-watcher-1.1.0.tgz",
+ "integrity": "sha512-mUT9RRGPDYenk59qJauN1rhsIMKBmWA3xMF+uRwE8MW/tjhaDSCCARqkSuDTq8vr4/2KcAxIGVjACxTjdk5C3g==",
+ "license": "MIT",
+ "dependencies": {
+ "runed": "^0.25.0",
+ "svelte-toolbelt": "^0.7.1"
+ },
+ "peerDependencies": {
+ "svelte": "^5.27.0"
+ }
+ },
+ "node_modules/mri": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
+ "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/mrmime": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
+ "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.11",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
+ "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
+ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.6",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
+ "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.11",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/readdirp": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
+ "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14.18.0"
+ },
+ "funding": {
+ "type": "individual",
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/robust-predicates": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz",
+ "integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==",
+ "dev": true,
+ "license": "Unlicense"
+ },
+ "node_modules/rollup": {
+ "version": "4.53.3",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz",
+ "integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.8"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.53.3",
+ "@rollup/rollup-android-arm64": "4.53.3",
+ "@rollup/rollup-darwin-arm64": "4.53.3",
+ "@rollup/rollup-darwin-x64": "4.53.3",
+ "@rollup/rollup-freebsd-arm64": "4.53.3",
+ "@rollup/rollup-freebsd-x64": "4.53.3",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.53.3",
+ "@rollup/rollup-linux-arm-musleabihf": "4.53.3",
+ "@rollup/rollup-linux-arm64-gnu": "4.53.3",
+ "@rollup/rollup-linux-arm64-musl": "4.53.3",
+ "@rollup/rollup-linux-loong64-gnu": "4.53.3",
+ "@rollup/rollup-linux-ppc64-gnu": "4.53.3",
+ "@rollup/rollup-linux-riscv64-gnu": "4.53.3",
+ "@rollup/rollup-linux-riscv64-musl": "4.53.3",
+ "@rollup/rollup-linux-s390x-gnu": "4.53.3",
+ "@rollup/rollup-linux-x64-gnu": "4.53.3",
+ "@rollup/rollup-linux-x64-musl": "4.53.3",
+ "@rollup/rollup-openharmony-arm64": "4.53.3",
+ "@rollup/rollup-win32-arm64-msvc": "4.53.3",
+ "@rollup/rollup-win32-ia32-msvc": "4.53.3",
+ "@rollup/rollup-win32-x64-gnu": "4.53.3",
+ "@rollup/rollup-win32-x64-msvc": "4.53.3",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/runed": {
+ "version": "0.25.0",
+ "resolved": "https://registry.npmjs.org/runed/-/runed-0.25.0.tgz",
+ "integrity": "sha512-7+ma4AG9FT2sWQEA0Egf6mb7PBT2vHyuHail1ie8ropfSjvZGtEAx8YTmUjv/APCsdRRxEVvArNjALk9zFSOrg==",
+ "funding": [
+ "https://github.com/sponsors/huntabyte",
+ "https://github.com/sponsors/tglide"
+ ],
+ "dependencies": {
+ "esm-env": "^1.0.0"
+ },
+ "peerDependencies": {
+ "svelte": "^5.7.0"
+ }
+ },
+ "node_modules/rw": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz",
+ "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==",
+ "dev": true,
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/sade": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz",
+ "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mri": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/set-cookie-parser": {
+ "version": "2.7.2",
+ "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
+ "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/sirv": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz",
+ "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@polka/url": "^1.0.0-next.24",
+ "mrmime": "^2.0.0",
+ "totalist": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/style-to-object": {
+ "version": "1.0.14",
+ "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz",
+ "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==",
+ "license": "MIT",
+ "dependencies": {
+ "inline-style-parser": "0.2.7"
+ }
+ },
+ "node_modules/svelte": {
+ "version": "5.45.3",
+ "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.45.3.tgz",
+ "integrity": "sha512-ngKXNhNvwPzF43QqEhDOue7TQTrG09em1sd4HBxVF0Wr2gopAmdEWan+rgbdgK4fhBtSOTJO8bYU4chUG7VXZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/remapping": "^2.3.4",
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@sveltejs/acorn-typescript": "^1.0.5",
+ "@types/estree": "^1.0.5",
+ "acorn": "^8.12.1",
+ "aria-query": "^5.3.1",
+ "axobject-query": "^4.1.0",
+ "clsx": "^2.1.1",
+ "devalue": "^5.5.0",
+ "esm-env": "^1.2.1",
+ "esrap": "^2.2.0",
+ "is-reference": "^3.0.3",
+ "locate-character": "^3.0.0",
+ "magic-string": "^0.30.11",
+ "zimmerframe": "^1.1.2"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/svelte-check": {
+ "version": "4.3.4",
+ "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.3.4.tgz",
+ "integrity": "sha512-DVWvxhBrDsd+0hHWKfjP99lsSXASeOhHJYyuKOFYJcP7ThfSCKgjVarE8XfuMWpS5JV3AlDf+iK1YGGo2TACdw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/trace-mapping": "^0.3.25",
+ "chokidar": "^4.0.1",
+ "fdir": "^6.2.0",
+ "picocolors": "^1.0.0",
+ "sade": "^1.7.4"
+ },
+ "bin": {
+ "svelte-check": "bin/svelte-check"
+ },
+ "engines": {
+ "node": ">= 18.0.0"
+ },
+ "peerDependencies": {
+ "svelte": "^4.0.0 || ^5.0.0-next.0",
+ "typescript": ">=5.0.0"
+ }
+ },
+ "node_modules/svelte-toolbelt": {
+ "version": "0.7.1",
+ "resolved": "https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.7.1.tgz",
+ "integrity": "sha512-HcBOcR17Vx9bjaOceUvxkY3nGmbBmCBBbuWLLEWO6jtmWH8f/QoWmbyUfQZrpDINH39en1b8mptfPQT9VKQ1xQ==",
+ "funding": [
+ "https://github.com/sponsors/huntabyte"
+ ],
+ "dependencies": {
+ "clsx": "^2.1.1",
+ "runed": "^0.23.2",
+ "style-to-object": "^1.0.8"
+ },
+ "engines": {
+ "node": ">=18",
+ "pnpm": ">=8.7.0"
+ },
+ "peerDependencies": {
+ "svelte": "^5.0.0"
+ }
+ },
+ "node_modules/svelte-toolbelt/node_modules/runed": {
+ "version": "0.23.4",
+ "resolved": "https://registry.npmjs.org/runed/-/runed-0.23.4.tgz",
+ "integrity": "sha512-9q8oUiBYeXIDLWNK5DfCWlkL0EW3oGbk845VdKlPeia28l751VpfesaB/+7pI6rnbx1I6rqoZ2fZxptOJLxILA==",
+ "funding": [
+ "https://github.com/sponsors/huntabyte",
+ "https://github.com/sponsors/tglide"
+ ],
+ "dependencies": {
+ "esm-env": "^1.0.0"
+ },
+ "peerDependencies": {
+ "svelte": "^5.7.0"
+ }
+ },
+ "node_modules/tailwindcss": {
+ "version": "4.1.17",
+ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.17.tgz",
+ "integrity": "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tapable": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",
+ "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.15",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
+ "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/totalist": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
+ "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/tw-animate-css": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz",
+ "integrity": "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/Wombosvideo"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "6.21.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/vite": {
+ "version": "6.4.1",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
+ "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.25.0",
+ "fdir": "^6.4.4",
+ "picomatch": "^4.0.2",
+ "postcss": "^8.5.3",
+ "rollup": "^4.34.9",
+ "tinyglobby": "^0.2.13"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
+ "jiti": ">=1.21.0",
+ "less": "*",
+ "lightningcss": "^1.21.0",
+ "sass": "*",
+ "sass-embedded": "*",
+ "stylus": "*",
+ "sugarss": "*",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vitefu": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.1.tgz",
+ "integrity": "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ==",
+ "dev": true,
+ "license": "MIT",
+ "workspaces": [
+ "tests/deps/*",
+ "tests/projects/*",
+ "tests/projects/workspace/packages/*"
+ ],
+ "peerDependencies": {
+ "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0"
+ },
+ "peerDependenciesMeta": {
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/zimmerframe": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
+ "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
+ "license": "MIT"
+ }
+ }
+}
diff --git a/dashboard/package.json b/dashboard/package.json
new file mode 100644
index 00000000..c9c27630
--- /dev/null
+++ b/dashboard/package.json
@@ -0,0 +1,33 @@
+{
+ "name": "exo-dashboard",
+ "private": true,
+ "version": "1.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite dev",
+ "build": "vite build",
+ "preview": "vite preview",
+ "prepare": "svelte-kit sync || echo ''",
+ "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
+ },
+ "devDependencies": {
+ "@sveltejs/adapter-static": "^3.0.10",
+ "@sveltejs/kit": "^2.48.4",
+ "@sveltejs/vite-plugin-svelte": "^5.0.0",
+ "@tailwindcss/vite": "^4.0.0",
+ "@types/d3": "^7.4.3",
+ "@types/node": "^22",
+ "d3": "^7.9.0",
+ "svelte": "^5.0.0",
+ "svelte-check": "^4.0.0",
+ "tailwindcss": "^4.0.0",
+ "tw-animate-css": "^1.3.5",
+ "typescript": "^5.0.0",
+ "vite": "^6.0.0"
+ },
+ "dependencies": {
+ "highlight.js": "^11.11.1",
+ "mode-watcher": "^1.1.0"
+ }
+}
+
diff --git a/dashboard/src/app.css b/dashboard/src/app.css
new file mode 100644
index 00000000..fc532578
--- /dev/null
+++ b/dashboard/src/app.css
@@ -0,0 +1,322 @@
+@import 'tailwindcss';
+@import 'tw-animate-css';
+
+@custom-variant dark (&:is(.dark *));
+
+:root {
+ /* EXO Brand Colors - Command Center Theme (neutral dark greys) */
+ --exo-black: oklch(0.12 0 0);
+ --exo-dark-gray: oklch(0.16 0 0);
+ --exo-medium-gray: oklch(0.22 0 0);
+ --exo-light-gray: oklch(0.6 0 0);
+ --exo-yellow: oklch(0.85 0.18 85);
+ --exo-yellow-darker: oklch(0.7 0.16 85);
+ --exo-yellow-glow: oklch(0.9 0.2 85);
+
+ /* Gotham-inspired accent colors */
+ --exo-grid: oklch(0.25 0 0);
+ --exo-scanline: oklch(0.15 0 0);
+ --exo-glow-yellow: 0 0 20px oklch(0.85 0.18 85 / 0.3);
+ --exo-glow-yellow-strong: 0 0 40px oklch(0.85 0.18 85 / 0.5);
+
+ /* Theme Variables */
+ --radius: 0.375rem;
+ --background: var(--exo-black);
+ --foreground: oklch(0.9 0 0);
+ --card: var(--exo-dark-gray);
+ --card-foreground: oklch(0.9 0 0);
+ --popover: var(--exo-dark-gray);
+ --popover-foreground: oklch(0.9 0 0);
+ --primary: var(--exo-yellow);
+ --primary-foreground: var(--exo-black);
+ --secondary: var(--exo-medium-gray);
+ --secondary-foreground: oklch(0.9 0 0);
+ --muted: var(--exo-medium-gray);
+ --muted-foreground: var(--exo-light-gray);
+ --accent: var(--exo-medium-gray);
+ --accent-foreground: oklch(0.9 0 0);
+ --destructive: oklch(0.6 0.25 25);
+ --border: oklch(0.22 0 0);
+ --input: oklch(0.22 0 0);
+ --ring: var(--exo-yellow);
+}
+
+@theme inline {
+ --radius-sm: calc(var(--radius) - 2px);
+ --radius-md: var(--radius);
+ --radius-lg: calc(var(--radius) + 2px);
+ --radius-xl: calc(var(--radius) + 4px);
+ --color-background: var(--background);
+ --color-foreground: var(--foreground);
+ --color-card: var(--card);
+ --color-card-foreground: var(--card-foreground);
+ --color-popover: var(--popover);
+ --color-popover-foreground: var(--popover-foreground);
+ --color-primary: var(--primary);
+ --color-primary-foreground: var(--primary-foreground);
+ --color-secondary: var(--secondary);
+ --color-secondary-foreground: var(--secondary-foreground);
+ --color-muted: var(--muted);
+ --color-muted-foreground: var(--muted-foreground);
+ --color-accent: var(--accent);
+ --color-accent-foreground: var(--accent-foreground);
+ --color-destructive: var(--destructive);
+ --color-border: var(--border);
+ --color-input: var(--input);
+ --color-ring: var(--ring);
+
+ /* Custom EXO colors */
+ --color-exo-yellow: var(--exo-yellow);
+ --color-exo-yellow-darker: var(--exo-yellow-darker);
+ --color-exo-black: var(--exo-black);
+ --color-exo-dark-gray: var(--exo-dark-gray);
+ --color-exo-medium-gray: var(--exo-medium-gray);
+ --color-exo-light-gray: var(--exo-light-gray);
+}
+
+@layer base {
+ * {
+ @apply border-border outline-ring/50;
+ }
+ html, body {
+ @apply bg-background text-foreground;
+ font-family: 'SF Mono', 'Fira Code', 'Monaco', 'Consolas', 'Liberation Mono', monospace;
+ letter-spacing: 0.02em;
+ }
+}
+
+@layer utilities {
+ .scrollbar-hide {
+ &::-webkit-scrollbar {
+ display: none;
+ }
+ -ms-overflow-style: none;
+ scrollbar-width: none;
+ }
+
+ /* CRT Scanline effect */
+ .scanlines {
+ position: relative;
+ &::before {
+ content: '';
+ position: absolute;
+ inset: 0;
+ background: repeating-linear-gradient(
+ 0deg,
+ transparent,
+ transparent 2px,
+ oklch(0 0 0 / 0.03) 2px,
+ oklch(0 0 0 / 0.03) 4px
+ );
+ pointer-events: none;
+ z-index: 100;
+ }
+ }
+
+ /* Command panel styling */
+ .command-panel {
+ background: linear-gradient(
+ 180deg,
+ oklch(0.16 0 0 / 0.95) 0%,
+ oklch(0.12 0 0 / 0.98) 100%
+ );
+ border: 1px solid oklch(0.25 0 0);
+ box-shadow:
+ inset 0 1px 0 oklch(1 0 0 / 0.03),
+ 0 4px 20px oklch(0 0 0 / 0.5);
+ }
+
+ /* Glow text */
+ .glow-text {
+ text-shadow:
+ 0 0 10px oklch(0.85 0.18 85 / 0.5),
+ 0 0 20px oklch(0.85 0.18 85 / 0.3),
+ 0 0 40px oklch(0.85 0.18 85 / 0.1);
+ }
+
+ /* Status indicator pulse */
+ .status-pulse {
+ animation: statusPulse 2s ease-in-out infinite;
+ }
+
+ /* Grid background */
+ .grid-bg {
+ background-image:
+ linear-gradient(oklch(0.2 0 0 / 0.3) 1px, transparent 1px),
+ linear-gradient(90deg, oklch(0.2 0 0 / 0.3) 1px, transparent 1px);
+ background-size: 40px 40px;
+ }
+}
+
+/* Animations */
+@keyframes flowAnimation {
+ from {
+ stroke-dashoffset: 0;
+ }
+ to {
+ stroke-dashoffset: -16;
+ }
+}
+
+@keyframes statusPulse {
+ 0%, 100% {
+ opacity: 1;
+ }
+ 50% {
+ opacity: 0.5;
+ }
+}
+
+@keyframes radarSweep {
+ from {
+ transform: rotate(0deg);
+ }
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+@keyframes glowPulse {
+ 0%, 100% {
+ box-shadow: 0 0 5px oklch(0.85 0.18 85 / 0.3), 0 0 10px oklch(0.85 0.18 85 / 0.1);
+ }
+ 50% {
+ box-shadow: 0 0 15px oklch(0.85 0.18 85 / 0.5), 0 0 30px oklch(0.85 0.18 85 / 0.2);
+ }
+}
+
+@keyframes dataPulse {
+ 0%, 100% {
+ opacity: 0.6;
+ }
+ 50% {
+ opacity: 1;
+ }
+}
+
+.graph-link {
+ stroke: oklch(0.85 0.18 85 / 0.4);
+ stroke-width: 1.5px;
+ stroke-dasharray: 8, 8;
+ animation: flowAnimation 1s linear infinite;
+ filter: drop-shadow(0 0 3px oklch(0.85 0.18 85 / 0.5));
+}
+
+.graph-link-active {
+ stroke: oklch(0.85 0.18 85 / 0.8);
+ stroke-width: 2px;
+ filter: drop-shadow(0 0 6px oklch(0.85 0.18 85 / 0.8));
+}
+
+/* CRT Screen effect for topology */
+.crt-screen {
+ position: relative;
+ border-radius: 50%;
+ background: radial-gradient(
+ ellipse at center,
+ oklch(0.16 0 0) 0%,
+ oklch(0.12 0 0) 50%,
+ oklch(0.09 0 0) 100%
+ );
+ box-shadow:
+ inset 0 0 100px oklch(0 0 0 / 0.5),
+ 0 0 50px oklch(0.85 0.18 85 / 0.1);
+}
+
+/* Data readout styling */
+.data-readout {
+ font-family: 'SF Mono', 'Fira Code', monospace;
+ font-size: 11px;
+ letter-spacing: 0.05em;
+ text-transform: uppercase;
+}
+
+/* Terminal cursor blink */
+.cursor-blink {
+ animation: cursorBlink 1s step-end infinite;
+}
+
+@keyframes cursorBlink {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0; }
+}
+
+/* Custom scrollbar for command center */
+::-webkit-scrollbar {
+ width: 6px;
+ height: 6px;
+}
+
+::-webkit-scrollbar-track {
+ background: oklch(0.1 0 0);
+}
+
+::-webkit-scrollbar-thumb {
+ background: oklch(0.3 0 0);
+ border-radius: 3px;
+}
+
+::-webkit-scrollbar-thumb:hover {
+ background: oklch(0.85 0.18 85 / 0.5);
+}
+
+/* Remove focus outline/border for inputs */
+input:focus, textarea:focus {
+ outline: none;
+ box-shadow: none;
+}
+
+/* Shooting Stars Animation */
+.shooting-stars {
+ position: fixed;
+ inset: 0;
+ overflow: hidden;
+ pointer-events: none;
+ z-index: 0;
+}
+
+.shooting-star {
+ position: absolute;
+ width: 3px;
+ height: 3px;
+ background: oklch(0.85 0.18 85 / 1);
+ border-radius: 50%;
+ box-shadow: 0 0 6px oklch(0.85 0.18 85 / 0.8);
+ animation: shootingStar var(--duration, 3s) linear infinite;
+ animation-delay: var(--delay, 0s);
+ opacity: 0;
+}
+
+.shooting-star::before {
+ content: '';
+ position: absolute;
+ width: 80px;
+ height: 2px;
+ background: linear-gradient(90deg, oklch(0.85 0.18 85 / 0), oklch(0.85 0.18 85 / 0.6));
+ transform: rotate(45deg);
+ transform-origin: right center;
+ top: 0;
+ right: 2px;
+}
+
+@keyframes shootingStar {
+ 0% {
+ opacity: 0;
+ transform: translate(0, 0);
+ }
+ 0.5% {
+ opacity: 1;
+ }
+ 2.5% {
+ opacity: 0.8;
+ transform: translate(300px, 300px);
+ }
+ 3.5% {
+ opacity: 0;
+ transform: translate(400px, 400px);
+ }
+ 100% {
+ opacity: 0;
+ transform: translate(400px, 400px);
+ }
+}
diff --git a/dashboard/src/app.d.ts b/dashboard/src/app.d.ts
new file mode 100644
index 00000000..b111beb0
--- /dev/null
+++ b/dashboard/src/app.d.ts
@@ -0,0 +1,14 @@
+// See https://svelte.dev/docs/kit/types#app.d.ts
+// for information about these interfaces
+declare global {
+ namespace App {
+ // interface Error {}
+ // interface Locals {}
+ // interface PageData {}
+ // interface PageState {}
+ // interface Platform {}
+ }
+}
+
+export {};
+
diff --git a/dashboard/src/app.html b/dashboard/src/app.html
new file mode 100644
index 00000000..a974a968
--- /dev/null
+++ b/dashboard/src/app.html
@@ -0,0 +1,14 @@
+<!doctype html>
+<html lang="en">
+ <head>
+ <meta charset="utf-8" />
+ <link rel="icon" href="%sveltekit.assets%/favicon.ico" />
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
+ <title>EXO</title>
+ %sveltekit.head%
+ </head>
+ <body data-sveltekit-preload-data="hover">
+ <div style="display: contents">%sveltekit.body%</div>
+ </body>
+</html>
+
diff --git a/dashboard/src/lib/components/ChatAttachments.svelte b/dashboard/src/lib/components/ChatAttachments.svelte
new file mode 100644
index 00000000..f56e23e3
--- /dev/null
+++ b/dashboard/src/lib/components/ChatAttachments.svelte
@@ -0,0 +1,75 @@
+<script lang="ts">
+ import type { ChatUploadedFile } from '$lib/types/files';
+ import { formatFileSize, getFileCategory } from '$lib/types/files';
+
+ interface Props {
+ files: ChatUploadedFile[];
+ readonly?: boolean;
+ onRemove?: (fileId: string) => void;
+ }
+
+ let { files, readonly = false, onRemove }: Props = $props();
+
+ function getFileIcon(file: ChatUploadedFile): string {
+ const category = getFileCategory(file.type, file.name);
+ switch (category) {
+ case 'image': return '🖼';
+ case 'text': return '📄';
+ case 'pdf': return '📑';
+ case 'audio': return '🎵';
+ default: return '📎';
+ }
+ }
+
+ function truncateName(name: string, maxLen: number = 20): string {
+ if (name.length <= maxLen) return name;
+ const ext = name.slice(name.lastIndexOf('.'));
+ const base = name.slice(0, name.lastIndexOf('.'));
+ const available = maxLen - ext.length - 3;
+ return base.slice(0, available) + '...' + ext;
+ }
+</script>
+
+{#if files.length > 0}
+ <div class="flex flex-wrap gap-2 mb-3 px-1">
+ {#each files as file (file.id)}
+ <div class="group relative flex items-center gap-2 bg-exo-dark-gray/80 border border-exo-yellow/30 rounded px-2.5 py-1.5 text-xs font-mono transition-all hover:border-exo-yellow/50 hover:shadow-[0_0_10px_rgba(255,215,0,0.1)]">
+ <!-- File preview or icon -->
+ {#if file.preview && getFileCategory(file.type, file.name) === 'image'}
+ <img
+ src={file.preview}
+ alt={file.name}
+ class="w-8 h-8 object-cover rounded border border-exo-yellow/20"
+ />
+ {:else}
+ <span class="text-base">{getFileIcon(file)}</span>
+ {/if}
+
+ <!-- File info -->
+ <div class="flex flex-col min-w-0">
+ <span class="text-exo-yellow truncate max-w-[120px]" title={file.name}>
+ {truncateName(file.name)}
+ </span>
+ <span class="text-exo-light-gray text-xs">
+ {formatFileSize(file.size)}
+ </span>
+ </div>
+
+ <!-- Remove button -->
+ {#if !readonly && onRemove}
+ <button
+ type="button"
+ onclick={() => onRemove?.(file.id)}
+ class="ml-1 w-4 h-4 flex items-center justify-center text-exo-light-gray hover:text-red-400 transition-colors cursor-pointer"
+ title="Remove file"
+ >
+ <svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
+ </svg>
+ </button>
+ {/if}
+ </div>
+ {/each}
+ </div>
+{/if}
+
diff --git a/dashboard/src/lib/components/ChatForm.svelte b/dashboard/src/lib/components/ChatForm.svelte
new file mode 100644
index 00000000..95d023c3
--- /dev/null
+++ b/dashboard/src/lib/components/ChatForm.svelte
@@ -0,0 +1,398 @@
+<script lang="ts">
+ import { isLoading, sendMessage, selectedChatModel, setSelectedChatModel, instances, ttftMs, tps, totalTokens } from '$lib/stores/app.svelte';
+ import ChatAttachments from './ChatAttachments.svelte';
+ import type { ChatUploadedFile } from '$lib/types/files';
+ import { processUploadedFiles, getAcceptString } from '$lib/types/files';
+
+ interface Props {
+ class?: string;
+ placeholder?: string;
+ showHelperText?: boolean;
+ autofocus?: boolean;
+ showModelSelector?: boolean;
+ }
+
+ let {
+ class: className = '',
+ placeholder = 'Ask anything',
+ showHelperText = false,
+ autofocus = true,
+ showModelSelector = false
+ }: Props = $props();
+
+ let message = $state('');
+ let textareaRef: HTMLTextAreaElement | undefined = $state();
+ let fileInputRef: HTMLInputElement | undefined = $state();
+ let uploadedFiles = $state<ChatUploadedFile[]>([]);
+ let isDragOver = $state(false);
+ let loading = $derived(isLoading());
+ const currentModel = $derived(selectedChatModel());
+ const instanceData = $derived(instances());
+ const currentTtft = $derived(ttftMs());
+ const currentTps = $derived(tps());
+ const currentTokens = $derived(totalTokens());
+
+ // Custom dropdown state
+ let isModelDropdownOpen = $state(false);
+ let dropdownButtonRef: HTMLButtonElement | undefined = $state();
+ let dropdownPosition = $derived(() => {
+ if (!dropdownButtonRef || !isModelDropdownOpen) return { top: 0, left: 0, width: 0 };
+ const rect = dropdownButtonRef.getBoundingClientRect();
+ return {
+ top: rect.top,
+ left: rect.left,
+ width: rect.width
+ };
+ });
+
+ // Accept all supported file types
+ const acceptString = getAcceptString(['image', 'text', 'pdf']);
+
+ // Extract available models from running instances
+ const availableModels = $derived(() => {
+ const models: Array<{id: string, label: string}> = [];
+ for (const [, instance] of Object.entries(instanceData)) {
+ const modelId = getInstanceModelId(instance);
+ if (modelId && modelId !== 'Unknown' && !models.some(m => m.id === modelId)) {
+ models.push({ id: modelId, label: modelId.split('/').pop() || modelId });
+ }
+ }
+ return models;
+ });
+
+ // Auto-select the first available model if none is selected
+ $effect(() => {
+ const models = availableModels();
+ if (models.length > 0 && !currentModel) {
+ setSelectedChatModel(models[0].id);
+ }
+ });
+
+ function getInstanceModelId(instanceWrapped: unknown): string {
+ if (!instanceWrapped || typeof instanceWrapped !== 'object') return '';
+ const keys = Object.keys(instanceWrapped as Record<string, unknown>);
+ if (keys.length === 1) {
+ const instance = (instanceWrapped as Record<string, unknown>)[keys[0]] as { shardAssignments?: { modelId?: string } };
+ return instance?.shardAssignments?.modelId || '';
+ }
+ return '';
+ }
+
+ async function handleFiles(files: File[]) {
+ if (files.length === 0) return;
+ const processed = await processUploadedFiles(files);
+ uploadedFiles = [...uploadedFiles, ...processed];
+ }
+
+ function handleFileInputChange(event: Event) {
+ const input = event.target as HTMLInputElement;
+ if (input.files && input.files.length > 0) {
+ handleFiles(Array.from(input.files));
+ input.value = ''; // Reset for next selection
+ }
+ }
+
+ function handleFileRemove(fileId: string) {
+ uploadedFiles = uploadedFiles.filter(f => f.id !== fileId);
+ }
+
+ function handlePaste(event: ClipboardEvent) {
+ if (!event.clipboardData) return;
+
+ const files = Array.from(event.clipboardData.items)
+ .filter(item => item.kind === 'file')
+ .map(item => item.getAsFile())
+ .filter((file): file is File => file !== null);
+
+ if (files.length > 0) {
+ event.preventDefault();
+ handleFiles(files);
+ return;
+ }
+
+ // Handle long text paste as file
+ const text = event.clipboardData.getData('text/plain');
+ if (text.length > 2500) {
+ event.preventDefault();
+ const textFile = new File([text], 'pasted-text.txt', { type: 'text/plain' });
+ handleFiles([textFile]);
+ }
+ }
+
+ function handleDragOver(event: DragEvent) {
+ event.preventDefault();
+ isDragOver = true;
+ }
+
+ function handleDragLeave(event: DragEvent) {
+ event.preventDefault();
+ isDragOver = false;
+ }
+
+ function handleDrop(event: DragEvent) {
+ event.preventDefault();
+ isDragOver = false;
+
+ if (event.dataTransfer?.files) {
+ handleFiles(Array.from(event.dataTransfer.files));
+ }
+ }
+
+ function handleKeydown(event: KeyboardEvent) {
+ if (event.key === 'Enter' && !event.shiftKey) {
+ event.preventDefault();
+ handleSubmit();
+ }
+ }
+
+ function handleSubmit() {
+ if ((!message.trim() && uploadedFiles.length === 0) || loading) return;
+
+ const content = message.trim();
+ const files = [...uploadedFiles];
+
+ message = '';
+ uploadedFiles = [];
+ resetTextareaHeight();
+
+ sendMessage(content, files);
+
+ // Refocus the textarea after sending
+ setTimeout(() => textareaRef?.focus(), 10);
+ }
+
+ function handleInput() {
+ if (!textareaRef) return;
+ textareaRef.style.height = 'auto';
+ textareaRef.style.height = Math.min(textareaRef.scrollHeight, 150) + 'px';
+ }
+
+ function resetTextareaHeight() {
+ if (textareaRef) {
+ textareaRef.style.height = 'auto';
+ }
+ }
+
+ function openFilePicker() {
+ fileInputRef?.click();
+ }
+
+ // Track previous loading state to detect when loading completes
+ let wasLoading = $state(false);
+
+ $effect(() => {
+ if (autofocus && textareaRef) {
+ setTimeout(() => textareaRef?.focus(), 10);
+ }
+ });
+
+ // Refocus after loading completes (AI response finished)
+ $effect(() => {
+ if (wasLoading && !loading && textareaRef) {
+ setTimeout(() => textareaRef?.focus(), 50);
+ }
+ wasLoading = loading;
+ });
+
+ const canSend = $derived(message.trim().length > 0 || uploadedFiles.length > 0);
+</script>
+
+<!-- Hidden file input -->
+<input
+ bind:this={fileInputRef}
+ type="file"
+ accept={acceptString}
+ multiple
+ class="hidden"
+ onchange={handleFileInputChange}
+/>
+
+<form
+ onsubmit={(e) => { e.preventDefault(); handleSubmit(); }}
+ class="w-full {className}"
+ ondragover={handleDragOver}
+ ondragleave={handleDragLeave}
+ ondrop={handleDrop}
+>
+ <div
+ class="relative command-panel rounded overflow-hidden transition-all duration-200 {isDragOver ? 'ring-2 ring-exo-yellow ring-opacity-50' : ''}"
+ >
+ <!-- Top accent line -->
+ <div class="absolute top-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-exo-yellow/50 to-transparent"></div>
+
+ <!-- Drag overlay -->
+ {#if isDragOver}
+ <div class="absolute inset-0 bg-exo-dark-gray/80 z-10 flex items-center justify-center">
+ <div class="text-exo-yellow text-sm font-mono tracking-wider uppercase">
+ DROP FILES HERE
+ </div>
+ </div>
+ {/if}
+
+ <!-- Model selector (when enabled) -->
+ {#if showModelSelector && availableModels().length > 0}
+ <div class="flex items-center justify-between gap-2 px-3 py-2 border-b border-exo-medium-gray/30">
+ <div class="flex items-center gap-2 flex-1">
+ <span class="text-xs text-exo-light-gray uppercase tracking-wider flex-shrink-0">MODEL:</span>
+ <!-- Custom dropdown -->
+ <div class="relative flex-1 max-w-xs">
+ <button
+ bind:this={dropdownButtonRef}
+ type="button"
+ onclick={() => isModelDropdownOpen = !isModelDropdownOpen}
+ class="w-full bg-exo-medium-gray/50 border border-exo-yellow/30 rounded pl-3 pr-8 py-1.5 text-xs font-mono text-left tracking-wide cursor-pointer transition-all duration-200 hover:border-exo-yellow/50 focus:outline-none focus:border-exo-yellow/70 {isModelDropdownOpen ? 'border-exo-yellow/70' : ''}"
+ >
+ {#if availableModels().find(m => m.id === currentModel)}
+ <span class="text-exo-yellow truncate">{availableModels().find(m => m.id === currentModel)?.label}</span>
+ {:else if availableModels().length > 0}
+ <span class="text-exo-yellow truncate">{availableModels()[0].label}</span>
+ {:else}
+ <span class="text-exo-light-gray/50">— SELECT MODEL —</span>
+ {/if}
+ </button>
+ <div class="absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none transition-transform duration-200 {isModelDropdownOpen ? 'rotate-180' : ''}">
+ <svg class="w-3 h-3 text-exo-yellow/60" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
+ </svg>
+ </div>
+ </div>
+
+ {#if isModelDropdownOpen}
+ <!-- Backdrop to close dropdown -->
+ <button
+ type="button"
+ class="fixed inset-0 z-[9998] cursor-default"
+ onclick={() => isModelDropdownOpen = false}
+ aria-label="Close dropdown"
+ ></button>
+
+ <!-- Dropdown Panel - fixed positioning to escape overflow:hidden -->
+ <div
+ class="fixed bg-exo-dark-gray border border-exo-yellow/30 rounded shadow-lg shadow-black/50 z-[9999] max-h-48 overflow-y-auto"
+ style="bottom: calc(100vh - {dropdownPosition().top}px + 4px); left: {dropdownPosition().left}px; width: {dropdownPosition().width}px;"
+ >
+ <div class="py-1">
+ {#each availableModels() as model}
+ <button
+ type="button"
+ onclick={() => {
+ setSelectedChatModel(model.id);
+ isModelDropdownOpen = false;
+ }}
+ class="w-full px-3 py-2 text-left text-xs font-mono tracking-wide transition-colors duration-100 flex items-center gap-2 {
+ currentModel === model.id
+ ? 'bg-transparent text-exo-yellow'
+ : 'text-exo-light-gray hover:text-exo-yellow'
+ }"
+ >
+ {#if currentModel === model.id}
+ <svg class="w-3 h-3 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
+ <path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
+ </svg>
+ {:else}
+ <span class="w-3"></span>
+ {/if}
+ <span class="truncate">{model.label}</span>
+ </button>
+ {/each}
+ </div>
+ </div>
+ {/if}
+ </div>
+ <!-- Performance stats -->
+ {#if currentTtft !== null || currentTps !== null}
+ <div class="flex items-center gap-4 text-xs font-mono flex-shrink-0">
+ {#if currentTtft !== null}
+ <span class="text-exo-light-gray">
+ <span class="text-white/70">TTFT</span> <span class="text-exo-yellow">{currentTtft.toFixed(1)}ms</span>
+ </span>
+ {/if}
+ {#if currentTps !== null}
+ <span class="text-exo-light-gray">
+ <span class="text-white/70">TPS</span> <span class="text-exo-yellow">{currentTps.toFixed(1)}</span> <span class="text-white/60">tok/s</span>
+ <span class="text-white/50">({(1000 / currentTps).toFixed(1)} ms/tok)</span>
+ </span>
+ {/if}
+ </div>
+ {/if}
+ </div>
+ {/if}
+
+ <!-- Attached files preview -->
+ {#if uploadedFiles.length > 0}
+ <div class="px-3 pt-3">
+ <ChatAttachments
+ files={uploadedFiles}
+ onRemove={handleFileRemove}
+ />
+ </div>
+ {/if}
+
+ <!-- Input area -->
+ <div class="flex items-start gap-2 sm:gap-3 py-3 px-3 sm:px-4">
+ <!-- Attach file button -->
+ <button
+ type="button"
+ onclick={openFilePicker}
+ disabled={loading}
+ class="flex items-center justify-center w-7 h-7 rounded text-exo-light-gray hover:text-exo-yellow transition-all disabled:opacity-50 disabled:cursor-not-allowed flex-shrink-0 cursor-pointer"
+ title="Attach file"
+ >
+ <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13" />
+ </svg>
+ </button>
+
+ <!-- Terminal prompt -->
+ <span class="text-exo-yellow text-sm font-bold flex-shrink-0 leading-7">▶</span>
+
+ <textarea
+ bind:this={textareaRef}
+ bind:value={message}
+ onkeydown={handleKeydown}
+ oninput={handleInput}
+ onpaste={handlePaste}
+ {placeholder}
+ disabled={loading}
+ rows={1}
+ class="flex-1 resize-none bg-transparent text-foreground placeholder:text-exo-light-gray/60 placeholder:text-sm placeholder:tracking-[0.15em] placeholder:leading-7 focus:outline-none focus:ring-0 focus:border-none disabled:opacity-50 text-sm leading-7 font-mono"
+ style="min-height: 28px; max-height: 150px;"
+ ></textarea>
+
+ <button
+ type="submit"
+ disabled={!canSend || loading}
+ class="px-2.5 sm:px-4 py-1.5 sm:py-2 rounded text-xs sm:text-xs tracking-[0.1em] sm:tracking-[0.15em] uppercase font-medium transition-all duration-200 whitespace-nowrap
+ {!canSend || loading
+ ? 'bg-exo-medium-gray/50 text-exo-light-gray cursor-not-allowed'
+ : 'bg-exo-yellow text-exo-black hover:bg-exo-yellow-darker hover:shadow-[0_0_20px_rgba(255,215,0,0.3)]'}"
+ aria-label="Send message"
+ >
+ {#if loading}
+ <span class="inline-flex items-center gap-1 sm:gap-2">
+ <span class="w-2.5 h-2.5 sm:w-3 sm:h-3 border-2 border-current border-t-transparent rounded-full animate-spin"></span>
+ <span class="hidden sm:inline">PROCESSING</span>
+ <span class="sm:hidden">...</span>
+ </span>
+ {:else}
+ SEND
+ {/if}
+ </button>
+ </div>
+
+ <!-- Bottom accent line -->
+ <div class="absolute bottom-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-exo-yellow/30 to-transparent"></div>
+ </div>
+
+ {#if showHelperText}
+ <p class="mt-2 sm:mt-3 text-center text-xs sm:text-xs text-exo-light-gray tracking-[0.1em] sm:tracking-[0.15em] uppercase">
+ <kbd class="px-1 sm:px-1.5 py-0.5 rounded bg-exo-medium-gray/30 text-exo-light-gray border border-exo-medium-gray/50">ENTER</kbd>
+ <span class="mx-0.5 sm:mx-1">TO SEND</span>
+ <span class="text-exo-medium-gray mx-1 sm:mx-2">|</span>
+ <kbd class="px-1 sm:px-1.5 py-0.5 rounded bg-exo-medium-gray/30 text-exo-light-gray border border-exo-medium-gray/50">SHIFT+ENTER</kbd>
+ <span class="mx-0.5 sm:mx-1">NEW LINE</span>
+ <span class="text-exo-medium-gray mx-1 sm:mx-2">|</span>
+ <span class="text-exo-light-gray">DRAG & DROP OR PASTE FILES</span>
+ </p>
+ {/if}
+</form>
diff --git a/dashboard/src/lib/components/ChatMessages.svelte b/dashboard/src/lib/components/ChatMessages.svelte
new file mode 100644
index 00000000..baaf43f7
--- /dev/null
+++ b/dashboard/src/lib/components/ChatMessages.svelte
@@ -0,0 +1,462 @@
+<script lang="ts">
+ import {
+ messages,
+ currentResponse,
+ isLoading,
+ deleteMessage,
+ editAndRegenerate,
+ regenerateLastResponse
+ } from '$lib/stores/app.svelte';
+ import type { MessageAttachment } from '$lib/stores/app.svelte';
+import { tick, onDestroy } from 'svelte';
+
+interface Props {
+ class?: string;
+ scrollParent?: HTMLElement | null;
+}
+
+let { class: className = '', scrollParent = null }: Props = $props();
+
+ const messageList = $derived(messages());
+ const response = $derived(currentResponse());
+ const loading = $derived(isLoading());
+
+// Ref for scroll anchor at bottom
+let scrollAnchorRef: HTMLDivElement | undefined = $state();
+
+// Scroll management
+const SCROLL_BOTTOM_THRESHOLD = 120;
+let autoScrollEnabled = true;
+let currentScrollEl: HTMLElement | null = null;
+
+function resolveScrollElement(): HTMLElement | null {
+ if (scrollParent) return scrollParent;
+ let node: HTMLElement | null = scrollAnchorRef?.parentElement as HTMLElement | null;
+ while (node) {
+ const isScrollable = node.scrollHeight > node.clientHeight + 1;
+ if (isScrollable) return node;
+ node = node.parentElement;
+ }
+ return null;
+}
+
+function handleScroll() {
+ if (!currentScrollEl) return;
+ const distanceFromBottom = currentScrollEl.scrollHeight - currentScrollEl.scrollTop - currentScrollEl.clientHeight;
+ const isNearBottom = distanceFromBottom < SCROLL_BOTTOM_THRESHOLD;
+ autoScrollEnabled = isNearBottom;
+}
+
+function attachScrollListener() {
+ const nextEl = resolveScrollElement();
+ if (currentScrollEl === nextEl) return;
+ if (currentScrollEl) {
+ currentScrollEl.removeEventListener('scroll', handleScroll);
+ }
+ currentScrollEl = nextEl;
+ if (currentScrollEl) {
+ currentScrollEl.addEventListener('scroll', handleScroll);
+ // Initialize state based on current position
+ handleScroll();
+ }
+}
+
+onDestroy(() => {
+ if (currentScrollEl) {
+ currentScrollEl.removeEventListener('scroll', handleScroll);
+ }
+});
+
+$effect(() => {
+ // Re-evaluate scroll container if prop changes or after mount
+ scrollParent;
+ attachScrollListener();
+});
+
+// Auto-scroll to bottom when messages change or response updates, but only if user is near bottom
+$effect(() => {
+ // Track these values to trigger effect
+ const _ = messageList.length;
+ const __ = response;
+ const ___ = loading;
+
+ tick().then(() => {
+ const el = currentScrollEl ?? resolveScrollElement();
+ if (!el || !scrollAnchorRef) return;
+ const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
+ const isNearBottom = distanceFromBottom < SCROLL_BOTTOM_THRESHOLD;
+ if (autoScrollEnabled || isNearBottom) {
+ scrollAnchorRef.scrollIntoView({ behavior: 'smooth', block: 'end' });
+ autoScrollEnabled = true;
+ }
+ });
+});
+
+ // Edit state
+ let editingMessageId = $state<string | null>(null);
+ let editContent = $state('');
+ let editTextareaRef: HTMLTextAreaElement | undefined = $state();
+
+ // Delete confirmation state
+ let deleteConfirmId = $state<string | null>(null);
+
+// Copied state for feedback
+let copiedMessageId = $state<string | null>(null);
+let expandedThinkingMessageIds = $state<Set<string>>(new Set());
+
+ function formatTimestamp(timestamp: number): string {
+ return new Date(timestamp).toLocaleTimeString('en-US', {
+ hour12: false,
+ hour: '2-digit',
+ minute: '2-digit',
+ second: '2-digit'
+ });
+ }
+
+ function getAttachmentIcon(attachment: MessageAttachment): string {
+ switch (attachment.type) {
+ case 'image': return '🖼';
+ case 'text': return '📄';
+ default: return '📎';
+ }
+ }
+
+ function truncateName(name: string, maxLen: number = 25): string {
+ if (name.length <= maxLen) return name;
+ const ext = name.slice(name.lastIndexOf('.'));
+ const base = name.slice(0, name.lastIndexOf('.'));
+ const available = maxLen - ext.length - 3;
+ return base.slice(0, available) + '...' + ext;
+ }
+
+ async function handleCopy(content: string, messageId: string) {
+ try {
+ await navigator.clipboard.writeText(content);
+ copiedMessageId = messageId;
+ setTimeout(() => {
+ copiedMessageId = null;
+ }, 2000);
+ } catch (error) {
+ console.error('Failed to copy:', error);
+ }
+ }
+
+function toggleThinkingVisibility(messageId: string) {
+ const next = new Set(expandedThinkingMessageIds);
+ if (next.has(messageId)) {
+ next.delete(messageId);
+ } else {
+ next.add(messageId);
+ }
+ expandedThinkingMessageIds = next;
+}
+
+function isThinkingExpanded(messageId: string): boolean {
+ return expandedThinkingMessageIds.has(messageId);
+}
+
+ function handleStartEdit(messageId: string, content: string) {
+ editingMessageId = messageId;
+ editContent = content;
+ setTimeout(() => {
+ if (editTextareaRef) {
+ editTextareaRef.focus();
+ editTextareaRef.setSelectionRange(editTextareaRef.value.length, editTextareaRef.value.length);
+ // Auto-resize
+ editTextareaRef.style.height = 'auto';
+ editTextareaRef.style.height = Math.min(editTextareaRef.scrollHeight, 200) + 'px';
+ }
+ }, 10);
+ }
+
+ function handleCancelEdit() {
+ editingMessageId = null;
+ editContent = '';
+ }
+
+ function handleSaveEdit() {
+ if (editingMessageId && editContent.trim()) {
+ editAndRegenerate(editingMessageId, editContent.trim());
+ }
+ editingMessageId = null;
+ editContent = '';
+ }
+
+ function handleEditKeydown(event: KeyboardEvent) {
+ if (event.key === 'Enter' && !event.shiftKey) {
+ event.preventDefault();
+ handleSaveEdit();
+ } else if (event.key === 'Escape') {
+ handleCancelEdit();
+ }
+ }
+
+ function handleEditInput() {
+ if (editTextareaRef) {
+ editTextareaRef.style.height = 'auto';
+ editTextareaRef.style.height = Math.min(editTextareaRef.scrollHeight, 200) + 'px';
+ }
+ }
+
+ function handleDeleteClick(messageId: string) {
+ deleteConfirmId = messageId;
+ }
+
+ function handleConfirmDelete() {
+ if (deleteConfirmId) {
+ deleteMessage(deleteConfirmId);
+ deleteConfirmId = null;
+ }
+ }
+
+ function handleCancelDelete() {
+ deleteConfirmId = null;
+ }
+
+ function handleRegenerate() {
+ regenerateLastResponse();
+ }
+
+ // Check if a message is the last assistant message
+ function isLastAssistantMessage(messageId: string): boolean {
+ for (let i = messageList.length - 1; i >= 0; i--) {
+ if (messageList[i].role === 'assistant') {
+ return messageList[i].id === messageId;
+ }
+ }
+ return false;
+ }
+</script>
+
+<div class="flex flex-col gap-4 sm:gap-6 {className}">
+ {#each messageList as message (message.id)}
+ <div class="group flex {message.role === 'user' ? 'justify-end' : 'justify-start'}">
+ <div class="{message.role === 'user' ? 'max-w-[85%] sm:max-w-[70%] flex flex-col items-end' : 'max-w-[95%] sm:max-w-[85%]'}">
+ {#if message.role === 'assistant'}
+ <!-- Assistant message header -->
+ <div class="flex items-center gap-1.5 sm:gap-2 mb-1.5 sm:mb-2">
+ <div class="w-1.5 h-1.5 sm:w-2 sm:h-2 bg-exo-yellow rounded-full shadow-[0_0_10px_rgba(255,215,0,0.5)]"></div>
+ <span class="text-sm sm:text-xs text-exo-yellow tracking-[0.15em] sm:tracking-[0.2em] uppercase font-medium">EXO</span>
+ <span class="text-xs sm:text-sm text-exo-light-gray tracking-wider tabular-nums">{formatTimestamp(message.timestamp)}</span>
+ {#if message.ttftMs || message.tps}
+ <span class="text-xs text-exo-light-gray/80 font-mono ml-2">
+ {#if message.ttftMs}<span class="text-exo-light-gray/50">TTFT</span> {message.ttftMs.toFixed(0)}ms{/if}{#if message.ttftMs && message.tps}<span class="text-exo-light-gray/30 mx-1">•</span>{/if}{#if message.tps}{message.tps.toFixed(1)} <span class="text-exo-light-gray/50">tok/s</span>{/if}
+ </span>
+ {/if}
+ </div>
+ {:else}
+ <!-- User message header -->
+ <div class="flex items-center justify-end gap-1.5 sm:gap-2 mb-1.5 sm:mb-2">
+ <span class="text-xs sm:text-sm text-exo-light-gray tracking-wider tabular-nums">{formatTimestamp(message.timestamp)}</span>
+ <span class="text-sm sm:text-xs text-exo-light-gray tracking-[0.1em] sm:tracking-[0.15em] uppercase">QUERY</span>
+ <div class="w-1.5 h-1.5 sm:w-2 sm:h-2 bg-exo-light-gray/50 rounded-full"></div>
+ </div>
+ {/if}
+
+ {#if deleteConfirmId === message.id}
+ <!-- Delete confirmation -->
+ <div class="bg-red-500/10 border border-red-500/30 rounded-lg p-3">
+ <p class="text-xs text-red-400 mb-3">Delete this message{message.role === 'user' ? ' and all responses after it' : ''}?</p>
+ <div class="flex gap-2 justify-end">
+ <button
+ onclick={handleCancelDelete}
+ class="px-3 py-1.5 text-sm font-mono tracking-wider uppercase bg-exo-medium-gray/20 text-exo-light-gray border border-exo-medium-gray/30 rounded hover:bg-exo-medium-gray/30 transition-colors cursor-pointer"
+ >
+ CANCEL
+ </button>
+ <button
+ onclick={handleConfirmDelete}
+ class="px-3 py-1.5 text-sm font-mono tracking-wider uppercase bg-red-500/20 text-red-400 border border-red-500/30 rounded hover:bg-red-500/30 transition-colors cursor-pointer"
+ >
+ DELETE
+ </button>
+ </div>
+ </div>
+ {:else if editingMessageId === message.id}
+ <!-- Edit mode -->
+ <div class="command-panel rounded-lg p-3">
+ <textarea
+ bind:this={editTextareaRef}
+ bind:value={editContent}
+ onkeydown={handleEditKeydown}
+ oninput={handleEditInput}
+ class="w-full bg-exo-black/60 border border-exo-yellow/30 rounded px-3 py-2 text-sm text-foreground font-mono focus:outline-none focus:border-exo-yellow/50 resize-none"
+ style="min-height: 60px; max-height: 200px;"
+ ></textarea>
+ <div class="flex gap-2 justify-end mt-2">
+ <button
+ onclick={handleCancelEdit}
+ class="px-3 py-1.5 text-sm font-mono tracking-wider uppercase bg-exo-medium-gray/20 text-exo-light-gray border border-exo-medium-gray/30 rounded hover:bg-exo-medium-gray/30 transition-colors cursor-pointer"
+ >
+ CANCEL
+ </button>
+ <button
+ onclick={handleSaveEdit}
+ disabled={!editContent.trim()}
+ class="px-3 py-1.5 text-sm font-mono tracking-wider uppercase bg-transparent text-exo-yellow border border-exo-yellow/30 rounded hover:border-exo-yellow/50 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1.5 cursor-pointer"
+ >
+ <svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
+ </svg>
+ SEND
+ </button>
+ </div>
+ </div>
+ {:else}
+ <div class="{message.role === 'user'
+ ? 'command-panel rounded-lg rounded-tr-sm inline-block'
+ : 'command-panel rounded-lg rounded-tl-sm border-l-2 border-l-exo-yellow/50 inline-block'}">
+
+ {#if message.role === 'user'}
+ <!-- User message styling -->
+ <div class="px-4 py-3">
+ <!-- Attachments -->
+ {#if message.attachments && message.attachments.length > 0}
+ <div class="flex flex-wrap gap-2 mb-3">
+ {#each message.attachments as attachment}
+ <div class="flex items-center gap-2 bg-exo-dark-gray/60 border border-exo-yellow/20 rounded px-2 py-1 text-xs font-mono">
+ {#if attachment.type === 'image' && attachment.preview}
+ <img
+ src={attachment.preview}
+ alt={attachment.name}
+ class="w-12 h-12 object-cover rounded border border-exo-yellow/20"
+ />
+ {:else}
+ <span>{getAttachmentIcon(attachment)}</span>
+ {/if}
+ <span class="text-exo-yellow" title={attachment.name}>{truncateName(attachment.name)}</span>
+ </div>
+ {/each}
+ </div>
+ {/if}
+
+ {#if message.content}
+ <div class="text-sm text-foreground font-mono tracking-wide whitespace-pre-wrap break-words leading-relaxed">
+ {message.content}
+ </div>
+ {/if}
+ </div>
+ {:else}
+ <!-- Assistant message styling -->
+ <div class="p-3 sm:p-4">
+ {#if message.thinking && message.thinking.trim().length > 0}
+ <div class="mb-3 rounded border border-exo-yellow/20 bg-exo-black/40">
+ <button
+ type="button"
+ class="w-full flex items-center justify-between px-3 py-2 text-xs font-mono uppercase tracking-[0.2em] text-exo-light-gray/80 hover:text-exo-yellow transition-colors cursor-pointer"
+ onclick={() => toggleThinkingVisibility(message.id)}
+ aria-expanded={isThinkingExpanded(message.id)}
+ aria-controls={`thinking-panel-${message.id}`}
+ >
+ <span class="flex items-center gap-2 tracking-[0.25em]">
+ <svg
+ class={`w-3.5 h-3.5 text-current transition-transform duration-200 ${isThinkingExpanded(message.id) ? 'rotate-90' : ''}`}
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke="currentColor"
+ aria-hidden="true"
+ >
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
+ </svg>
+ <span>Thinking...</span>
+ </span>
+ <span class="text-[10px] tracking-[0.2em] text-exo-light-gray/60">
+ {isThinkingExpanded(message.id) ? 'HIDE' : 'SHOW'}
+ </span>
+ </button>
+ {#if isThinkingExpanded(message.id)}
+ <div
+ id={`thinking-panel-${message.id}`}
+ class="px-3 pb-3 text-xs text-exo-light-gray/90 font-mono whitespace-pre-wrap break-words leading-relaxed"
+ >
+ {message.thinking.trim()}
+ </div>
+ {/if}
+ </div>
+ {/if}
+ <div class="text-sm text-foreground font-mono tracking-wide whitespace-pre-wrap break-words leading-relaxed">
+ {message.content || (loading ? response : '')}
+ {#if loading && !message.content}
+ <span class="inline-block w-2 h-4 bg-exo-yellow/70 ml-1 cursor-blink"></span>
+ {/if}
+ </div>
+ </div>
+ {/if}
+ </div>
+
+ <!-- Action buttons -->
+ <div class="flex items-center gap-1 mt-1.5 opacity-0 group-hover:opacity-100 transition-opacity {message.role === 'user' ? 'justify-end' : 'justify-start'}">
+ <!-- Copy button -->
+ <button
+ onclick={() => handleCopy(message.content, message.id)}
+ class="p-1.5 text-exo-light-gray hover:text-exo-yellow transition-colors rounded cursor-pointer"
+ title="Copy message"
+ >
+ {#if copiedMessageId === message.id}
+ <svg class="w-3.5 h-3.5 text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
+ </svg>
+ {:else}
+ <svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
+ </svg>
+ {/if}
+ </button>
+
+ <!-- Edit button (user messages only) -->
+ {#if message.role === 'user'}
+ <button
+ onclick={() => handleStartEdit(message.id, message.content)}
+ class="p-1.5 text-exo-light-gray hover:text-exo-yellow transition-colors rounded cursor-pointer"
+ title="Edit message"
+ >
+ <svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
+ </svg>
+ </button>
+ {/if}
+
+ <!-- Regenerate button (last assistant message only) -->
+ {#if message.role === 'assistant' && isLastAssistantMessage(message.id) && !loading}
+ <button
+ onclick={handleRegenerate}
+ class="p-1.5 text-exo-light-gray hover:text-exo-yellow transition-colors rounded cursor-pointer"
+ title="Regenerate response"
+ >
+ <svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
+ </svg>
+ </button>
+ {/if}
+
+ <!-- Delete button -->
+ <button
+ onclick={() => handleDeleteClick(message.id)}
+ class="p-1.5 text-exo-light-gray hover:text-red-400 transition-colors rounded hover:bg-red-500/10 cursor-pointer"
+ title="Delete message"
+ >
+ <svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
+ </svg>
+ </button>
+ </div>
+ {/if}
+ </div>
+ </div>
+ {/each}
+
+ {#if messageList.length === 0}
+ <div class="flex-1 flex flex-col items-center justify-center text-center pt-[20vh]">
+ <div class="w-12 h-12 sm:w-16 sm:h-16 border border-exo-yellow/20 rounded-full flex items-center justify-center mb-3 sm:mb-4">
+ <div class="w-6 h-6 sm:w-8 sm:h-8 border border-exo-yellow/40 rounded-full flex items-center justify-center">
+ <div class="w-1.5 h-1.5 sm:w-2 sm:h-2 bg-exo-yellow/60 rounded-full"></div>
+ </div>
+ </div>
+ <p class="text-xs sm:text-sm text-exo-light-gray tracking-[0.15em] sm:tracking-[0.2em] uppercase">AWAITING INPUT</p>
+ <p class="text-sm sm:text-xs text-exo-light-gray tracking-wider mt-1">ENTER A QUERY TO BEGIN</p>
+ </div>
+ {/if}
+
+ <!-- Scroll anchor for auto-scroll -->
+ <div bind:this={scrollAnchorRef}></div>
+</div>
diff --git a/dashboard/src/lib/components/ChatSidebar.svelte b/dashboard/src/lib/components/ChatSidebar.svelte
new file mode 100644
index 00000000..87e06059
--- /dev/null
+++ b/dashboard/src/lib/components/ChatSidebar.svelte
@@ -0,0 +1,430 @@
+<script lang="ts">
+import {
+ conversations,
+ activeConversationId,
+ createConversation,
+ loadConversation,
+ deleteConversation,
+ deleteAllConversations,
+ renameConversation,
+ clearChat,
+ instances,
+ debugMode,
+ toggleDebugMode
+ } from '$lib/stores/app.svelte';
+
+ interface Props {
+ class?: string;
+ }
+
+ let { class: className = '' }: Props = $props();
+
+ const conversationList = $derived(conversations());
+ const activeId = $derived(activeConversationId());
+const instanceData = $derived(instances());
+const debugEnabled = $derived(debugMode());
+
+ let searchQuery = $state('');
+ let editingId = $state<string | null>(null);
+ let editingName = $state('');
+ let deleteConfirmId = $state<string | null>(null);
+ let showDeleteAllConfirm = $state(false);
+
+ const filteredConversations = $derived(
+ searchQuery.trim()
+ ? conversationList.filter(c => c.name.toLowerCase().includes(searchQuery.toLowerCase()))
+ : conversationList
+ );
+
+ function handleNewChat() {
+ createConversation();
+ }
+
+ function handleSelectConversation(id: string) {
+ loadConversation(id);
+ }
+
+ function handleStartEdit(id: string, name: string, event: MouseEvent) {
+ event.stopPropagation();
+ editingId = id;
+ editingName = name;
+ }
+
+ function handleSaveEdit() {
+ if (editingId && editingName.trim()) {
+ renameConversation(editingId, editingName.trim());
+ }
+ editingId = null;
+ editingName = '';
+ }
+
+ function handleCancelEdit() {
+ editingId = null;
+ editingName = '';
+ }
+
+ function handleEditKeydown(event: KeyboardEvent) {
+ if (event.key === 'Enter') {
+ handleSaveEdit();
+ } else if (event.key === 'Escape') {
+ handleCancelEdit();
+ }
+ }
+
+ function handleDeleteClick(id: string, event: MouseEvent) {
+ event.stopPropagation();
+ deleteConfirmId = id;
+ }
+
+ function handleConfirmDelete() {
+ if (deleteConfirmId) {
+ deleteConversation(deleteConfirmId);
+ deleteConfirmId = null;
+ }
+ }
+
+ function handleCancelDelete() {
+ deleteConfirmId = null;
+ }
+
+ function handleDeleteAllClick() {
+ showDeleteAllConfirm = true;
+ }
+
+ function handleConfirmDeleteAll() {
+ deleteAllConversations();
+ showDeleteAllConfirm = false;
+ }
+
+ function handleCancelDeleteAll() {
+ showDeleteAllConfirm = false;
+ }
+
+ function formatDate(timestamp: number): string {
+ const date = new Date(timestamp);
+ const now = new Date();
+ const diffDays = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24));
+
+ if (diffDays === 0) {
+ return date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
+ } else if (diffDays === 1) {
+ return 'Yesterday';
+ } else if (diffDays < 7) {
+ return date.toLocaleDateString('en-US', { weekday: 'short' });
+ } else {
+ return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
+ }
+ }
+
+ function getLastAssistantStats(conversation: typeof conversationList[0]): { ttftMs?: number; tps?: number } | null {
+ // Find the last assistant message with stats
+ for (let i = conversation.messages.length - 1; i >= 0; i--) {
+ const msg = conversation.messages[i];
+ if (msg.role === 'assistant' && (msg.ttftMs || msg.tps)) {
+ return { ttftMs: msg.ttftMs, tps: msg.tps };
+ }
+ }
+ return null;
+ }
+
+ function formatModelName(modelId: string | null | undefined): string {
+ if (!modelId) return 'Unknown Model';
+ const parts = modelId.split('/');
+ const tail = parts[parts.length - 1] || modelId;
+ return tail || modelId;
+ }
+
+ function formatStrategy(sharding: string | null | undefined, instanceType: string | null | undefined): string {
+ const shardLabel = sharding ?? 'Unknown';
+ const typeLabel = instanceType ?? null;
+ return typeLabel ? `${shardLabel} (${typeLabel})` : shardLabel;
+ }
+
+ function getTaggedValue(obj: unknown): [string | null, unknown] {
+ if (!obj || typeof obj !== 'object') return [null, null];
+ const keys = Object.keys(obj as Record<string, unknown>);
+ if (keys.length === 1) {
+ return [keys[0], (obj as Record<string, unknown>)[keys[0]]];
+ }
+ return [null, null];
+ }
+
+ function extractInstanceModelId(instanceWrapped: unknown): string | null {
+ const [, instance] = getTaggedValue(instanceWrapped);
+ if (!instance || typeof instance !== 'object') return null;
+ const inst = instance as { shardAssignments?: { modelId?: string } };
+ return inst.shardAssignments?.modelId ?? null;
+ }
+
+ function describeInstance(instanceWrapped: unknown): { sharding: string | null; instanceType: string | null } {
+ const [instanceTag, instance] = getTaggedValue(instanceWrapped);
+ if (!instance || typeof instance !== 'object') {
+ return { sharding: null, instanceType: null };
+ }
+
+ let instanceType: string | null = null;
+ if (instanceTag === 'MlxRingInstance') instanceType = 'MLX Ring';
+ else if (instanceTag === 'MlxIbvInstance' || instanceTag === 'MlxJacclInstance') instanceType = 'MLX RDMA';
+
+ let sharding: string | null = null;
+ const inst = instance as { shardAssignments?: { runnerToShard?: Record<string, unknown> } };
+ const runnerToShard = inst.shardAssignments?.runnerToShard || {};
+ const firstShardWrapped = Object.values(runnerToShard)[0];
+ if (firstShardWrapped) {
+ const [shardTag] = getTaggedValue(firstShardWrapped);
+ if (shardTag === 'PipelineShardMetadata') sharding = 'Pipeline';
+ else if (shardTag === 'TensorShardMetadata') sharding = 'Tensor';
+ else if (shardTag === 'PrefillDecodeShardMetadata') sharding = 'Prefill/Decode';
+ }
+
+ return { sharding, instanceType };
+ }
+
+ function resolveConversationInfo(conversation: typeof conversationList[0]): { modelLabel: string; strategyLabel: string } {
+ // Attempt to match conversation model to an instance
+ let matchedInstance: unknown = null;
+ let modelId = conversation.modelId ?? null;
+
+ if (modelId) {
+ for (const [, instanceWrapper] of Object.entries(instanceData)) {
+ const candidate = extractInstanceModelId(instanceWrapper);
+ if (candidate === modelId) {
+ matchedInstance = instanceWrapper;
+ break;
+ }
+ }
+ }
+
+ // Fallback: use the first available instance if no explicit match
+ if (!matchedInstance) {
+ const firstInstance = Object.values(instanceData)[0];
+ if (firstInstance) {
+ matchedInstance = firstInstance;
+ modelId = modelId ?? extractInstanceModelId(firstInstance);
+ }
+ }
+
+ const instanceDetails = matchedInstance ? describeInstance(matchedInstance) : { sharding: null, instanceType: null };
+ const displayModel = modelId ?? conversation.modelId ?? null;
+ const sharding = conversation.sharding ?? instanceDetails.sharding ?? 'Unknown';
+ const instanceType = conversation.instanceType ?? instanceDetails.instanceType;
+
+ return {
+ modelLabel: formatModelName(displayModel),
+ strategyLabel: formatStrategy(sharding, instanceType)
+ };
+ }
+</script>
+
+<aside class="flex flex-col h-full bg-exo-dark-gray border-r border-exo-yellow/10 {className}">
+ <!-- Header -->
+ <div class="p-4">
+ <button
+ onclick={handleNewChat}
+ class="w-full flex items-center justify-center gap-2 py-2.5 px-4 bg-transparent border border-exo-yellow/30 text-exo-yellow text-xs font-mono tracking-wider uppercase hover:border-exo-yellow/50 transition-all cursor-pointer"
+ >
+ <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
+ </svg>
+ NEW CHAT
+ </button>
+ </div>
+
+ <!-- Search -->
+ <div class="px-4 py-3">
+ <div class="relative">
+ <svg class="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-white/50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
+ </svg>
+ <input
+ type="text"
+ bind:value={searchQuery}
+ placeholder="Search conversations..."
+ class="w-full bg-exo-black/40 border border-exo-medium-gray/30 rounded px-3 py-2 pl-9 text-xs text-white/90 placeholder:text-white/40 focus:outline-none focus:border-exo-yellow/30"
+ />
+ </div>
+ </div>
+
+ <!-- Conversation List -->
+ <div class="flex-1 overflow-y-auto">
+ {#if filteredConversations.length > 0}
+ <div class="py-2">
+ <div class="px-4 py-2">
+ <span class="text-sm text-white/70 font-mono tracking-wider uppercase">
+ {searchQuery ? 'SEARCH RESULTS' : 'CONVERSATIONS'}
+ </span>
+ </div>
+
+ {#each filteredConversations as conversation (conversation.id)}
+ {@const info = resolveConversationInfo(conversation)}
+ <div class="px-2">
+ {#if editingId === conversation.id}
+ <!-- Edit mode -->
+ <div class="p-2 bg-transparent border border-exo-yellow/20 rounded mb-1">
+ <input
+ type="text"
+ bind:value={editingName}
+ onkeydown={handleEditKeydown}
+ class="w-full bg-exo-black/60 border border-exo-yellow/30 rounded px-2 py-1.5 text-xs text-exo-light-gray focus:outline-none focus:border-exo-yellow/50 mb-2"
+ autofocus
+ />
+ <div class="flex gap-2">
+ <button
+ onclick={handleSaveEdit}
+ class="flex-1 py-1.5 text-xs font-mono tracking-wider uppercase bg-transparent text-exo-yellow border border-exo-yellow/30 rounded hover:border-exo-yellow/50 cursor-pointer"
+ >
+ SAVE
+ </button>
+ <button
+ onclick={handleCancelEdit}
+ class="flex-1 py-1.5 text-xs font-mono tracking-wider uppercase bg-exo-medium-gray/20 text-exo-light-gray border border-exo-medium-gray/30 rounded hover:bg-exo-medium-gray/30 cursor-pointer"
+ >
+ CANCEL
+ </button>
+ </div>
+ </div>
+ {:else if deleteConfirmId === conversation.id}
+ <!-- Delete confirmation -->
+ <div class="p-2 bg-red-500/10 border border-red-500/30 rounded mb-1">
+ <p class="text-xs text-red-400 mb-2">Delete "{conversation.name}"?</p>
+ <div class="flex gap-2">
+ <button
+ onclick={handleConfirmDelete}
+ class="flex-1 py-1.5 text-xs font-mono tracking-wider uppercase bg-red-500/20 text-red-400 border border-red-500/30 rounded hover:bg-red-500/30 cursor-pointer"
+ >
+ DELETE
+ </button>
+ <button
+ onclick={handleCancelDelete}
+ class="flex-1 py-1.5 text-xs font-mono tracking-wider uppercase bg-exo-medium-gray/20 text-exo-light-gray border border-exo-medium-gray/30 rounded hover:bg-exo-medium-gray/30 cursor-pointer"
+ >
+ CANCEL
+ </button>
+ </div>
+ </div>
+ {:else}
+ <!-- Normal view -->
+ {@const stats = getLastAssistantStats(conversation)}
+ <div
+ role="button"
+ tabindex="0"
+ onclick={() => handleSelectConversation(conversation.id)}
+ onkeydown={(e) => e.key === 'Enter' && handleSelectConversation(conversation.id)}
+ class="group w-full flex items-center justify-between p-2 rounded mb-1 transition-all text-left cursor-pointer
+ {activeId === conversation.id
+ ? 'bg-transparent border border-exo-yellow/30'
+ : 'hover:border-exo-yellow/20 border border-transparent'}"
+ >
+ <div class="flex-1 min-w-0 pr-2">
+ <div class="text-sm truncate {activeId === conversation.id ? 'text-exo-yellow' : 'text-white/90'}">
+ {conversation.name}
+ </div>
+ <div class="text-sm text-white/50 mt-0.5">
+ {formatDate(conversation.updatedAt)}
+ </div>
+ <div class="text-sm text-white/70 truncate">
+ {info.modelLabel}
+ </div>
+ <div class="text-xs text-white/60 font-mono">
+ Strategy: <span class="text-white/80">{info.strategyLabel}</span>
+ </div>
+ {#if stats}
+ <div class="text-xs text-white/60 font-mono mt-1">
+ {#if stats.ttftMs}<span class="text-white/40">TTFT</span> {stats.ttftMs.toFixed(0)}ms{/if}{#if stats.ttftMs && stats.tps}<span class="text-white/30 mx-1.5">•</span>{/if}{#if stats.tps}{stats.tps.toFixed(1)} <span class="text-white/40">tok/s</span>{/if}
+ </div>
+ {/if}
+ </div>
+
+ <div class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
+ <button
+ type="button"
+ onclick={(e) => handleStartEdit(conversation.id, conversation.name, e)}
+ class="p-1 text-exo-light-gray hover:text-exo-yellow transition-colors cursor-pointer"
+ title="Rename"
+ >
+ <svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
+ </svg>
+ </button>
+ <button
+ type="button"
+ onclick={(e) => handleDeleteClick(conversation.id, e)}
+ class="p-1 text-exo-light-gray hover:text-red-400 transition-colors cursor-pointer"
+ title="Delete"
+ >
+ <svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
+ </svg>
+ </button>
+ </div>
+ </div>
+ {/if}
+ </div>
+ {/each}
+ </div>
+ {:else}
+ <div class="flex flex-col items-center justify-center h-full p-4 text-center">
+ <div class="w-12 h-12 border border-exo-yellow/20 rounded-full flex items-center justify-center mb-3">
+ <svg class="w-6 h-6 text-exo-yellow/40" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
+ </svg>
+ </div>
+ <p class="text-xs text-white/70 font-mono tracking-wider uppercase mb-1">
+ {searchQuery ? 'NO RESULTS' : 'NO CONVERSATIONS'}
+ </p>
+ <p class="text-sm text-white/50">
+ {searchQuery ? 'Try a different search' : 'Start a new chat to begin'}
+ </p>
+ </div>
+ {/if}
+ </div>
+
+ <!-- Footer -->
+ <div class="p-3 border-t border-exo-yellow/10">
+ {#if showDeleteAllConfirm}
+ <div class="bg-red-500/10 border border-red-500/30 rounded p-2 mb-2">
+ <p class="text-xs text-red-400 text-center mb-2">Delete all {conversationList.length} conversations?</p>
+ <div class="flex gap-2">
+ <button
+ onclick={handleConfirmDeleteAll}
+ class="flex-1 py-1.5 text-xs font-mono tracking-wider uppercase bg-red-500/20 text-red-400 border border-red-500/30 rounded hover:bg-red-500/30 transition-colors cursor-pointer"
+ >
+ DELETE ALL
+ </button>
+ <button
+ onclick={handleCancelDeleteAll}
+ class="flex-1 py-1.5 text-xs font-mono tracking-wider uppercase bg-exo-medium-gray/20 text-exo-light-gray border border-exo-medium-gray/30 rounded hover:bg-exo-medium-gray/30 transition-colors cursor-pointer"
+ >
+ CANCEL
+ </button>
+ </div>
+ </div>
+ {:else if conversationList.length > 0}
+ <button
+ onclick={handleDeleteAllClick}
+ class="w-full flex items-center justify-center gap-2 py-1.5 text-sm font-mono tracking-wider uppercase text-white/70 hover:text-red-400 hover:bg-red-500/10 border border-transparent hover:border-red-500/20 rounded transition-all cursor-pointer"
+ >
+ <svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
+ </svg>
+ DELETE ALL CHATS
+ </button>
+ {/if}
+ <div class="flex items-center justify-center gap-3 {conversationList.length > 0 && !showDeleteAllConfirm ? 'mt-2' : ''}">
+ <button
+ type="button"
+ onclick={toggleDebugMode}
+ class="p-1.5 rounded border border-exo-medium-gray/40 hover:border-exo-yellow/50 transition-colors cursor-pointer"
+ title="Toggle debug mode"
+ >
+ <svg class="w-4 h-4 {debugEnabled ? 'text-exo-yellow' : 'text-exo-medium-gray'}" fill="currentColor" viewBox="0 0 24 24">
+ <path d="M19 8h-1.81A6.002 6.002 0 0 0 12 2a6.002 6.002 0 0 0-5.19 3H5a1 1 0 0 0 0 2h1v2H5a1 1 0 0 0 0 2h1v2H5a1 1 0 0 0 0 2h1.81A6.002 6.002 0 0 0 12 22a6.002 6.002 0 0 0 5.19-3H19a1 1 0 0 0 0-2h-1v-2h1a1 1 0 0 0 0-2h-1v-2h1a1 1 0 1 0 0-2Zm-5 10.32V19a1 1 0 1 1-2 0v-.68a3.999 3.999 0 0 1-3-3.83V9.32a3.999 3.999 0 0 1 3-3.83V5a1 1 0 0 1 2 0v.49a3.999 3.999 0 0 1 3 3.83v5.17a3.999 3.999 0 0 1-3 3.83Z"/>
+ </svg>
+ </button>
+ <div class="text-xs text-white/60 font-mono tracking-wider text-center">
+ {conversationList.length} CONVERSATION{conversationList.length !== 1 ? 'S' : ''}
+ </div>
+ </div>
+ </div>
+</aside>
+
diff --git a/dashboard/src/lib/components/HeaderNav.svelte b/dashboard/src/lib/components/HeaderNav.svelte
new file mode 100644
index 00000000..4ec770d6
--- /dev/null
+++ b/dashboard/src/lib/components/HeaderNav.svelte
@@ -0,0 +1,57 @@
+<script lang="ts">
+ import { browser } from '$app/environment';
+
+ export let showHome = true;
+ export let onHome: (() => void) | null = null;
+
+ function handleHome(): void {
+ if (onHome) {
+ onHome();
+ return;
+ }
+ if (browser) {
+ // Hash router: send to root
+ window.location.hash = '/';
+ }
+ }
+</script>
+
+<header class="relative z-20 flex items-center justify-center px-6 pt-8 pb-4 bg-exo-dark-gray">
+ <!-- Center: Logo (clickable to go home) -->
+ <button
+ onclick={handleHome}
+ class="hover:opacity-80 transition-opacity {showHome ? 'cursor-pointer' : 'cursor-default'}"
+ title={showHome ? 'Go to home' : ''}
+ disabled={!showHome}
+ >
+ <img src="/exo-logo.png" alt="EXO" class="h-18 drop-shadow-[0_0_20px_rgba(255,215,0,0.5)]" />
+ </button>
+
+ <!-- Right: Home + Downloads -->
+ <div class="absolute right-6 top-1/2 -translate-y-1/2 flex items-center gap-4">
+ {#if showHome}
+ <button
+ onclick={handleHome}
+ class="text-sm text-exo-light-gray hover:text-exo-yellow transition-colors tracking-wider uppercase flex items-center gap-2 cursor-pointer"
+ title="Back to topology view"
+ >
+ <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
+ </svg>
+ Home
+ </button>
+ {/if}
+ <a
+ href="/#/downloads"
+ class="text-sm text-exo-light-gray hover:text-exo-yellow transition-colors tracking-wider uppercase flex items-center gap-2 cursor-pointer"
+ title="View downloads overview"
+ >
+ <svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+ <path d="M12 3v12" />
+ <path d="M7 12l5 5 5-5" />
+ <path d="M5 21h14" />
+ </svg>
+ Downloads
+ </a>
+ </div>
+</header>
diff --git a/dashboard/src/lib/components/ModelCard.svelte b/dashboard/src/lib/components/ModelCard.svelte
new file mode 100644
index 00000000..ee5f07ab
--- /dev/null
+++ b/dashboard/src/lib/components/ModelCard.svelte
@@ -0,0 +1,660 @@
+<script lang="ts">
+ import type { DownloadProgress, NodeInfo, PlacementPreview } from '$lib/stores/app.svelte';
+
+interface Props {
+ model: { id: string; name?: string; storage_size_megabytes?: number };
+ isLaunching?: boolean;
+ downloadStatus?: {
+ isDownloading: boolean;
+ progress: DownloadProgress | null;
+ perNode?: Array<{
+ nodeId: string;
+ nodeName: string;
+ progress: DownloadProgress;
+ }>;
+ } | null;
+ nodes?: Record<string, NodeInfo>;
+ sharding?: 'Pipeline' | 'Tensor';
+ runtime?: 'MlxRing' | 'MlxIbv' | 'MlxJaccl';
+ onLaunch?: () => void;
+ tags?: string[];
+ apiPreview?: PlacementPreview | null;
+ modelIdOverride?: string | null;
+ }
+
+ let {
+ model,
+ isLaunching = false,
+ downloadStatus = null,
+ nodes = {},
+ sharding = 'Pipeline',
+ runtime = 'MlxRing',
+ onLaunch,
+ tags = [],
+ apiPreview = null,
+ modelIdOverride = null
+ }: Props = $props();
+
+ // Estimate memory requirements from model name
+ // Uses regex with word boundaries to avoid false matches like '4bit' matching '4b'
+ function estimateMemoryGB(modelId: string, modelName?: string): number {
+ // Check both ID and name for quantization info
+ const combined = `${modelId} ${modelName || ''}`.toLowerCase();
+
+ // Detect quantization level - affects memory by roughly 2x between levels
+ const is4bit = combined.includes('4bit') || combined.includes('4-bit') || combined.includes(':4bit');
+ const is8bit = combined.includes('8bit') || combined.includes('8-bit') || combined.includes(':8bit');
+ // 4-bit = 0.5 bytes/param, 8-bit = 1 byte/param, fp16 = 2 bytes/param
+ const quantMultiplier = is4bit ? 0.5 : is8bit ? 1 : 2;
+ const id = modelId.toLowerCase();
+
+ // Known large models that don't follow the standard naming pattern
+ // DeepSeek V3 has 685B parameters
+ if (id.includes('deepseek-v3')) {
+ return Math.round(685 * quantMultiplier);
+ }
+ // DeepSeek V2 has 236B parameters
+ if (id.includes('deepseek-v2')) {
+ return Math.round(236 * quantMultiplier);
+ }
+ // Llama 4 Scout/Maverick are large models
+ if (id.includes('llama-4')) {
+ return Math.round(400 * quantMultiplier);
+ }
+
+ // Match parameter counts with word boundaries (e.g., "70b" but not "4bit")
+ const paramMatch = id.match(/(\d+(?:\.\d+)?)\s*b(?![a-z])/i);
+ if (paramMatch) {
+ const params = parseFloat(paramMatch[1]);
+ return Math.max(4, Math.round(params * quantMultiplier));
+ }
+
+ // Fallback patterns for explicit size markers (assume fp16 baseline, adjust for quant)
+ if (id.includes('405b') || id.includes('400b')) return Math.round(405 * quantMultiplier);
+ if (id.includes('180b')) return Math.round(180 * quantMultiplier);
+ if (id.includes('141b') || id.includes('140b')) return Math.round(140 * quantMultiplier);
+ if (id.includes('123b') || id.includes('120b')) return Math.round(123 * quantMultiplier);
+ if (id.includes('72b') || id.includes('70b')) return Math.round(70 * quantMultiplier);
+ if (id.includes('67b') || id.includes('65b')) return Math.round(65 * quantMultiplier);
+ if (id.includes('35b') || id.includes('34b') || id.includes('32b') || id.includes('30b')) return Math.round(32 * quantMultiplier);
+ if (id.includes('27b') || id.includes('26b') || id.includes('22b')) return Math.round(24 * quantMultiplier);
+ if (id.includes('14b') || id.includes('13b') || id.includes('15b')) return Math.round(14 * quantMultiplier);
+ if (id.includes('8b') || id.includes('9b') || id.includes('7b')) return Math.round(8 * quantMultiplier);
+ if (id.includes('3b') || id.includes('3.8b')) return Math.round(4 * quantMultiplier);
+ if (id.includes('2b') || id.includes('1b') || id.includes('1.5b') || id.includes('0.5b')) return Math.round(2 * quantMultiplier);
+
+ return 16; // Default fallback
+ }
+
+ function formatBytes(bytes: number, decimals = 1): string {
+ if (!bytes || bytes === 0) return '0 B';
+ const k = 1024;
+ const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)) + ' ' + sizes[i];
+ }
+
+ function formatSpeed(bps: number): string {
+ if (!bps || bps <= 0) return '0 B/s';
+ return formatBytes(bps) + '/s';
+ }
+
+ function formatEta(ms: number): string {
+ if (!ms || ms <= 0) return '--';
+ const totalSeconds = Math.round(ms / 1000);
+ const s = totalSeconds % 60;
+ const m = Math.floor(totalSeconds / 60) % 60;
+ const h = Math.floor(totalSeconds / 3600);
+ if (h > 0) return `${h}h ${m}m`;
+ if (m > 0) return `${m}m ${s}s`;
+ return `${s}s`;
+ }
+
+ const isDownloading = $derived(downloadStatus?.isDownloading ?? false);
+ const progress = $derived(downloadStatus?.progress);
+ const percentage = $derived(progress?.percentage ?? 0);
+let expandedNodes = $state<Set<string>>(new Set());
+
+function toggleNodeDetails(nodeId: string): void {
+ const next = new Set(expandedNodes);
+ if (next.has(nodeId)) {
+ next.delete(nodeId);
+ } else {
+ next.add(nodeId);
+ }
+ expandedNodes = next;
+}
+
+ // Use actual storage_size_megabytes from API if available, otherwise fall back to estimate
+ const estimatedMemory = $derived(
+ model.storage_size_megabytes
+ ? Math.round(model.storage_size_megabytes / 1024)
+ : estimateMemoryGB(model.id, model.name)
+ );
+
+ function getDeviceType(name: string): 'macbook' | 'studio' | 'mini' | 'unknown' {
+ const lower = name.toLowerCase();
+ if (lower.includes('macbook')) return 'macbook';
+ if (lower.includes('studio')) return 'studio';
+ if (lower.includes('mini')) return 'mini';
+ return 'unknown';
+ }
+
+ const clampPercent = (value: number): number => Math.min(100, Math.max(0, value));
+ const huggingFaceModelId = $derived(modelIdOverride ?? model.id);
+
+ // Get node list in the same order as the topology graph (insertion order of
+ // topology nodes), while still ensuring preview nodes render even if the
+ // topology payload is missing them. Topology order is preserved exactly so
+ // that the mini preview matches the main TopologyGraph layout.
+ const nodeList = $derived(() => {
+ const nodesFromTopology = Object.keys(nodes).map((id) => {
+ const info = nodes[id];
+ const totalBytes = info.macmon_info?.memory?.ram_total ?? info.system_info?.memory ?? 0;
+ const usedBytes = info.macmon_info?.memory?.ram_usage ?? 0;
+ const availableBytes = Math.max(totalBytes - usedBytes, 0);
+ const totalGB = totalBytes / (1024 * 1024 * 1024);
+ const availableGB = availableBytes / (1024 * 1024 * 1024);
+ const usedGB = Math.max(totalGB - availableGB, 0);
+ const deviceName = info.system_info?.model_id ?? 'Unknown';
+ const deviceType = getDeviceType(deviceName);
+
+ return { id, totalGB, availableGB, usedGB, deviceName, deviceType, usedBytes, totalBytes };
+ });
+
+ const previewEntries = apiPreview?.memory_delta_by_node ?? null;
+ const previewIds = previewEntries ? Object.keys(previewEntries) : [];
+
+ if (previewIds.length === 0) return nodesFromTopology;
+
+ // Append any preview-only nodes (not in topology) at the end
+ const topologyIds = new Set(nodesFromTopology.map((n) => n.id));
+ const extraPreviewNodes = previewIds
+ .filter((id) => !topologyIds.has(id))
+ .map((id) => {
+ const deltaBytes = previewEntries?.[id] ?? 0;
+ const deltaGB = deltaBytes / (1024 * 1024 * 1024);
+ const totalGB = Math.max(deltaGB * 1.2, 1);
+ const usedGB = Math.max(totalGB - deltaGB, 0);
+
+ return {
+ id,
+ totalGB,
+ availableGB: Math.max(totalGB - usedGB, 0),
+ usedGB,
+ deviceName: 'Unknown',
+ deviceType: 'unknown' as const,
+ usedBytes: usedGB * 1024 * 1024 * 1024,
+ totalBytes: totalGB * 1024 * 1024 * 1024
+ };
+ });
+
+ return [...nodesFromTopology, ...extraPreviewNodes];
+ });
+
+ // Calculate placement preview with all SVG metrics pre-computed
+ // Uses API preview data when available, falls back to local estimation
+ const placementPreview = $derived(() => {
+ const nodeArray = nodeList();
+ if (nodeArray.length === 0) return { nodes: [], canFit: false, totalAvailable: 0, error: null };
+
+ const numNodes = nodeArray.length;
+ const iconSize = numNodes === 1 ? 50 : 36;
+ const topoWidth = 260;
+ const topoHeight = numNodes === 1 ? 90 : numNodes === 2 ? 140 : numNodes * 50 + 20;
+ const centerX = topoWidth / 2;
+ const centerY = topoHeight / 2;
+ const radius = numNodes === 1 ? 0 : numNodes === 2 ? 45 : Math.min(topoWidth, topoHeight) * 0.32;
+
+ // Use API preview data if available
+ const hasApiPreview = apiPreview !== null && apiPreview.error === null && apiPreview.memory_delta_by_node !== null;
+ const canFit = hasApiPreview ? true : (() => {
+ const totalAvailable = nodeArray.reduce((sum, n) => sum + n.availableGB, 0);
+ return totalAvailable >= estimatedMemory;
+ })();
+ const error = apiPreview?.error ?? null;
+
+ let placementNodes: Array<{
+ id: string;
+ deviceName: string;
+ deviceType: 'macbook' | 'studio' | 'mini' | 'unknown';
+ totalGB: number;
+ currentUsedGB: number;
+ modelUsageGB: number;
+ currentPercent: number;
+ newPercent: number;
+ isUsed: boolean;
+ x: number;
+ y: number;
+ iconSize: number;
+ screenHeight: number;
+ currentFillHeight: number;
+ modelFillHeight: number;
+ }> = [];
+
+ if (hasApiPreview && apiPreview.memory_delta_by_node) {
+ // Use API placement data
+ const memoryDelta = apiPreview.memory_delta_by_node;
+ placementNodes = nodeArray.map((n, i) => {
+ const deltaBytes = memoryDelta[n.id] ?? 0;
+ const modelUsageGB = deltaBytes / (1024 * 1024 * 1024);
+ const isUsed = deltaBytes > 0;
+ const angle = numNodes === 1 ? 0 : (i / numNodes) * Math.PI * 2 - Math.PI / 2;
+ const safeTotal = Math.max(n.totalGB, 0.001);
+ const currentPercent = clampPercent((n.usedGB / safeTotal) * 100);
+ const newPercent = clampPercent(((n.usedGB + modelUsageGB) / safeTotal) * 100);
+ const screenHeight = iconSize * 0.58;
+
+ return {
+ id: n.id,
+ deviceName: n.deviceName,
+ deviceType: n.deviceType,
+ totalGB: n.totalGB,
+ currentUsedGB: n.usedGB,
+ modelUsageGB,
+ currentPercent,
+ newPercent,
+ isUsed,
+ x: centerX + Math.cos(angle) * radius,
+ y: centerY + Math.sin(angle) * radius,
+ iconSize,
+ screenHeight,
+ currentFillHeight: screenHeight * (currentPercent / 100),
+ modelFillHeight: screenHeight * ((newPercent - currentPercent) / 100)
+ };
+ });
+ } else if (apiPreview?.error) {
+ // API returned an error - model can't fit, show all nodes as unused
+ placementNodes = nodeArray.map((n, i) => {
+ const angle = numNodes === 1 ? 0 : (i / numNodes) * Math.PI * 2 - Math.PI / 2;
+ const safeTotal = Math.max(n.totalGB, 0.001);
+ const currentPercent = clampPercent((n.usedGB / safeTotal) * 100);
+ const screenHeight = iconSize * 0.58;
+
+ return {
+ id: n.id,
+ deviceName: n.deviceName,
+ deviceType: n.deviceType,
+ totalGB: n.totalGB,
+ currentUsedGB: n.usedGB,
+ modelUsageGB: 0,
+ currentPercent,
+ newPercent: currentPercent,
+ isUsed: false,
+ x: centerX + Math.cos(angle) * radius,
+ y: centerY + Math.sin(angle) * radius,
+ iconSize,
+ screenHeight,
+ currentFillHeight: screenHeight * (currentPercent / 100),
+ modelFillHeight: 0
+ };
+ });
+ } else {
+ // Fallback: local estimation based on sharding strategy
+ const memoryNeeded = estimatedMemory;
+
+ if (sharding === 'Pipeline') {
+ const memoryPerNode = memoryNeeded / numNodes;
+ placementNodes = nodeArray.map((n, i) => {
+ const angle = numNodes === 1 ? 0 : (i / numNodes) * Math.PI * 2 - Math.PI / 2;
+ const safeTotal = Math.max(n.totalGB, 0.001);
+ const currentPercent = clampPercent((n.usedGB / safeTotal) * 100);
+ const newPercent = clampPercent(((n.usedGB + memoryPerNode) / safeTotal) * 100);
+ const screenHeight = iconSize * 0.58;
+
+ return {
+ id: n.id,
+ deviceName: n.deviceName,
+ deviceType: n.deviceType,
+ totalGB: n.totalGB,
+ currentUsedGB: n.usedGB,
+ modelUsageGB: memoryPerNode,
+ currentPercent,
+ newPercent,
+ isUsed: true,
+ x: centerX + Math.cos(angle) * radius,
+ y: centerY + Math.sin(angle) * radius,
+ iconSize,
+ screenHeight,
+ currentFillHeight: screenHeight * (currentPercent / 100),
+ modelFillHeight: screenHeight * ((newPercent - currentPercent) / 100)
+ };
+ });
+ } else {
+ let remaining = memoryNeeded;
+ placementNodes = nodeArray.map((n, i) => {
+ const allocated = Math.min(remaining, n.availableGB);
+ remaining -= allocated;
+ const isUsed = allocated > 0;
+ const angle = numNodes === 1 ? 0 : (i / numNodes) * Math.PI * 2 - Math.PI / 2;
+ const safeTotal = Math.max(n.totalGB, 0.001);
+ const currentPercent = clampPercent((n.usedGB / safeTotal) * 100);
+ const newPercent = clampPercent(((n.usedGB + allocated) / safeTotal) * 100);
+ const screenHeight = iconSize * 0.58;
+
+ return {
+ id: n.id,
+ deviceName: n.deviceName,
+ deviceType: n.deviceType,
+ totalGB: n.totalGB,
+ currentUsedGB: n.usedGB,
+ modelUsageGB: allocated,
+ currentPercent,
+ newPercent,
+ isUsed,
+ x: centerX + Math.cos(angle) * radius,
+ y: centerY + Math.sin(angle) * radius,
+ iconSize,
+ screenHeight,
+ currentFillHeight: screenHeight * (currentPercent / 100),
+ modelFillHeight: screenHeight * ((newPercent - currentPercent) / 100)
+ };
+ });
+ }
+ }
+
+ const totalAvailable = nodeArray.reduce((sum, n) => sum + n.availableGB, 0);
+ return { nodes: placementNodes, canFit: hasApiPreview || canFit, totalAvailable, topoWidth, topoHeight, error };
+ });
+
+ const canFit = $derived(apiPreview ? apiPreview.error === null : placementPreview().canFit);
+ const placementError = $derived(apiPreview?.error ?? null);
+ const nodeCount = $derived(nodeList().length);
+ const filterId = $derived(model.id.replace(/[^a-zA-Z0-9]/g, ''));
+</script>
+
+<div class="relative group">
+ <!-- Corner accents -->
+ <div class="absolute -top-px -left-px w-2 h-2 border-l border-t {canFit ? 'border-exo-yellow/30 group-hover:border-exo-yellow/60' : 'border-red-500/30'} transition-colors"></div>
+ <div class="absolute -top-px -right-px w-2 h-2 border-r border-t {canFit ? 'border-exo-yellow/30 group-hover:border-exo-yellow/60' : 'border-red-500/30'} transition-colors"></div>
+ <div class="absolute -bottom-px -left-px w-2 h-2 border-l border-b {canFit ? 'border-exo-yellow/30 group-hover:border-exo-yellow/60' : 'border-red-500/30'} transition-colors"></div>
+ <div class="absolute -bottom-px -right-px w-2 h-2 border-r border-b {canFit ? 'border-exo-yellow/30 group-hover:border-exo-yellow/60' : 'border-red-500/30'} transition-colors"></div>
+
+ <div class="bg-exo-dark-gray/60 border {canFit ? 'border-exo-yellow/20 group-hover:border-exo-yellow/40' : 'border-red-500/20'} p-3 transition-all duration-200 group-hover:shadow-[0_0_15px_rgba(255,215,0,0.1)]">
+ <!-- Model Name & Memory Required -->
+ <div class="flex items-start justify-between gap-2 mb-2">
+ <div class="flex-1 min-w-0">
+ <div class="flex items-center gap-2">
+ <div class="text-exo-yellow text-xs font-mono tracking-wide truncate" title={model.name || model.id}>
+ {model.name || model.id}
+ </div>
+ {#if huggingFaceModelId}
+ <a
+ class="shrink-0 text-white/60 hover:text-exo-yellow transition-colors"
+ href={`https://huggingface.co/${huggingFaceModelId}`}
+ target="_blank"
+ rel="noreferrer noopener"
+ aria-label="View model on Hugging Face"
+ >
+ <svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+ <path d="M14 3h7v7"/>
+ <path d="M10 14l11-11"/>
+ <path d="M21 14v6a1 1 0 0 1-1 1h-16a1 1 0 0 1-1-1v-16a1 1 0 0 1 1-1h6"/>
+ </svg>
+ </a>
+ {/if}
+ {#if tags.length > 0}
+ <div class="flex gap-1 flex-shrink-0">
+ {#each tags as tag}
+ <span class="px-1.5 py-0.5 text-xs font-mono tracking-wider uppercase rounded {tag === 'FASTEST' ? 'bg-green-500/20 text-green-400 border border-green-500/30' : 'bg-purple-500/20 text-purple-400 border border-purple-500/30'}">
+ {tag}
+ </span>
+ {/each}
+ </div>
+ {/if}
+ </div>
+ {#if model.name && model.name !== model.id}
+ <div class="text-xs text-exo-light-gray font-mono truncate mt-0.5" title={model.id}>
+ {model.id}
+ </div>
+ {/if}
+ </div>
+ <div class="flex-shrink-0 text-right">
+ <div class="text-xs font-mono {canFit ? 'text-exo-yellow' : 'text-red-400'}">
+ {estimatedMemory}GB
+ </div>
+ </div>
+ </div>
+
+ <!-- Configuration Badge -->
+ <div class="flex items-center gap-1.5 mb-2">
+ <span class="px-1.5 py-0.5 text-xs font-mono tracking-wider uppercase bg-exo-medium-gray/30 text-exo-light-gray border border-exo-medium-gray/40">
+ {sharding}
+ </span>
+ <span class="px-1.5 py-0.5 text-xs font-mono tracking-wider uppercase bg-exo-medium-gray/30 text-exo-light-gray border border-exo-medium-gray/40">
+ {runtime === 'MlxRing' ? 'MLX Ring' : runtime === 'MlxIbv' || runtime === 'MlxJaccl' ? 'MLX RDMA' : runtime}
+ </span>
+ </div>
+
+ <!-- Mini Topology Preview -->
+ {#if placementPreview().nodes.length > 0}
+ {@const preview = placementPreview()}
+ <div class="mb-3 bg-exo-black/60 rounded border border-exo-medium-gray/20 p-2 relative overflow-hidden">
+ <!-- Scanline effect -->
+ <div class="absolute inset-0 bg-[repeating-linear-gradient(0deg,transparent,transparent_2px,rgba(255,215,0,0.02)_2px,rgba(255,215,0,0.02)_4px)] pointer-events-none"></div>
+
+ <svg width="100%" height={preview.topoHeight} viewBox="0 0 {preview.topoWidth} {preview.topoHeight}" class="overflow-visible">
+ <defs>
+ <!-- Glow filter for active nodes -->
+ <filter id="nodeGlow-{filterId}" x="-50%" y="-50%" width="200%" height="200%">
+ <feGaussianBlur stdDeviation="2" result="blur"/>
+ <feMerge>
+ <feMergeNode in="blur"/>
+ <feMergeNode in="SourceGraphic"/>
+ </feMerge>
+ </filter>
+
+ <!-- Strong glow for new memory -->
+ <filter id="memGlow-{filterId}" x="-100%" y="-100%" width="300%" height="300%">
+ <feGaussianBlur stdDeviation="3" result="blur"/>
+ <feComposite in="SourceGraphic" in2="blur" operator="over"/>
+ </filter>
+ </defs>
+
+ <!-- Connection lines between nodes (if multiple) -->
+ {#if preview.nodes.length > 1}
+ {#each preview.nodes as node, i}
+ {#each preview.nodes.slice(i + 1) as node2}
+ <line
+ x1={node.x} y1={node.y} x2={node2.x} y2={node2.y}
+ stroke={node.isUsed && node2.isUsed ? '#FFD700' : '#374151'}
+ stroke-width="1"
+ stroke-dasharray={node.isUsed && node2.isUsed ? '4,2' : '2,4'}
+ opacity={node.isUsed && node2.isUsed ? 0.4 : 0.15}
+ />
+ {/each}
+ {/each}
+ {/if}
+
+ {#each preview.nodes as node}
+ <g
+ transform="translate({node.x}, {node.y})"
+ opacity={node.isUsed ? 1 : 0.25}
+ filter={node.isUsed ? `url(#nodeGlow-${filterId})` : 'none'}
+ >
+ <!-- Device icon based on type -->
+ {#if node.deviceType === 'macbook'}
+ <!-- MacBook Pro icon with memory fill -->
+ <g transform="translate({-node.iconSize/2}, {-node.iconSize/2})">
+ <!-- Screen bezel -->
+ <rect
+ x="2" y="0"
+ width={node.iconSize - 4} height={node.iconSize * 0.65}
+ rx="2"
+ fill="none"
+ stroke={node.isUsed ? '#FFD700' : '#4B5563'}
+ stroke-width="1.5"
+ />
+ <!-- Screen area (memory fill container) -->
+ <rect
+ x="4" y="2"
+ width={node.iconSize - 8} height={node.screenHeight}
+ fill="#0a0a0a"
+ />
+ <!-- Current memory fill (gray) -->
+ <rect
+ x="4"
+ y={2 + node.screenHeight - node.currentFillHeight}
+ width={node.iconSize - 8}
+ height={node.currentFillHeight}
+ fill="#374151"
+ />
+ <!-- New model memory fill (glowing yellow) -->
+ {#if node.modelUsageGB > 0 && node.isUsed}
+ <rect
+ x="4"
+ y={2 + node.screenHeight - node.currentFillHeight - node.modelFillHeight}
+ width={node.iconSize - 8}
+ height={node.modelFillHeight}
+ fill="#FFD700"
+ filter="url(#memGlow-{filterId})"
+ class="animate-pulse-slow"
+ />
+ {/if}
+ <!-- Base/keyboard -->
+ <path
+ d="M 0 {node.iconSize * 0.68} L {node.iconSize} {node.iconSize * 0.68} L {node.iconSize - 2} {node.iconSize * 0.78} L 2 {node.iconSize * 0.78} Z"
+ fill="none"
+ stroke={node.isUsed ? '#FFD700' : '#4B5563'}
+ stroke-width="1.5"
+ />
+ </g>
+ {:else if node.deviceType === 'studio'}
+ <!-- Mac Studio icon -->
+ <g transform="translate({-node.iconSize/2}, {-node.iconSize/2})">
+ <rect
+ x="2" y="2"
+ width={node.iconSize - 4} height={node.iconSize - 4}
+ rx="4"
+ fill="none"
+ stroke={node.isUsed ? '#FFD700' : '#4B5563'}
+ stroke-width="1.5"
+ />
+ <!-- Memory fill background -->
+ <rect
+ x="4" y="4"
+ width={node.iconSize - 8} height={node.iconSize - 8}
+ fill="#0a0a0a"
+ />
+ <!-- Current memory fill -->
+ <rect
+ x="4"
+ y={4 + (node.iconSize - 8) * (1 - node.currentPercent / 100)}
+ width={node.iconSize - 8}
+ height={(node.iconSize - 8) * (node.currentPercent / 100)}
+ fill="#374151"
+ />
+ <!-- New model memory fill -->
+ {#if node.modelUsageGB > 0 && node.isUsed}
+ <rect
+ x="4"
+ y={4 + (node.iconSize - 8) * (1 - node.newPercent / 100)}
+ width={node.iconSize - 8}
+ height={(node.iconSize - 8) * ((node.newPercent - node.currentPercent) / 100)}
+ fill="#FFD700"
+ filter="url(#memGlow-{filterId})"
+ class="animate-pulse-slow"
+ />
+ {/if}
+ </g>
+ {:else if node.deviceType === 'mini'}
+ <!-- Mac Mini icon -->
+ <g transform="translate({-node.iconSize/2}, {-node.iconSize/2})">
+ <rect
+ x="2" y={node.iconSize * 0.3}
+ width={node.iconSize - 4} height={node.iconSize * 0.4}
+ rx="3"
+ fill="none"
+ stroke={node.isUsed ? '#FFD700' : '#4B5563'}
+ stroke-width="1.5"
+ />
+ <!-- Memory fill background -->
+ <rect
+ x="4" y={node.iconSize * 0.32}
+ width={node.iconSize - 8} height={node.iconSize * 0.36}
+ fill="#0a0a0a"
+ />
+ <!-- Current memory fill -->
+ <rect
+ x="4"
+ y={node.iconSize * 0.32 + (node.iconSize * 0.36) * (1 - node.currentPercent / 100)}
+ width={node.iconSize - 8}
+ height={(node.iconSize * 0.36) * (node.currentPercent / 100)}
+ fill="#374151"
+ />
+ <!-- New model memory fill -->
+ {#if node.modelUsageGB > 0 && node.isUsed}
+ <rect
+ x="4"
+ y={node.iconSize * 0.32 + (node.iconSize * 0.36) * (1 - node.newPercent / 100)}
+ width={node.iconSize - 8}
+ height={(node.iconSize * 0.36) * ((node.newPercent - node.currentPercent) / 100)}
+ fill="#FFD700"
+ filter="url(#memGlow-{filterId})"
+ class="animate-pulse-slow"
+ />
+ {/if}
+ </g>
+ {:else}
+ <!-- Unknown device - hexagon -->
+ <g transform="translate({-node.iconSize/2}, {-node.iconSize/2})">
+ <polygon
+ points="{node.iconSize/2},0 {node.iconSize},{node.iconSize*0.25} {node.iconSize},{node.iconSize*0.75} {node.iconSize/2},{node.iconSize} 0,{node.iconSize*0.75} 0,{node.iconSize*0.25}"
+ fill={node.isUsed ? 'rgba(255,215,0,0.1)' : '#0a0a0a'}
+ stroke={node.isUsed ? '#FFD700' : '#4B5563'}
+ stroke-width="1.5"
+ />
+ </g>
+ {/if}
+
+ <!-- Percentage label -->
+ <text
+ y={node.iconSize/2 + 12}
+ text-anchor="middle"
+ font-size="8"
+ font-family="SF Mono, Monaco, monospace"
+ fill={node.isUsed ? (node.newPercent > 90 ? '#f87171' : '#FFD700') : '#4B5563'}
+ >
+ {node.newPercent.toFixed(0)}%
+ </text>
+ </g>
+ {/each}
+ </svg>
+ </div>
+ {/if}
+
+ <!-- Launch Button -->
+ <button
+ onclick={onLaunch}
+ disabled={isLaunching || !canFit}
+ class="w-full py-2 text-sm font-mono tracking-wider uppercase border transition-all duration-200
+ {isLaunching
+ ? 'bg-transparent text-exo-yellow border-exo-yellow/50 cursor-wait'
+ : !canFit
+ ? 'bg-red-500/10 text-red-400/70 border-red-500/30 cursor-not-allowed'
+ : 'bg-transparent text-exo-light-gray border-exo-light-gray/40 hover:text-exo-yellow hover:border-exo-yellow/50 cursor-pointer'
+ }"
+ >
+ {#if isLaunching}
+ <span class="flex items-center justify-center gap-1.5">
+ <span class="w-2 h-2 border border-exo-yellow border-t-transparent rounded-full animate-spin"></span>
+ LAUNCHING...
+ </span>
+ {:else if !canFit}
+ INSUFFICIENT MEMORY
+ {:else}
+ ▸ LAUNCH
+ {/if}
+ </button>
+ </div>
+</div>
+
+<style>
+ @keyframes pulse-slow {
+ 0%, 100% { opacity: 0.8; }
+ 50% { opacity: 1; }
+ }
+ .animate-pulse-slow {
+ animation: pulse-slow 1.5s ease-in-out infinite;
+ }
+</style>
diff --git a/dashboard/src/lib/components/TopologyGraph.svelte b/dashboard/src/lib/components/TopologyGraph.svelte
new file mode 100644
index 00000000..e45ca080
--- /dev/null
+++ b/dashboard/src/lib/components/TopologyGraph.svelte
@@ -0,0 +1,971 @@
+<script lang="ts">
+ import { onMount, onDestroy } from 'svelte';
+ import * as d3 from 'd3';
+import { topologyData, isTopologyMinimized, debugMode } from '$lib/stores/app.svelte';
+
+ interface Props {
+ class?: string;
+ highlightedNodes?: Set<string>;
+ }
+
+ let { class: className = '', highlightedNodes = new Set() }: Props = $props();
+
+ let svgContainer: SVGSVGElement | undefined = $state();
+ let resizeObserver: ResizeObserver | undefined;
+
+const isMinimized = $derived(isTopologyMinimized());
+const data = $derived(topologyData());
+const debugEnabled = $derived(debugMode());
+
+function getNodeLabel(nodeId: string): string {
+ const node = data?.nodes?.[nodeId];
+ return node?.friendly_name || nodeId.slice(0, 8);
+}
+
+function getInterfaceLabel(nodeId: string, ip?: string): { label: string; missing: boolean } {
+ if (!ip) return { label: '?', missing: true };
+ const node = data?.nodes?.[nodeId];
+ if (!node) return { label: '?', missing: true };
+
+ const matchFromInterfaces = node.network_interfaces?.find((iface) =>
+ (iface.addresses || []).some((addr) => addr === ip)
+ );
+ if (matchFromInterfaces?.name) {
+ return { label: matchFromInterfaces.name, missing: false };
+ }
+
+ const mapped = node.ip_to_interface?.[ip];
+ if (mapped && mapped.trim().length > 0) {
+ return { label: mapped, missing: false };
+ }
+
+ return { label: '?', missing: true };
+}
+
+function wrapLine(text: string, maxLen: number): string[] {
+ if (text.length <= maxLen) return [text];
+ const words = text.split(' ');
+ const lines: string[] = [];
+ let current = '';
+ for (const word of words) {
+ if (word.length > maxLen) {
+ if (current) {
+ lines.push(current);
+ current = '';
+ }
+ for (let i = 0; i < word.length; i += maxLen) {
+ lines.push(word.slice(i, i + maxLen));
+ }
+ } else if ((current + ' ' + word).trim().length > maxLen) {
+ lines.push(current);
+ current = word;
+ } else {
+ current = current ? `${current} ${word}` : word;
+ }
+ }
+ if (current) lines.push(current);
+ return lines;
+}
+
+ // Apple logo path for MacBook Pro screen
+ const APPLE_LOGO_PATH = "M788.1 340.9c-5.8 4.5-108.2 62.2-108.2 190.5 0 148.4 130.3 200.9 134.2 202.2-.6 3.2-20.7 71.9-68.7 141.9-42.8 61.6-87.5 123.1-155.5 123.1s-85.5-39.5-164-39.5c-76.5 0-103.7 40.8-165.9 40.8s-105.6-57-155.5-127C46.7 790.7 0 663 0 541.8c0-194.4 126.4-297.5 250.8-297.5 66.1 0 121.2 43.4 162.7 43.4 39.5 0 101.1-46 176.3-46 28.5 0 130.9 2.6 198.3 99.2zm-234-181.5c31.1-36.9 53.1-88.1 53.1-139.3 0-7.1-.6-14.3-1.9-20.1-50.6 1.9-110.8 33.7-147.1 75.8-28.5 32.4-55.1 83.6-55.1 135.5 0 7.8 1.3 15.6 1.9 18.1 3.2.6 8.4 1.3 13.6 1.3 45.4 0 102.5-30.4 135.5-71.3z";
+ const LOGO_NATIVE_WIDTH = 814;
+ const LOGO_NATIVE_HEIGHT = 1000;
+
+ function formatBytes(bytes: number, decimals = 1): string {
+ if (!bytes || bytes === 0) return '0B';
+ const k = 1024;
+ const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)) + sizes[i];
+ }
+
+ function getTemperatureColor(temp: number): string {
+ // Default for N/A temp - light gray
+ if (isNaN(temp) || temp === null) return 'rgba(179, 179, 179, 0.8)';
+
+ const coolTemp = 45; // Temp for pure blue
+ const midTemp = 57.5; // Temp for pure yellow
+ const hotTemp = 75; // Temp for pure red
+
+ const coolColor = { r: 93, g: 173, b: 226 }; // #5DADE2 (Blue)
+ const midColor = { r: 255, g: 215, b: 0 }; // #FFD700 (Yellow)
+ const hotColor = { r: 244, g: 67, b: 54 }; // #F44336 (Red)
+
+ let r: number, g: number, b: number;
+
+ if (temp <= coolTemp) {
+ ({ r, g, b } = coolColor);
+ } else if (temp <= midTemp) {
+ const ratio = (temp - coolTemp) / (midTemp - coolTemp);
+ r = Math.round(coolColor.r * (1 - ratio) + midColor.r * ratio);
+ g = Math.round(coolColor.g * (1 - ratio) + midColor.g * ratio);
+ b = Math.round(coolColor.b * (1 - ratio) + midColor.b * ratio);
+ } else if (temp < hotTemp) {
+ const ratio = (temp - midTemp) / (hotTemp - midTemp);
+ r = Math.round(midColor.r * (1 - ratio) + hotColor.r * ratio);
+ g = Math.round(midColor.g * (1 - ratio) + hotColor.g * ratio);
+ b = Math.round(midColor.b * (1 - ratio) + hotColor.b * ratio);
+ } else {
+ ({ r, g, b } = hotColor);
+ }
+
+ return `rgb(${r}, ${g}, ${b})`;
+ }
+
+ function renderGraph() {
+ if (!svgContainer || !data) return;
+
+ d3.select(svgContainer).selectAll('*').remove();
+
+ const nodes = data.nodes || {};
+ const edges = data.edges || [];
+ const nodeIds = Object.keys(nodes);
+
+ const rect = svgContainer.getBoundingClientRect();
+ const width = rect.width;
+ const height = rect.height;
+ const centerX = width / 2;
+ const centerY = height / 2;
+
+ const svg = d3.select(svgContainer);
+
+ // Add defs for clip paths and filters
+ const defs = svg.append('defs');
+
+ // Glow filter
+ const glowFilter = defs.append('filter')
+ .attr('id', 'glow')
+ .attr('x', '-50%')
+ .attr('y', '-50%')
+ .attr('width', '200%')
+ .attr('height', '200%');
+ glowFilter.append('feGaussianBlur')
+ .attr('stdDeviation', '2')
+ .attr('result', 'coloredBlur');
+ const glowMerge = glowFilter.append('feMerge');
+ glowMerge.append('feMergeNode').attr('in', 'coloredBlur');
+ glowMerge.append('feMergeNode').attr('in', 'SourceGraphic');
+
+ // Arrowhead marker for directional edges
+ const marker = defs.append('marker')
+ .attr('id', 'arrowhead')
+ .attr('viewBox', '0 0 10 10')
+ .attr('refX', '10')
+ .attr('refY', '5')
+ .attr('markerWidth', '11')
+ .attr('markerHeight', '11')
+ .attr('orient', 'auto-start-reverse');
+ marker.append('path')
+ .attr('d', 'M 0 0 L 10 5 L 0 10')
+ .attr('fill', 'none')
+ .attr('stroke', 'var(--exo-light-gray, #B3B3B3)')
+ .attr('stroke-width', '1.6')
+ .attr('stroke-linecap', 'round')
+ .attr('stroke-linejoin', 'round')
+ .style('animation', 'none');
+
+ if (nodeIds.length === 0) {
+ svg.append('text')
+ .attr('x', centerX)
+ .attr('y', centerY)
+ .attr('text-anchor', 'middle')
+ .attr('dominant-baseline', 'middle')
+ .attr('fill', 'rgba(255,215,0,0.4)')
+ .attr('font-size', isMinimized ? 10 : 12)
+ .attr('font-family', 'SF Mono, monospace')
+ .attr('letter-spacing', '0.1em')
+ .text('AWAITING NODES');
+ return;
+ }
+
+ const numNodes = nodeIds.length;
+ const minDimension = Math.min(width, height);
+
+ // Dynamic scaling - larger nodes for big displays
+ const sizeScale = numNodes === 1 ? 1 : Math.max(0.6, 1 - (numNodes - 1) * 0.10);
+ const baseNodeRadius = isMinimized
+ ? Math.max(36, Math.min(60, minDimension * 0.22))
+ : Math.min(120, minDimension * 0.20);
+ const nodeRadius = baseNodeRadius * sizeScale;
+
+ // Orbit radius - balanced spacing for nodes
+ const circumference = numNodes * nodeRadius * 4;
+ const radiusFromCircumference = circumference / (2 * Math.PI);
+ const minOrbitRadius = Math.max(radiusFromCircumference, minDimension * 0.18);
+ const maxOrbitRadius = minDimension * 0.30;
+ const orbitRadius = isMinimized
+ ? Math.min(maxOrbitRadius, Math.max(minOrbitRadius, minDimension * 0.26))
+ : Math.min(maxOrbitRadius, Math.max(minOrbitRadius, minDimension * (0.22 + numNodes * 0.02)));
+
+ // Determine display mode based on space and node count
+ const showFullLabels = !isMinimized && numNodes <= 4;
+ const showCompactLabels = !isMinimized && numNodes > 4;
+
+ // Add padding for labels (top/bottom)
+ const topPadding = 70; // Space for "NETWORK TOPOLOGY" label and node names
+ const bottomPadding = 70; // Space for stats and bottom label
+ const safeCenterY = topPadding + (height - topPadding - bottomPadding) / 2;
+
+ // Calculate node positions
+ const nodesWithPositions = nodeIds.map((id, index) => {
+ if (numNodes === 1) {
+ // Single node: center it
+ return {
+ id,
+ data: nodes[id],
+ x: centerX,
+ y: safeCenterY
+ };
+ }
+ // Distribute nodes around the orbit
+ // Start from top (-90 degrees) and go clockwise
+ const angle = (index / numNodes) * 2 * Math.PI - (Math.PI / 2);
+ return {
+ id,
+ data: nodes[id],
+ x: centerX + orbitRadius * Math.cos(angle),
+ y: safeCenterY + orbitRadius * Math.sin(angle)
+ };
+ });
+
+ const positionById: Record<string, { x: number; y: number }> = {};
+ nodesWithPositions.forEach(n => { positionById[n.id] = { x: n.x, y: n.y }; });
+
+ // Draw edges
+ const linksGroup = svg.append('g').attr('class', 'links-group');
+ const arrowsGroup = svg.append('g').attr('class', 'arrows-group');
+ const debugLabelsGroup = svg.append('g').attr('class', 'debug-edge-labels');
+
+ const pairMap = new Map<string, { a: string; b: string; aToB: boolean; bToA: boolean; connections: Array<{ from: string; to: string; ip: string; ifaceLabel: string; missingIface: boolean }> }>();
+ edges.forEach(edge => {
+ if (!edge.source || !edge.target || edge.source === edge.target) return;
+ if (!positionById[edge.source] || !positionById[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, connections: [] };
+
+ if (edge.source === a) entry.aToB = true;
+ else entry.bToA = true;
+
+ const ip = edge.sendBackIp || edge.sendBackMultiaddr?.ip_address || '?';
+ const ifaceInfo = getInterfaceLabel(edge.source, ip);
+ entry.connections.push({
+ from: edge.source,
+ to: edge.target,
+ ip,
+ ifaceLabel: ifaceInfo.label,
+ missingIface: ifaceInfo.missing
+ });
+ pairMap.set(key, entry);
+ });
+
+ pairMap.forEach(entry => {
+ const posA = positionById[entry.a];
+ const posB = positionById[entry.b];
+ if (!posA || !posB) return;
+
+ // Base dashed line
+ linksGroup.append('line')
+ .attr('x1', posA.x)
+ .attr('y1', posA.y)
+ .attr('x2', posB.x)
+ .attr('y2', posB.y)
+ .attr('class', 'graph-link');
+
+ // Calculate midpoint and direction for arrows
+ 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 mx = (posA.x + posB.x) / 2;
+ const my = (posA.y + posB.y) / 2;
+ const tipOffset = 16; // Distance from center for arrow tips
+ const carrier = 2; // Short segment length for arrow orientation
+
+ // Arrow A -> B (if connection exists in that direction)
+ if (entry.aToB) {
+ const tipX = mx - ux * tipOffset;
+ const tipY = my - uy * tipOffset;
+ arrowsGroup.append('line')
+ .attr('x1', tipX - ux * carrier)
+ .attr('y1', tipY - uy * carrier)
+ .attr('x2', tipX)
+ .attr('y2', tipY)
+ .attr('stroke', 'none')
+ .attr('fill', 'none')
+ .attr('marker-end', 'url(#arrowhead)');
+ }
+
+ // Arrow B -> A (if connection exists in that direction)
+ if (entry.bToA) {
+ const tipX = mx + ux * tipOffset;
+ const tipY = my + uy * tipOffset;
+ arrowsGroup.append('line')
+ .attr('x1', tipX + ux * carrier)
+ .attr('y1', tipY + uy * carrier)
+ .attr('x2', tipX)
+ .attr('y2', tipY)
+ .attr('stroke', 'none')
+ .attr('fill', 'none')
+ .attr('marker-end', 'url(#arrowhead)');
+ }
+
+ if (debugEnabled && entry.connections.length > 0) {
+ const maxBoxes = 6;
+ const fontSize = isMinimized ? 8 : 9;
+ const lineGap = 2;
+ const labelOffsetOut = Math.max(140, minDimension * 0.38);
+ const labelOffsetSide = isMinimized ? 16 : 20;
+ const boxWidth = 170;
+ const maxLineLen = 26;
+
+ const connections = entry.connections.slice(0, maxBoxes);
+ if (entry.connections.length > maxBoxes) {
+ const remaining = entry.connections.length - maxBoxes;
+ connections.push({
+ from: '',
+ to: '',
+ ip: `(+${remaining} more)`,
+ ifaceLabel: '',
+ missingIface: false
+ });
+ }
+
+ let dirX = mx - centerX;
+ let dirY = my - centerY;
+ const dirLen = Math.hypot(dirX, dirY);
+ if (dirLen < 1) {
+ dirX = -uy;
+ dirY = ux;
+ } else {
+ dirX /= dirLen;
+ dirY /= dirLen;
+ }
+
+ const nx = -dirY;
+ const ny = dirX;
+
+ const labelXRaw = mx + dirX * labelOffsetOut + nx * labelOffsetSide;
+ const labelYRaw = my + dirY * labelOffsetOut + ny * labelOffsetSide;
+ const clampPad = Math.min(120, minDimension * 0.12);
+ const labelX = Math.max(clampPad, Math.min(width - clampPad, labelXRaw));
+ const labelY = Math.max(clampPad, Math.min(height - clampPad, labelYRaw));
+
+ const labelGroup = debugLabelsGroup.append('g')
+ .attr('transform', `translate(${labelX}, ${labelY})`);
+
+ const textGroup = labelGroup.append('g');
+
+ connections.forEach((conn, idx) => {
+ const rawLines = conn.from && conn.to
+ ? [
+ `${getNodeLabel(conn.from)}→${getNodeLabel(conn.to)}`,
+ `${conn.ip}`,
+ `${conn.ifaceLabel}`
+ ]
+ : [conn.ip];
+
+ const wrapped = rawLines.flatMap(line => wrapLine(line, maxLineLen));
+
+ wrapped.forEach((line, lineIdx) => {
+ textGroup.append('text')
+ .attr('x', 0)
+ .attr('y', (idx * (wrapped.length * (fontSize + lineGap))) + lineIdx * (fontSize + lineGap))
+ .attr('text-anchor', 'middle')
+ .attr('dominant-baseline', 'hanging')
+ .attr('font-size', fontSize)
+ .attr('font-family', 'SF Mono, monospace')
+ .attr('fill', conn.missingIface ? 'rgba(248,113,113,0.9)' : 'rgba(255,255,255,0.9)')
+ .text(line);
+ });
+ });
+
+ const bbox = textGroup.node()?.getBBox();
+ if (bbox) {
+ const paddedWidth = Math.max(boxWidth, bbox.width + 14);
+ const boxHeight = bbox.height + 8;
+ const boxMinX = labelX - paddedWidth / 2;
+ const boxMaxX = labelX + paddedWidth / 2;
+ const boxMinY = labelY + bbox.y - 4;
+ const boxMaxY = boxMinY + boxHeight;
+
+ const clampPadDynamic = Math.min(140, minDimension * 0.18);
+ let shiftX = 0;
+ let shiftY = 0;
+ if (boxMinX < clampPadDynamic) shiftX = clampPadDynamic - boxMinX;
+ if (boxMaxX > width - clampPadDynamic) shiftX = (width - clampPadDynamic) - boxMaxX;
+ if (boxMinY < clampPadDynamic) shiftY = clampPadDynamic - boxMinY;
+ if (boxMaxY > height - clampPadDynamic) shiftY = (height - clampPadDynamic) - boxMaxY;
+
+ const finalX = labelX + shiftX;
+ const finalY = labelY + shiftY;
+ labelGroup.attr('transform', `translate(${finalX}, ${finalY})`);
+
+ labelGroup.insert('rect', 'g')
+ .attr('x', -paddedWidth / 2)
+ .attr('y', bbox.y - 4)
+ .attr('width', paddedWidth)
+ .attr('height', boxHeight)
+ .attr('rx', 4)
+ .attr('fill', 'rgba(0,0,0,0.75)')
+ .attr('stroke', 'rgba(255,255,255,0.12)')
+ .attr('stroke-width', 0.6);
+ }
+ }
+ });
+
+ // Draw nodes
+ const nodesGroup = svg.append('g').attr('class', 'nodes-group');
+
+ nodesWithPositions.forEach(nodeInfo => {
+ const node = nodeInfo.data;
+ const macmon = node.macmon_info;
+ const modelId = node.system_info?.model_id || 'Unknown';
+ const friendlyName = node.friendly_name || modelId;
+
+ let ramUsagePercent = 0;
+ let gpuTemp = NaN;
+ let ramTotal = 0;
+ let ramUsed = 0;
+ let gpuUsagePercent = 0;
+ let sysPower: number | null = null;
+
+ if (macmon) {
+ if (macmon.memory && macmon.memory.ram_total > 0) {
+ ramUsagePercent = (macmon.memory.ram_usage / macmon.memory.ram_total) * 100;
+ ramTotal = macmon.memory.ram_total;
+ ramUsed = macmon.memory.ram_usage;
+ }
+ if (macmon.temp && typeof macmon.temp.gpu_temp_avg === 'number') {
+ gpuTemp = Math.max(30, macmon.temp.gpu_temp_avg);
+ }
+ if (macmon.gpu_usage) {
+ gpuUsagePercent = macmon.gpu_usage[1] * 100;
+ }
+ if (macmon.sys_power) {
+ sysPower = macmon.sys_power;
+ }
+ }
+
+ 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)
+ 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 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 screenFill = 'rgba(0,20,40,0.9)';
+ const glowColor = 'rgba(255,215,0,0.3)';
+
+ if (modelLower === 'mac studio') {
+ // Mac Studio - classic cube with memory fill
+ iconBaseWidth = nodeRadius * 1.25;
+ iconBaseHeight = nodeRadius * 0.85;
+ const x = nodeInfo.x - iconBaseWidth / 2;
+ const y = nodeInfo.y - iconBaseHeight / 2;
+ const cornerRadius = 4;
+ const topSurfaceHeight = iconBaseHeight * 0.15;
+
+ // Create clip path for memory fill area (front body)
+ const studioClipId = `studio-clip-${nodeInfo.id.replace(/[^a-zA-Z0-9]/g, '-')}`;
+ defs.append('clipPath')
+ .attr('id', studioClipId)
+ .append('rect')
+ .attr('x', x)
+ .attr('y', y + topSurfaceHeight)
+ .attr('width', iconBaseWidth)
+ .attr('height', iconBaseHeight - topSurfaceHeight)
+ .attr('rx', cornerRadius - 1);
+
+ // Main body (uniform color)
+ nodeG.append('rect')
+ .attr('x', x)
+ .attr('y', y)
+ .attr('width', iconBaseWidth)
+ .attr('height', iconBaseHeight)
+ .attr('rx', cornerRadius)
+ .attr('fill', '#1a1a1a')
+ .attr('stroke', wireColor)
+ .attr('stroke-width', strokeWidth);
+
+ // Memory fill (fills from bottom up)
+ if (ramUsagePercent > 0) {
+ const memFillTotalHeight = iconBaseHeight - topSurfaceHeight;
+ const memFillActualHeight = (ramUsagePercent / 100) * memFillTotalHeight;
+ nodeG.append('rect')
+ .attr('x', x)
+ .attr('y', y + topSurfaceHeight + (memFillTotalHeight - memFillActualHeight))
+ .attr('width', iconBaseWidth)
+ .attr('height', memFillActualHeight)
+ .attr('fill', 'rgba(255,215,0,0.75)')
+ .attr('clip-path', `url(#${studioClipId})`);
+ }
+
+ // Front panel details - vertical slots
+ const detailColor = 'rgba(0,0,0,0.35)';
+ const slotHeight = iconBaseHeight * 0.14;
+ const vSlotWidth = iconBaseWidth * 0.05;
+ const vSlotY = y + topSurfaceHeight + (iconBaseHeight - topSurfaceHeight) * 0.6;
+ const vSlot1X = x + iconBaseWidth * 0.18;
+ const vSlot2X = x + iconBaseWidth * 0.28;
+
+ [vSlot1X, vSlot2X].forEach(vx => {
+ nodeG.append('rect')
+ .attr('x', vx - vSlotWidth / 2)
+ .attr('y', vSlotY)
+ .attr('width', vSlotWidth)
+ .attr('height', slotHeight)
+ .attr('fill', detailColor)
+ .attr('rx', 1.5);
+ });
+
+ // Horizontal slot (SD card)
+ const hSlotWidth = iconBaseWidth * 0.2;
+ const hSlotX = x + iconBaseWidth * 0.5 - hSlotWidth / 2;
+ nodeG.append('rect')
+ .attr('x', hSlotX)
+ .attr('y', vSlotY)
+ .attr('width', hSlotWidth)
+ .attr('height', slotHeight * 0.6)
+ .attr('fill', detailColor)
+ .attr('rx', 1);
+
+ } else if (modelLower === 'mac mini') {
+ // Mac Mini - classic flat box with memory fill
+ iconBaseWidth = nodeRadius * 1.3;
+ iconBaseHeight = nodeRadius * 0.7;
+ const x = nodeInfo.x - iconBaseWidth / 2;
+ const y = nodeInfo.y - iconBaseHeight / 2;
+ const cornerRadius = 3;
+ const topSurfaceHeight = iconBaseHeight * 0.20;
+
+ // Create clip path for memory fill area
+ const miniClipId = `mini-clip-${nodeInfo.id.replace(/[^a-zA-Z0-9]/g, '-')}`;
+ defs.append('clipPath')
+ .attr('id', miniClipId)
+ .append('rect')
+ .attr('x', x)
+ .attr('y', y + topSurfaceHeight)
+ .attr('width', iconBaseWidth)
+ .attr('height', iconBaseHeight - topSurfaceHeight)
+ .attr('rx', cornerRadius - 1);
+
+ // Main body (uniform color)
+ nodeG.append('rect')
+ .attr('x', x)
+ .attr('y', y)
+ .attr('width', iconBaseWidth)
+ .attr('height', iconBaseHeight)
+ .attr('rx', cornerRadius)
+ .attr('fill', '#1a1a1a')
+ .attr('stroke', wireColor)
+ .attr('stroke-width', strokeWidth);
+
+ // Memory fill (fills from bottom up)
+ if (ramUsagePercent > 0) {
+ const memFillTotalHeight = iconBaseHeight - topSurfaceHeight;
+ const memFillActualHeight = (ramUsagePercent / 100) * memFillTotalHeight;
+ nodeG.append('rect')
+ .attr('x', x)
+ .attr('y', y + topSurfaceHeight + (memFillTotalHeight - memFillActualHeight))
+ .attr('width', iconBaseWidth)
+ .attr('height', memFillActualHeight)
+ .attr('fill', 'rgba(255,215,0,0.75)')
+ .attr('clip-path', `url(#${miniClipId})`);
+ }
+
+ // Front panel details - vertical slots (no horizontal slot for Mini)
+ const detailColor = 'rgba(0,0,0,0.35)';
+ const slotHeight = iconBaseHeight * 0.20;
+ const vSlotWidth = iconBaseWidth * 0.045;
+ const vSlotY = y + topSurfaceHeight + (iconBaseHeight - topSurfaceHeight) * 0.45;
+ const vSlot1X = x + iconBaseWidth * 0.20;
+ const vSlot2X = x + iconBaseWidth * 0.30;
+
+ [vSlot1X, vSlot2X].forEach(vx => {
+ nodeG.append('rect')
+ .attr('x', vx - vSlotWidth / 2)
+ .attr('y', vSlotY)
+ .attr('width', vSlotWidth)
+ .attr('height', slotHeight)
+ .attr('fill', detailColor)
+ .attr('rx', 1.2);
+ });
+
+ } else if (modelLower === 'macbook pro' || modelLower.includes('macbook')) {
+ // MacBook Pro - classic style with memory fill on screen
+ iconBaseWidth = nodeRadius * 1.6;
+ iconBaseHeight = nodeRadius * 1.15;
+ const x = nodeInfo.x - iconBaseWidth / 2;
+ const y = nodeInfo.y - iconBaseHeight / 2;
+
+ const screenHeight = iconBaseHeight * 0.70;
+ const baseHeight = iconBaseHeight * 0.30;
+ const screenWidth = iconBaseWidth * 0.85;
+ const screenX = nodeInfo.x - screenWidth / 2;
+ const screenBezel = 3;
+
+ // Create clip path for screen content
+ const screenClipId = `screen-clip-${nodeInfo.id.replace(/[^a-zA-Z0-9]/g, '-')}`;
+ defs.append('clipPath')
+ .attr('id', screenClipId)
+ .append('rect')
+ .attr('x', screenX + screenBezel)
+ .attr('y', y + screenBezel)
+ .attr('width', screenWidth - screenBezel * 2)
+ .attr('height', screenHeight - screenBezel * 2)
+ .attr('rx', 2);
+
+ // Screen outer frame
+ nodeG.append('rect')
+ .attr('x', screenX)
+ .attr('y', y)
+ .attr('width', screenWidth)
+ .attr('height', screenHeight)
+ .attr('rx', 3)
+ .attr('fill', '#1a1a1a')
+ .attr('stroke', wireColor)
+ .attr('stroke-width', strokeWidth);
+
+ // Screen inner (dark background)
+ nodeG.append('rect')
+ .attr('x', screenX + screenBezel)
+ .attr('y', y + screenBezel)
+ .attr('width', screenWidth - screenBezel * 2)
+ .attr('height', screenHeight - screenBezel * 2)
+ .attr('rx', 2)
+ .attr('fill', '#0a0a12');
+
+ // Memory fill on screen (fills from bottom up - classic style)
+ if (ramUsagePercent > 0) {
+ const memFillTotalHeight = screenHeight - screenBezel * 2;
+ const memFillActualHeight = (ramUsagePercent / 100) * memFillTotalHeight;
+ nodeG.append('rect')
+ .attr('x', screenX + screenBezel)
+ .attr('y', y + screenBezel + (memFillTotalHeight - memFillActualHeight))
+ .attr('width', screenWidth - screenBezel * 2)
+ .attr('height', memFillActualHeight)
+ .attr('fill', 'rgba(255,215,0,0.85)')
+ .attr('clip-path', `url(#${screenClipId})`);
+ }
+
+ // Apple logo on screen (centered, on top of memory fill)
+ const targetLogoHeight = screenHeight * 0.22;
+ const logoScale = targetLogoHeight / LOGO_NATIVE_HEIGHT;
+ const logoX = nodeInfo.x - (LOGO_NATIVE_WIDTH * logoScale / 2);
+ const logoY = y + screenHeight / 2 - (LOGO_NATIVE_HEIGHT * logoScale / 2);
+ nodeG.append('path')
+ .attr('d', APPLE_LOGO_PATH)
+ .attr('transform', `translate(${logoX}, ${logoY}) scale(${logoScale})`)
+ .attr('fill', '#FFFFFF')
+ .attr('opacity', 0.9);
+
+ // Base (keyboard) - trapezoidal
+ const baseY = y + screenHeight;
+ const baseTopWidth = screenWidth;
+ const baseBottomWidth = iconBaseWidth;
+ const baseTopX = nodeInfo.x - baseTopWidth / 2;
+ const baseBottomX = nodeInfo.x - baseBottomWidth / 2;
+
+ nodeG.append('path')
+ .attr('d', `M ${baseTopX} ${baseY} L ${baseTopX + baseTopWidth} ${baseY} L ${baseBottomX + baseBottomWidth} ${baseY + baseHeight} L ${baseBottomX} ${baseY + baseHeight} Z`)
+ .attr('fill', '#2c2c2c')
+ .attr('stroke', wireColor)
+ .attr('stroke-width', 1);
+
+ // Keyboard area
+ const keyboardX = baseTopX + 6;
+ const keyboardY = baseY + 3;
+ const keyboardWidth = baseTopWidth - 12;
+ const keyboardHeight = baseHeight * 0.55;
+ nodeG.append('rect')
+ .attr('x', keyboardX)
+ .attr('y', keyboardY)
+ .attr('width', keyboardWidth)
+ .attr('height', keyboardHeight)
+ .attr('fill', 'rgba(0,0,0,0.2)')
+ .attr('rx', 2);
+
+ // Trackpad
+ const trackpadWidth = baseTopWidth * 0.4;
+ const trackpadX = nodeInfo.x - trackpadWidth / 2;
+ const trackpadY = baseY + keyboardHeight + 5;
+ const trackpadHeight = baseHeight * 0.30;
+ nodeG.append('rect')
+ .attr('x', trackpadX)
+ .attr('y', trackpadY)
+ .attr('width', trackpadWidth)
+ .attr('height', trackpadHeight)
+ .attr('fill', 'rgba(255,255,255,0.08)')
+ .attr('rx', 2);
+
+ } else {
+ // Default/Unknown - holographic hexagon
+ const hexRadius = nodeRadius * 0.6;
+ const hexPoints = Array.from({ length: 6 }, (_, i) => {
+ const angle = (i * 60 - 30) * Math.PI / 180;
+ return `${nodeInfo.x + hexRadius * Math.cos(angle)},${nodeInfo.y + hexRadius * Math.sin(angle)}`;
+ }).join(' ');
+
+ // Main shape
+ nodeG.append('polygon')
+ .attr('points', hexPoints)
+ .attr('fill', fillColor)
+ .attr('stroke', wireColor)
+ .attr('stroke-width', strokeWidth);
+ }
+
+ // --- Vertical GPU Bar (right side of icon) ---
+ // Show in both full mode and minimized mode (scaled appropriately)
+ if (showFullLabels || isMinimized) {
+ const gpuBarWidth = isMinimized ? Math.max(16, nodeRadius * 0.32) : Math.max(28, nodeRadius * 0.30);
+ const gpuBarHeight = iconBaseHeight * 0.95;
+ const barXOffset = iconBaseWidth / 2 + (isMinimized ? 5 : 10);
+ const gpuBarX = nodeInfo.x + barXOffset;
+ const gpuBarY = nodeInfo.y - gpuBarHeight / 2;
+
+ // GPU Bar Background (grey, no border)
+ nodeG.append('rect')
+ .attr('x', gpuBarX)
+ .attr('y', gpuBarY)
+ .attr('width', gpuBarWidth)
+ .attr('height', gpuBarHeight)
+ .attr('fill', 'rgba(80, 80, 90, 0.7)')
+ .attr('rx', 2);
+
+ // GPU Bar Fill (from bottom up, colored by temperature)
+ if (gpuUsagePercent > 0) {
+ const fillHeight = (gpuUsagePercent / 100) * gpuBarHeight;
+ const gpuFillColor = getTemperatureColor(gpuTemp);
+ nodeG.append('rect')
+ .attr('x', gpuBarX)
+ .attr('y', gpuBarY + (gpuBarHeight - fillHeight))
+ .attr('width', gpuBarWidth)
+ .attr('height', fillHeight)
+ .attr('fill', gpuFillColor)
+ .attr('opacity', 0.9)
+ .attr('rx', 2);
+ }
+
+ // GPU Stats Text (centered on bar, multiline, bigger and bold)
+ const gpuTextX = gpuBarX + gpuBarWidth / 2;
+ const gpuTextY = gpuBarY + gpuBarHeight / 2;
+ const gpuTextFontSize = isMinimized ? Math.max(10, gpuBarWidth * 0.6) : Math.min(16, Math.max(12, gpuBarWidth * 0.55));
+ const lineSpacing = gpuTextFontSize * 1.25;
+
+ const gpuUsageText = `${gpuUsagePercent.toFixed(0)}%`;
+ const tempText = !isNaN(gpuTemp) ? `${gpuTemp.toFixed(0)}°C` : '-';
+ const powerText = sysPower !== null ? `${sysPower.toFixed(0)}W` : '-';
+
+ // GPU Usage %
+ nodeG.append('text')
+ .attr('x', gpuTextX)
+ .attr('y', gpuTextY - lineSpacing)
+ .attr('text-anchor', 'middle')
+ .attr('dominant-baseline', 'middle')
+ .attr('fill', '#FFFFFF')
+ .attr('font-size', gpuTextFontSize)
+ .attr('font-weight', '700')
+ .attr('font-family', 'SF Mono, Monaco, monospace')
+ .text(gpuUsageText);
+
+ // Temperature
+ nodeG.append('text')
+ .attr('x', gpuTextX)
+ .attr('y', gpuTextY)
+ .attr('text-anchor', 'middle')
+ .attr('dominant-baseline', 'middle')
+ .attr('fill', '#FFFFFF')
+ .attr('font-size', gpuTextFontSize)
+ .attr('font-weight', '700')
+ .attr('font-family', 'SF Mono, Monaco, monospace')
+ .text(tempText);
+
+ // Power (Watts)
+ nodeG.append('text')
+ .attr('x', gpuTextX)
+ .attr('y', gpuTextY + lineSpacing)
+ .attr('text-anchor', 'middle')
+ .attr('dominant-baseline', 'middle')
+ .attr('fill', '#FFFFFF')
+ .attr('font-size', gpuTextFontSize)
+ .attr('font-weight', '700')
+ .attr('font-family', 'SF Mono, Monaco, monospace')
+ .text(powerText);
+ }
+
+ // Labels - adapt based on mode
+ if (showFullLabels) {
+ // FULL MODE: Name above, memory info below (1-4 nodes)
+ const nameY = nodeInfo.y - iconBaseHeight / 2 - 15;
+ const fontSize = Math.max(10, nodeRadius * 0.16);
+
+ // Truncate name based on node count
+ const maxNameLen = numNodes === 1 ? 22 : (numNodes === 2 ? 18 : numNodes === 3 ? 16 : 14);
+ const displayName = friendlyName.length > maxNameLen
+ ? friendlyName.slice(0, maxNameLen - 2) + '..'
+ : friendlyName;
+
+ // Name label above
+ nodeG.append('text')
+ .attr('x', nodeInfo.x)
+ .attr('y', nameY)
+ .attr('text-anchor', 'middle')
+ .attr('dominant-baseline', 'middle')
+ .attr('fill', '#FFD700')
+ .attr('font-size', fontSize)
+ .attr('font-weight', 500)
+ .attr('font-family', 'SF Mono, Monaco, monospace')
+ .text(displayName);
+
+ // Memory info below - used in grey, total in yellow
+ const infoY = nodeInfo.y + iconBaseHeight / 2 + 16;
+ const memText = nodeG.append('text')
+ .attr('x', nodeInfo.x)
+ .attr('y', infoY)
+ .attr('text-anchor', 'middle')
+ .attr('font-size', fontSize * 0.85)
+ .attr('font-family', 'SF Mono, Monaco, monospace');
+ memText.append('tspan')
+ .attr('fill', 'rgba(255,215,0,0.9)')
+ .text(`${formatBytes(ramUsed)}`);
+ memText.append('tspan')
+ .attr('fill', 'rgba(179,179,179,0.9)')
+ .text(`/${formatBytes(ramTotal)}`);
+ memText.append('tspan')
+ .attr('fill', 'rgba(179,179,179,0.7)')
+ .text(` (${ramUsagePercent.toFixed(0)}%)`);
+
+ } else if (showCompactLabels) {
+ // COMPACT MODE: Just name and basic info (4+ nodes)
+ const fontSize = Math.max(7, nodeRadius * 0.11);
+
+ // Very compact name below icon
+ const nameY = nodeInfo.y + iconBaseHeight / 2 + 9;
+ const shortName = friendlyName.length > 10
+ ? friendlyName.slice(0, 8) + '..'
+ : friendlyName;
+ nodeG.append('text')
+ .attr('x', nodeInfo.x)
+ .attr('y', nameY)
+ .attr('text-anchor', 'middle')
+ .attr('fill', '#FFD700')
+ .attr('font-size', fontSize)
+ .attr('font-family', 'SF Mono, Monaco, monospace')
+ .text(shortName);
+
+ // Single line of key stats
+ const statsY = nameY + 9;
+ nodeG.append('text')
+ .attr('x', nodeInfo.x)
+ .attr('y', statsY)
+ .attr('text-anchor', 'middle')
+ .attr('fill', 'rgba(255,215,0,0.7)')
+ .attr('font-size', fontSize * 0.85)
+ .attr('font-family', 'SF Mono, Monaco, monospace')
+ .text(`${ramUsagePercent.toFixed(0)}%${!isNaN(gpuTemp) ? ' ' + gpuTemp.toFixed(0) + '°C' : ''}`);
+
+ } else {
+ // MINIMIZED MODE: Show name above and memory info below (like main topology)
+ const fontSize = 8;
+
+ // Friendly name (shortened) above icon
+ const nameY = nodeInfo.y - iconBaseHeight / 2 - 8;
+ const shortName = friendlyName.length > 12
+ ? friendlyName.slice(0, 10) + '..'
+ : friendlyName;
+ nodeG.append('text')
+ .attr('x', nodeInfo.x)
+ .attr('y', nameY)
+ .attr('text-anchor', 'middle')
+ .attr('fill', '#FFD700')
+ .attr('font-size', fontSize)
+ .attr('font-weight', '500')
+ .attr('font-family', 'SF Mono, Monaco, monospace')
+ .text(shortName);
+
+ // Memory info below icon - used in grey, total in yellow (same as main topology)
+ const infoY = nodeInfo.y + iconBaseHeight / 2 + 10;
+ const memTextMini = nodeG.append('text')
+ .attr('x', nodeInfo.x)
+ .attr('y', infoY)
+ .attr('text-anchor', 'middle')
+ .attr('font-size', fontSize * 0.85)
+ .attr('font-family', 'SF Mono, Monaco, monospace');
+ memTextMini.append('tspan')
+ .attr('fill', 'rgba(255,215,0,0.9)')
+ .text(`${formatBytes(ramUsed)}`);
+ memTextMini.append('tspan')
+ .attr('fill', 'rgba(179,179,179,0.9)')
+ .text(`/${formatBytes(ramTotal)}`);
+ memTextMini.append('tspan')
+ .attr('fill', 'rgba(179,179,179,0.7)')
+ .text(` (${ramUsagePercent.toFixed(0)}%)`);
+ }
+ });
+
+ }
+
+ $effect(() => {
+ if (data) {
+ renderGraph();
+ }
+ });
+
+ onMount(() => {
+ if (svgContainer) {
+ resizeObserver = new ResizeObserver(() => {
+ renderGraph();
+ });
+ resizeObserver.observe(svgContainer);
+ }
+ });
+
+ onDestroy(() => {
+ resizeObserver?.disconnect();
+ });
+</script>
+
+<svg
+ bind:this={svgContainer}
+ class="w-full h-full {className}"
+></svg>
+
+<style>
+ :global(.graph-node) {
+ transition: transform 0.2s ease, opacity 0.2s ease;
+ }
+ :global(.graph-node:hover) {
+ filter: brightness(1.1);
+ }
+ :global(.graph-link) {
+ stroke: var(--exo-light-gray, #B3B3B3);
+ stroke-width: 1px;
+ stroke-dasharray: 4, 4;
+ opacity: 0.8;
+ animation: flowAnimation 0.75s linear infinite;
+ }
+ @keyframes flowAnimation {
+ from { stroke-dashoffset: 0; }
+ to { stroke-dashoffset: -10; }
+ }
+</style>
diff --git a/dashboard/src/lib/components/index.ts b/dashboard/src/lib/components/index.ts
new file mode 100644
index 00000000..bd750839
--- /dev/null
+++ b/dashboard/src/lib/components/index.ts
@@ -0,0 +1,7 @@
+export { default as TopologyGraph } from './TopologyGraph.svelte';
+export { default as ChatForm } from './ChatForm.svelte';
+export { default as ChatMessages } from './ChatMessages.svelte';
+export { default as ChatAttachments } from './ChatAttachments.svelte';
+export { default as ChatSidebar } from './ChatSidebar.svelte';
+export { default as ModelCard } from './ModelCard.svelte';
+
diff --git a/dashboard/src/lib/stores/app.svelte.ts b/dashboard/src/lib/stores/app.svelte.ts
new file mode 100644
index 00000000..ffeb1aa1
--- /dev/null
+++ b/dashboard/src/lib/stores/app.svelte.ts
@@ -0,0 +1,1395 @@
+/**
+ * AppStore - Central state management for the EXO dashboard
+ *
+ * Manages:
+ * - Chat state (whether a conversation has started)
+ * - Topology data from the EXO server
+ * - UI state for the topology/chat transition
+ */
+
+import { browser } from '$app/environment';
+
+// UUID generation fallback for browsers without crypto.randomUUID
+function generateUUID(): string {
+ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
+ return crypto.randomUUID();
+ }
+ // Fallback implementation
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
+ const r = Math.random() * 16 | 0;
+ const v = c === 'x' ? r : (r & 0x3 | 0x8);
+ return v.toString(16);
+ });
+}
+
+export interface NodeInfo {
+ system_info?: {
+ model_id?: string;
+ chip?: string;
+ memory?: number;
+ };
+ network_interfaces?: Array<{
+ name?: string;
+ addresses?: string[];
+ }>;
+ ip_to_interface?: Record<string, string>;
+ macmon_info?: {
+ memory?: {
+ ram_usage: number;
+ ram_total: number;
+ };
+ temp?: {
+ gpu_temp_avg: number;
+ };
+ gpu_usage?: [number, number];
+ sys_power?: number;
+ };
+ last_macmon_update: number;
+ friendly_name?: string;
+}
+
+export interface TopologyEdge {
+ source: string;
+ target: string;
+ sendBackIp?: string;
+ sendBackInterface?: string;
+}
+
+export interface TopologyData {
+ nodes: Record<string, NodeInfo>;
+ edges: TopologyEdge[];
+}
+
+export interface Instance {
+ shardAssignments?: {
+ modelId?: string;
+ runnerToShard?: Record<string, unknown>;
+ nodeToRunner?: Record<string, string>;
+ };
+}
+
+interface RawNodeProfile {
+ modelId?: string;
+ chipId?: string;
+ friendlyName?: string;
+ networkInterfaces?: Array<{
+ name?: string;
+ ipAddress?: string;
+ addresses?: Array<{ address?: string } | string>;
+ ipv4?: string;
+ ipv6?: string;
+ ipAddresses?: string[];
+ ips?: string[];
+ }>;
+ memory?: {
+ ramTotal?: { inBytes: number };
+ ramAvailable?: { inBytes: number };
+ swapTotal?: { inBytes: number };
+ swapAvailable?: { inBytes: number };
+ };
+ system?: {
+ gpuUsage?: number;
+ temp?: number;
+ sysPower?: number;
+ };
+}
+
+interface RawTopologyNode {
+ nodeId: string;
+ nodeProfile: RawNodeProfile;
+}
+
+interface RawTopologyConnection {
+ localNodeId: string;
+ sendBackNodeId: string;
+ sendBackMultiaddr?: { multiaddr?: string; address?: string; ip_address?: string } | string;
+}
+
+interface RawTopology {
+ nodes: RawTopologyNode[];
+ connections?: RawTopologyConnection[];
+}
+
+type RawNodeProfiles = Record<string, RawNodeProfile>;
+
+export interface DownloadProgress {
+ totalBytes: number;
+ downloadedBytes: number;
+ speed: number;
+ etaMs: number;
+ percentage: number;
+ completedFiles: number;
+ totalFiles: number;
+ files: Array<{
+ name: string;
+ totalBytes: number;
+ downloadedBytes: number;
+ speed: number;
+ etaMs: number;
+ percentage: number;
+ }>;
+}
+
+export interface ModelDownloadStatus {
+ isDownloading: boolean;
+ progress: DownloadProgress | null;
+ nodeDetails: Array<{
+ nodeId: string;
+ nodeName: string;
+ progress: DownloadProgress;
+ }>;
+}
+
+// Placement preview from the API
+export interface PlacementPreview {
+ model_id: string;
+ sharding: 'Pipeline' | 'Tensor';
+ instance_meta: 'MlxRing' | 'MlxIbv' | 'MlxJaccl';
+ instance: unknown | null;
+ memory_delta_by_node: Record<string, number> | null;
+ error: string | null;
+}
+
+export interface PlacementPreviewResponse {
+ previews: PlacementPreview[];
+}
+
+interface RawStateResponse {
+ topology?: RawTopology;
+ instances?: Record<string, { MlxRingInstance?: Instance; MlxIbvInstance?: Instance; MlxJacclInstance?: Instance }>;
+ runners?: Record<string, unknown>;
+ downloads?: Record<string, unknown[]>;
+ nodeProfiles?: RawNodeProfiles;
+}
+
+export interface MessageAttachment {
+ type: 'image' | 'text' | 'file';
+ name: string;
+ content?: string;
+ preview?: string;
+ mimeType?: string;
+}
+
+export interface Message {
+ id: string;
+ role: 'user' | 'assistant' | 'system';
+ content: string;
+ timestamp: number;
+ thinking?: string;
+ attachments?: MessageAttachment[];
+ ttftMs?: number; // Time to first token in ms (for assistant messages)
+ tps?: number; // Tokens per second (for assistant messages)
+}
+
+export interface Conversation {
+ id: string;
+ name: string;
+ messages: Message[];
+ createdAt: number;
+ updatedAt: number;
+ modelId: string | null;
+ sharding: string | null;
+ instanceType: string | null;
+}
+
+const STORAGE_KEY = 'exo-conversations';
+
+function transformTopology(raw: RawTopology, profiles?: RawNodeProfiles): TopologyData {
+ const nodes: Record<string, NodeInfo> = {};
+ const edges: TopologyEdge[] = [];
+
+ for (const node of raw.nodes || []) {
+ const mergedProfile = profiles?.[node.nodeId];
+ const profile = { ...(node.nodeProfile ?? {}), ...(mergedProfile ?? {}) };
+ const ramTotal = profile?.memory?.ramTotal?.inBytes ?? 0;
+ const ramAvailable = profile?.memory?.ramAvailable?.inBytes ?? 0;
+ const ramUsage = Math.max(ramTotal - ramAvailable, 0);
+
+ const networkInterfaces = (profile?.networkInterfaces || []).map((iface) => {
+ const addresses: string[] = [];
+ if (iface.ipAddress && typeof iface.ipAddress === 'string') {
+ addresses.push(iface.ipAddress);
+ }
+ if (Array.isArray(iface.addresses)) {
+ for (const addr of iface.addresses) {
+ if (typeof addr === 'string') addresses.push(addr);
+ else if (addr && typeof addr === 'object' && addr.address) addresses.push(addr.address);
+ }
+ }
+ if (Array.isArray(iface.ipAddresses)) {
+ addresses.push(...iface.ipAddresses.filter((a): a is string => typeof a === 'string'));
+ }
+ if (Array.isArray(iface.ips)) {
+ addresses.push(...iface.ips.filter((a): a is string => typeof a === 'string'));
+ }
+ if (iface.ipv4 && typeof iface.ipv4 === 'string') addresses.push(iface.ipv4);
+ if (iface.ipv6 && typeof iface.ipv6 === 'string') addresses.push(iface.ipv6);
+
+ return {
+ name: iface.name,
+ addresses: Array.from(new Set(addresses))
+ };
+ });
+
+ const ipToInterface: Record<string, string> = {};
+ for (const iface of networkInterfaces) {
+ for (const addr of iface.addresses || []) {
+ ipToInterface[addr] = iface.name ?? '';
+ }
+ }
+
+ nodes[node.nodeId] = {
+ system_info: {
+ model_id: profile?.modelId ?? 'Unknown',
+ chip: profile?.chipId,
+ memory: ramTotal
+ },
+ network_interfaces: networkInterfaces,
+ ip_to_interface: ipToInterface,
+ macmon_info: {
+ memory: {
+ ram_usage: ramUsage,
+ ram_total: ramTotal
+ },
+ temp: profile?.system?.temp !== undefined ? { gpu_temp_avg: profile.system.temp } : undefined,
+ gpu_usage: profile?.system?.gpuUsage !== undefined ? [0, profile.system.gpuUsage] : undefined,
+ sys_power: profile?.system?.sysPower
+ },
+ last_macmon_update: Date.now() / 1000,
+ friendly_name: profile?.friendlyName
+ };
+ }
+
+ for (const conn of raw.connections || []) {
+ if (!conn.localNodeId || !conn.sendBackNodeId) continue;
+ if (conn.localNodeId === conn.sendBackNodeId) continue;
+ if (!nodes[conn.localNodeId] || !nodes[conn.sendBackNodeId]) continue;
+
+ let sendBackIp: string | undefined;
+ if (conn.sendBackMultiaddr) {
+ const multi = conn.sendBackMultiaddr;
+ if (typeof multi === 'string') {
+ sendBackIp = extractIpFromMultiaddr(multi);
+ } else {
+ sendBackIp = multi.ip_address || extractIpFromMultiaddr(multi.multiaddr) || extractIpFromMultiaddr(multi.address);
+ }
+ }
+
+ edges.push({
+ source: conn.localNodeId,
+ target: conn.sendBackNodeId,
+ sendBackIp
+ });
+ }
+
+ return { nodes, edges };
+}
+
+function extractIpFromMultiaddr(ma?: string): string | undefined {
+ if (!ma) return undefined;
+ const parts = ma.split('/');
+ const ip4Idx = parts.indexOf('ip4');
+ const ip6Idx = parts.indexOf('ip6');
+ const idx = ip4Idx >= 0 ? ip4Idx : ip6Idx;
+ if (idx >= 0 && parts.length > idx + 1) {
+ return parts[idx + 1];
+ }
+ return undefined;
+}
+
+class AppStore {
+ // Conversation state
+ conversations = $state<Conversation[]>([]);
+ activeConversationId = $state<string | null>(null);
+
+ // Chat state
+ hasStartedChat = $state(false);
+ messages = $state<Message[]>([]);
+ currentResponse = $state('');
+ isLoading = $state(false);
+
+ // Performance metrics
+ ttftMs = $state<number | null>(null); // Time to first token in ms
+ tps = $state<number | null>(null); // Tokens per second
+ totalTokens = $state<number>(0); // Total tokens in current response
+
+ // Topology state
+ topologyData = $state<TopologyData | null>(null);
+ instances = $state<Record<string, unknown>>({});
+ runners = $state<Record<string, unknown>>({});
+ downloads = $state<Record<string, unknown[]>>({});
+ placementPreviews = $state<PlacementPreview[]>([]);
+ selectedPreviewModelId = $state<string | null>(null);
+ isLoadingPreviews = $state(false);
+ lastUpdate = $state<number | null>(null);
+
+ // UI state
+ isTopologyMinimized = $state(false);
+ isSidebarOpen = $state(false); // Hidden by default, shown when in chat mode
+ debugMode = $state(false);
+
+ private fetchInterval: ReturnType<typeof setInterval> | null = null;
+ private previewsInterval: ReturnType<typeof setInterval> | null = null;
+ private lastConversationPersistTs = 0;
+
+ constructor() {
+ if (browser) {
+ this.startPolling();
+ this.loadConversationsFromStorage();
+ this.loadDebugModeFromStorage();
+ }
+ }
+
+ /**
+ * Load conversations from localStorage
+ */
+ private loadConversationsFromStorage() {
+ try {
+ const stored = localStorage.getItem(STORAGE_KEY);
+ if (stored) {
+ const parsed = JSON.parse(stored) as Array<Partial<Conversation>>;
+ this.conversations = parsed.map((conversation) => ({
+ id: conversation.id ?? generateUUID(),
+ name: conversation.name ?? 'Chat',
+ messages: conversation.messages ?? [],
+ createdAt: conversation.createdAt ?? Date.now(),
+ updatedAt: conversation.updatedAt ?? Date.now(),
+ modelId: conversation.modelId ?? null,
+ sharding: conversation.sharding ?? null,
+ instanceType: conversation.instanceType ?? null
+ }));
+ }
+ } catch (error) {
+ console.error('Failed to load conversations:', error);
+ }
+ }
+
+ /**
+ * Save conversations to localStorage
+ */
+ private saveConversationsToStorage() {
+ try {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(this.conversations));
+ } catch (error) {
+ console.error('Failed to save conversations:', error);
+ }
+ }
+
+ private loadDebugModeFromStorage() {
+ try {
+ const stored = localStorage.getItem('exo-debug-mode');
+ if (stored !== null) {
+ this.debugMode = stored === 'true';
+ }
+ } catch (error) {
+ console.error('Failed to load debug mode:', error);
+ }
+ }
+
+ private saveDebugModeToStorage() {
+ try {
+ localStorage.setItem('exo-debug-mode', this.debugMode ? 'true' : 'false');
+ } catch (error) {
+ console.error('Failed to save debug mode:', error);
+ }
+ }
+
+ /**
+ * Create a new conversation
+ */
+ createConversation(name?: string): string {
+ const id = generateUUID();
+ const now = Date.now();
+
+ // Try to derive model and strategy immediately from selected model or running instances
+ let derivedModelId = this.selectedChatModel || null;
+ let derivedInstanceType: string | null = null;
+ let derivedSharding: string | null = null;
+
+ // If no selected model, fall back to the first running instance
+ if (!derivedModelId) {
+ const firstInstance = Object.values(this.instances)[0];
+ if (firstInstance) {
+ const candidateModel = this.extractInstanceModelId(firstInstance);
+ derivedModelId = candidateModel ?? null;
+ const details = this.describeInstance(firstInstance);
+ derivedInstanceType = details.instanceType;
+ derivedSharding = details.sharding;
+ }
+ } else {
+ // If selected model is set, attempt to get its details from instances
+ for (const [, instanceWrapper] of Object.entries(this.instances)) {
+ const candidateModelId = this.extractInstanceModelId(instanceWrapper);
+ if (candidateModelId === derivedModelId) {
+ const details = this.describeInstance(instanceWrapper);
+ derivedInstanceType = details.instanceType;
+ derivedSharding = details.sharding;
+ break;
+ }
+ }
+ }
+
+ const conversation: Conversation = {
+ id,
+ name: name || `Chat ${new Date(now).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}`,
+ messages: [],
+ createdAt: now,
+ updatedAt: now,
+ modelId: derivedModelId,
+ sharding: derivedSharding,
+ instanceType: derivedInstanceType
+ };
+
+ this.conversations.unshift(conversation);
+ this.activeConversationId = id;
+ this.messages = [];
+ this.hasStartedChat = true;
+ this.isTopologyMinimized = true;
+ this.isSidebarOpen = true; // Auto-open sidebar when chatting
+
+ this.saveConversationsToStorage();
+ return id;
+ }
+
+ /**
+ * Load a conversation by ID
+ */
+ loadConversation(id: string): boolean {
+ const conversation = this.conversations.find(c => c.id === id);
+ if (!conversation) return false;
+
+ this.activeConversationId = id;
+ this.messages = [...conversation.messages];
+ this.hasStartedChat = true;
+ this.isTopologyMinimized = true;
+ this.isSidebarOpen = true; // Auto-open sidebar when chatting
+ this.refreshConversationModelFromInstances();
+
+ return true;
+ }
+
+ /**
+ * Delete a conversation by ID
+ */
+ deleteConversation(id: string) {
+ this.conversations = this.conversations.filter(c => c.id !== id);
+
+ if (this.activeConversationId === id) {
+ this.activeConversationId = null;
+ this.messages = [];
+ this.hasStartedChat = false;
+ this.isTopologyMinimized = false;
+ }
+
+ this.saveConversationsToStorage();
+ }
+
+ /**
+ * Delete all conversations
+ */
+ deleteAllConversations() {
+ this.conversations = [];
+ this.activeConversationId = null;
+ this.messages = [];
+ this.hasStartedChat = false;
+ this.isTopologyMinimized = false;
+ this.saveConversationsToStorage();
+ }
+
+ /**
+ * Rename a conversation
+ */
+ renameConversation(id: string, newName: string) {
+ const conversation = this.conversations.find(c => c.id === id);
+ if (conversation) {
+ conversation.name = newName;
+ conversation.updatedAt = Date.now();
+ this.saveConversationsToStorage();
+ }
+ }
+
+ private getTaggedValue(obj: unknown): [string | null, unknown] {
+ if (!obj || typeof obj !== 'object') return [null, null];
+ const keys = Object.keys(obj as Record<string, unknown>);
+ if (keys.length === 1) {
+ return [keys[0], (obj as Record<string, unknown>)[keys[0]]];
+ }
+ return [null, null];
+ }
+
+ private extractInstanceModelId(instanceWrapped: unknown): string | null {
+ const [, instance] = this.getTaggedValue(instanceWrapped);
+ if (!instance || typeof instance !== 'object') return null;
+ const inst = instance as { shardAssignments?: { modelId?: string } };
+ return inst.shardAssignments?.modelId ?? null;
+ }
+
+ private describeInstance(instanceWrapped: unknown): { sharding: string | null; instanceType: string | null } {
+ const [instanceTag, instance] = this.getTaggedValue(instanceWrapped);
+ if (!instance || typeof instance !== 'object') {
+ return { sharding: null, instanceType: null };
+ }
+
+ let instanceType: string | null = null;
+ if (instanceTag === 'MlxRingInstance') instanceType = 'MLX Ring';
+ else if (instanceTag === 'MlxIbvInstance' || instanceTag === 'MlxJacclInstance') instanceType = 'MLX RDMA';
+
+ let sharding: string | null = null;
+ const inst = instance as { shardAssignments?: { runnerToShard?: Record<string, unknown> } };
+ const runnerToShard = inst.shardAssignments?.runnerToShard || {};
+ const firstShardWrapped = Object.values(runnerToShard)[0];
+ if (firstShardWrapped) {
+ const [shardTag] = this.getTaggedValue(firstShardWrapped);
+ if (shardTag === 'PipelineShardMetadata') sharding = 'Pipeline';
+ else if (shardTag === 'TensorShardMetadata') sharding = 'Tensor';
+ else if (shardTag === 'PrefillDecodeShardMetadata') sharding = 'Prefill/Decode';
+ }
+
+ return { sharding, instanceType };
+ }
+
+ private buildConversationModelInfo(modelId: string): { modelId: string; sharding: string | null; instanceType: string | null } {
+ let sharding: string | null = null;
+ let instanceType: string | null = null;
+
+ for (const [, instanceWrapper] of Object.entries(this.instances)) {
+ const candidateModelId = this.extractInstanceModelId(instanceWrapper);
+ if (candidateModelId === modelId) {
+ const details = this.describeInstance(instanceWrapper);
+ sharding = details.sharding;
+ instanceType = details.instanceType;
+ break;
+ }
+ }
+
+ return { modelId, sharding, instanceType };
+ }
+
+ private applyConversationModelInfo(info: { modelId: string; sharding: string | null; instanceType: string | null }) {
+ if (!this.activeConversationId) return;
+ const conversation = this.conversations.find(c => c.id === this.activeConversationId);
+ if (!conversation) return;
+
+ // Keep the first known modelId stable; only backfill if missing
+ if (!conversation.modelId) {
+ conversation.modelId = info.modelId;
+ }
+ conversation.sharding = info.sharding;
+ conversation.instanceType = info.instanceType;
+ this.saveConversationsToStorage();
+ }
+
+ private getModelTail(modelId: string): string {
+ const parts = modelId.split('/');
+ return (parts[parts.length - 1] || modelId).toLowerCase();
+ }
+
+ private isBetterModelId(currentId: string | null, candidateId: string | null): boolean {
+ if (!candidateId) return false;
+ if (!currentId) return true;
+ const currentTail = this.getModelTail(currentId);
+ const candidateTail = this.getModelTail(candidateId);
+ return candidateTail.length > currentTail.length && candidateTail.startsWith(currentTail);
+ }
+
+ private refreshConversationModelFromInstances() {
+ if (!this.activeConversationId) return;
+ const conversation = this.conversations.find(c => c.id === this.activeConversationId);
+ if (!conversation) return;
+
+ // Prefer stored model; do not replace it once set. Only backfill when missing.
+ let modelId = conversation.modelId;
+
+ // If missing, try the selected model
+ if (!modelId && this.selectedChatModel) {
+ modelId = this.selectedChatModel;
+ }
+
+ // If still missing, fall back to first instance model
+ if (!modelId) {
+ const firstInstance = Object.values(this.instances)[0];
+ if (firstInstance) {
+ modelId = this.extractInstanceModelId(firstInstance);
+ }
+ }
+
+ if (!modelId) return;
+
+ // If a more specific instance modelId is available (e.g., adds "-4bit"), prefer it
+ let preferredModelId = modelId;
+ for (const [, instanceWrapper] of Object.entries(this.instances)) {
+ const candidate = this.extractInstanceModelId(instanceWrapper);
+ if (!candidate) continue;
+ if (candidate === preferredModelId) {
+ break;
+ }
+ if (this.isBetterModelId(preferredModelId, candidate)) {
+ preferredModelId = candidate;
+ }
+ }
+
+ if (this.isBetterModelId(conversation.modelId, preferredModelId)) {
+ conversation.modelId = preferredModelId;
+ }
+
+ const info = this.buildConversationModelInfo(preferredModelId);
+ const hasNewInfo = Boolean(info.sharding || info.instanceType || !conversation.modelId);
+ if (hasNewInfo) {
+ this.applyConversationModelInfo(info);
+ }
+ }
+
+ getDebugMode(): boolean {
+ return this.debugMode;
+ }
+
+ /**
+ * Update the active conversation with current messages
+ */
+ private updateActiveConversation() {
+ if (!this.activeConversationId) return;
+
+ const conversation = this.conversations.find(c => c.id === this.activeConversationId);
+ if (conversation) {
+ conversation.messages = [...this.messages];
+ conversation.updatedAt = Date.now();
+
+ // Auto-generate name from first user message if still has default name
+ if (conversation.name.startsWith('Chat ')) {
+ const firstUserMsg = conversation.messages.find(m => m.role === 'user' && m.content.trim());
+ if (firstUserMsg) {
+ // Clean up the content - remove file context markers and whitespace
+ let content = firstUserMsg.content
+ .replace(/\[File:.*?\][\s\S]*?```[\s\S]*?```/g, '') // Remove file attachments
+ .trim();
+
+ if (content) {
+ const preview = content.slice(0, 50);
+ conversation.name = preview.length < content.length ? preview + '...' : preview;
+ }
+ }
+ }
+
+ this.saveConversationsToStorage();
+ }
+ }
+
+ private persistActiveConversation(throttleMs = 400) {
+ const now = Date.now();
+ if (now - this.lastConversationPersistTs < throttleMs) return;
+ this.lastConversationPersistTs = now;
+ this.updateActiveConversation();
+ }
+
+ /**
+ * Toggle sidebar visibility
+ */
+ toggleSidebar() {
+ this.isSidebarOpen = !this.isSidebarOpen;
+ }
+
+ setDebugMode(enabled: boolean) {
+ this.debugMode = enabled;
+ this.saveDebugModeToStorage();
+ }
+
+ toggleDebugMode() {
+ this.debugMode = !this.debugMode;
+ this.saveDebugModeToStorage();
+ }
+
+ startPolling() {
+ this.fetchState();
+ this.fetchInterval = setInterval(() => this.fetchState(), 1000);
+ }
+
+ stopPolling() {
+ if (this.fetchInterval) {
+ clearInterval(this.fetchInterval);
+ this.fetchInterval = null;
+ }
+ this.stopPreviewsPolling();
+ }
+
+ async fetchState() {
+ try {
+ const response = await fetch('/state');
+ if (!response.ok) {
+ throw new Error(`Failed to fetch state: ${response.status}`);
+ }
+ const data: RawStateResponse = await response.json();
+
+ if (data.topology) {
+ this.topologyData = transformTopology(data.topology, data.nodeProfiles);
+ }
+ if (data.instances) {
+ this.instances = data.instances;
+ this.refreshConversationModelFromInstances();
+ }
+ if (data.runners) {
+ this.runners = data.runners;
+ }
+ if (data.downloads) {
+ this.downloads = data.downloads;
+ }
+ this.lastUpdate = Date.now();
+ } catch (error) {
+ console.error('Error fetching state:', error);
+ }
+ }
+
+ async fetchPlacementPreviews(modelId: string, showLoading = true) {
+ if (!modelId) return;
+
+ if (showLoading) {
+ this.isLoadingPreviews = true;
+ }
+ this.selectedPreviewModelId = modelId;
+
+ try {
+ const response = await fetch(`/instance/previews?model_id=${encodeURIComponent(modelId)}`);
+ if (!response.ok) {
+ throw new Error(`Failed to fetch placement previews: ${response.status}`);
+ }
+ const data: PlacementPreviewResponse = await response.json();
+ this.placementPreviews = data.previews;
+ } catch (error) {
+ console.error('Error fetching placement previews:', error);
+ this.placementPreviews = [];
+ } finally {
+ if (showLoading) {
+ this.isLoadingPreviews = false;
+ }
+ }
+ }
+
+ startPreviewsPolling(modelId: string) {
+ // Stop any existing preview polling
+ this.stopPreviewsPolling();
+
+ // Fetch immediately
+ this.fetchPlacementPreviews(modelId);
+
+ // Then poll every 15 seconds (don't show loading spinner for subsequent fetches)
+ this.previewsInterval = setInterval(() => {
+ if (this.selectedPreviewModelId) {
+ this.fetchPlacementPreviews(this.selectedPreviewModelId, false);
+ }
+ }, 15000);
+ }
+
+ stopPreviewsPolling() {
+ if (this.previewsInterval) {
+ clearInterval(this.previewsInterval);
+ this.previewsInterval = null;
+ }
+ }
+
+ selectPreviewModel(modelId: string | null) {
+ if (modelId) {
+ this.startPreviewsPolling(modelId);
+ } else {
+ this.stopPreviewsPolling();
+ this.selectedPreviewModelId = null;
+ this.placementPreviews = [];
+ }
+ }
+
+ /**
+ * Starts a chat conversation - triggers the topology minimization animation
+ * Creates a new conversation if none is active
+ */
+ startChat() {
+ if (!this.activeConversationId) {
+ this.createConversation();
+ } else {
+ this.hasStartedChat = true;
+ this.isSidebarOpen = true; // Auto-open sidebar when chatting
+ // Small delay before minimizing for a nice visual effect
+ setTimeout(() => {
+ this.isTopologyMinimized = true;
+ }, 100);
+ }
+ }
+
+ /**
+ * Add a message to the conversation
+ */
+ addMessage(role: 'user' | 'assistant', content: string) {
+ const message: Message = {
+ id: generateUUID(),
+ role,
+ content,
+ timestamp: Date.now()
+ };
+ this.messages.push(message);
+ return message;
+ }
+
+ /**
+ * Delete a message and all subsequent messages
+ */
+ deleteMessage(messageId: string) {
+ const messageIndex = this.messages.findIndex(m => m.id === messageId);
+ if (messageIndex === -1) return;
+
+ // Remove this message and all subsequent messages
+ this.messages = this.messages.slice(0, messageIndex);
+ this.updateActiveConversation();
+ }
+
+ /**
+ * Edit a user message content (does not regenerate response)
+ */
+ editMessage(messageId: string, newContent: string) {
+ const message = this.messages.find(m => m.id === messageId);
+ if (!message) return;
+
+ message.content = newContent;
+ message.timestamp = Date.now();
+ this.updateActiveConversation();
+ }
+
+ /**
+ * Edit a user message and regenerate the response
+ */
+ async editAndRegenerate(messageId: string, newContent: string): Promise<void> {
+ const messageIndex = this.messages.findIndex(m => m.id === messageId);
+ if (messageIndex === -1) return;
+
+ const message = this.messages[messageIndex];
+ if (message.role !== 'user') return;
+
+ // Update the message content
+ message.content = newContent;
+ message.timestamp = Date.now();
+
+ // Remove all messages after this one (including the assistant response)
+ this.messages = this.messages.slice(0, messageIndex + 1);
+
+ // Regenerate the response
+ await this.regenerateLastResponse();
+ }
+
+ /**
+ * Regenerate the last assistant response
+ */
+ async regenerateLastResponse(): Promise<void> {
+ if (this.isLoading) return;
+
+ // Find the last user message
+ let lastUserIndex = -1;
+ for (let i = this.messages.length - 1; i >= 0; i--) {
+ if (this.messages[i].role === 'user') {
+ lastUserIndex = i;
+ break;
+ }
+ }
+
+ if (lastUserIndex === -1) return;
+
+ const lastUserMessage = this.messages[lastUserIndex];
+
+ // Remove any messages after the user message
+ this.messages = this.messages.slice(0, lastUserIndex + 1);
+
+ // Resend the message to get a new response
+ this.isLoading = true;
+ this.currentResponse = '';
+
+ // Create placeholder for assistant message
+ const assistantMessage = this.addMessage('assistant', '');
+
+ try {
+ const systemPrompt = {
+ role: 'system' as const,
+ content: 'You are a helpful AI assistant. Respond directly and concisely. Do not show your reasoning or thought process.'
+ };
+
+ const apiMessages = [
+ systemPrompt,
+ ...this.messages.slice(0, -1).map((m) => {
+ return { role: m.role, content: m.content };
+ })
+ ];
+
+ // Determine which model to use
+ let modelToUse = this.selectedChatModel;
+ if (!modelToUse) {
+ const firstInstanceKey = Object.keys(this.instances)[0];
+ if (firstInstanceKey) {
+ const instance = this.instances[firstInstanceKey] as Record<string, unknown> | undefined;
+ if (instance) {
+ const keys = Object.keys(instance);
+ if (keys.length === 1) {
+ const inst = instance[keys[0]] as { shardAssignments?: { modelId?: string } } | undefined;
+ modelToUse = inst?.shardAssignments?.modelId || '';
+ }
+ }
+ }
+ }
+
+ if (!modelToUse) {
+ assistantMessage.content = 'Error: No model available. Please launch an instance first.';
+ this.isLoading = false;
+ this.updateActiveConversation();
+ return;
+ }
+
+ const response = await fetch('/v1/chat/completions', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ model: modelToUse,
+ messages: apiMessages,
+ stream: true
+ })
+ });
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ assistantMessage.content = `Error: ${response.status} - ${errorText}`;
+ this.isLoading = false;
+ this.updateActiveConversation();
+ return;
+ }
+
+ const reader = response.body?.getReader();
+ if (!reader) {
+ assistantMessage.content = 'Error: No response stream available';
+ this.isLoading = false;
+ this.updateActiveConversation();
+ return;
+ }
+
+ const decoder = new TextDecoder();
+ let fullContent = '';
+ let partialLine = '';
+
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+
+ const chunk = decoder.decode(value, { stream: true });
+ const lines = (partialLine + chunk).split('\n');
+ partialLine = lines.pop() || '';
+
+ for (const line of lines) {
+ const trimmed = line.trim();
+ if (!trimmed || trimmed === 'data: [DONE]') continue;
+
+ if (trimmed.startsWith('data: ')) {
+ try {
+ const json = JSON.parse(trimmed.slice(6));
+ const delta = json.choices?.[0]?.delta?.content;
+ if (delta) {
+ fullContent += delta;
+ const { displayContent } = this.stripThinkingTags(fullContent);
+ this.currentResponse = displayContent;
+ assistantMessage.content = displayContent;
+ }
+ } catch {
+ // Skip malformed JSON
+ }
+ }
+ }
+ }
+
+ const { displayContent } = this.stripThinkingTags(fullContent);
+ assistantMessage.content = displayContent;
+ this.currentResponse = '';
+ this.updateActiveConversation();
+
+ } catch (error) {
+ assistantMessage.content = `Error: ${error instanceof Error ? error.message : 'Unknown error'}`;
+ this.updateActiveConversation();
+ } finally {
+ this.isLoading = false;
+ }
+ }
+
+ /**
+ * Selected model for chat (can be set by the UI)
+ */
+ selectedChatModel = $state('');
+
+ /**
+ * Set the model to use for chat
+ */
+ setSelectedModel(modelId: string) {
+ this.selectedChatModel = modelId;
+ // Clear stats when model changes
+ this.ttftMs = null;
+ this.tps = null;
+ }
+
+ /**
+ * Strip thinking tags from content for display.
+ * Handles both complete <think>...</think> blocks and in-progress <think>... blocks during streaming.
+ */
+ private stripThinkingTags(content: string): { displayContent: string; thinkingContent: string } {
+ const extracted: string[] = [];
+ let displayContent = content;
+
+ // Extract complete <think>...</think> blocks
+ const completeBlockRegex = /<think>([\s\S]*?)<\/think>/gi;
+ let match: RegExpExecArray | null;
+ while ((match = completeBlockRegex.exec(content)) !== null) {
+ const inner = match[1]?.trim();
+ if (inner) extracted.push(inner);
+ }
+ displayContent = displayContent.replace(completeBlockRegex, '');
+
+ // Handle in-progress thinking block (has <think> but no closing </think> yet)
+ const openTagIndex = displayContent.lastIndexOf('<think>');
+ if (openTagIndex !== -1) {
+ const inProgressThinking = displayContent.slice(openTagIndex + 7).trim();
+ if (inProgressThinking) {
+ extracted.push(inProgressThinking);
+ }
+ displayContent = displayContent.slice(0, openTagIndex);
+ }
+
+ return { displayContent: displayContent.trim(), thinkingContent: extracted.join('\n\n') };
+ }
+
+ /**
+ * Send a message to the LLM and stream the response
+ */
+ async sendMessage(content: string, files?: { id: string; name: string; type: string; textContent?: string; preview?: string }[]): Promise<void> {
+ if ((!content.trim() && (!files || files.length === 0)) || this.isLoading) return;
+
+ if (!this.hasStartedChat) {
+ this.startChat();
+ }
+
+ this.isLoading = true;
+ this.currentResponse = '';
+ this.ttftMs = null;
+ this.tps = null;
+ this.totalTokens = 0;
+
+ // Build attachments from files
+ const attachments: MessageAttachment[] = [];
+ let fileContext = '';
+
+ if (files && files.length > 0) {
+ for (const file of files) {
+ const isImage = file.type.startsWith('image/');
+
+ if (isImage && file.preview) {
+ attachments.push({
+ type: 'image',
+ name: file.name,
+ preview: file.preview,
+ mimeType: file.type
+ });
+ } else if (file.textContent) {
+ attachments.push({
+ type: 'text',
+ name: file.name,
+ content: file.textContent,
+ mimeType: file.type
+ });
+ // Add text file content to the message context
+ fileContext += `\n\n[File: ${file.name}]\n\`\`\`\n${file.textContent}\n\`\`\``;
+ } else {
+ attachments.push({
+ type: 'file',
+ name: file.name,
+ mimeType: file.type
+ });
+ }
+ }
+ }
+
+ // Combine content with file context
+ const fullContent = content + fileContext;
+
+ // Add user message with attachments
+ const userMessage: Message = {
+ id: generateUUID(),
+ role: 'user',
+ content: content, // Store original content for display
+ timestamp: Date.now(),
+ attachments: attachments.length > 0 ? attachments : undefined
+ };
+ this.messages.push(userMessage);
+
+ // Create placeholder for assistant message
+ const assistantMessage = this.addMessage('assistant', '');
+ this.updateActiveConversation();
+
+ try {
+ // Build the messages array for the API with system prompt
+ const systemPrompt = {
+ role: 'system' as const,
+ content: 'You are a helpful AI assistant. Respond directly and concisely. Do not show your reasoning or thought process. When files are shared with you, analyze them and respond helpfully.'
+ };
+
+ // Build API messages - include file content for text files
+ const apiMessages = [
+ systemPrompt,
+ ...this.messages.slice(0, -1).map((m) => {
+ // Build content including any text file attachments
+ let msgContent = m.content;
+
+ // Add text attachments as context
+ if (m.attachments) {
+ for (const attachment of m.attachments) {
+ if (attachment.type === 'text' && attachment.content) {
+ msgContent += `\n\n[File: ${attachment.name}]\n\`\`\`\n${attachment.content}\n\`\`\``;
+ }
+ }
+ }
+
+ return {
+ role: m.role,
+ content: msgContent
+ };
+ })
+ ];
+
+ // Determine the model to use - prefer selectedChatModel, otherwise try to get from instances
+ let modelToUse = this.selectedChatModel;
+ if (!modelToUse) {
+ // Try to get model from first running instance
+ for (const [, instanceWrapper] of Object.entries(this.instances)) {
+ if (instanceWrapper && typeof instanceWrapper === 'object') {
+ const keys = Object.keys(instanceWrapper as Record<string, unknown>);
+ if (keys.length === 1) {
+ const instance = (instanceWrapper as Record<string, unknown>)[keys[0]] as { shardAssignments?: { modelId?: string } };
+ if (instance?.shardAssignments?.modelId) {
+ modelToUse = instance.shardAssignments.modelId;
+ break;
+ }
+ }
+ }
+ }
+ }
+
+ if (!modelToUse) {
+ throw new Error('No model selected and no running instances available. Please launch an instance first.');
+ }
+
+ const conversationModelInfo = this.buildConversationModelInfo(modelToUse);
+ this.applyConversationModelInfo(conversationModelInfo);
+
+ // Start timing for TTFT measurement
+ const requestStartTime = performance.now();
+ let firstTokenTime: number | null = null;
+ let tokenCount = 0;
+
+ const response = await fetch('/v1/chat/completions', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({
+ model: modelToUse,
+ messages: apiMessages,
+ temperature: 0.7,
+ stream: true
+ })
+ });
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ throw new Error(`API error: ${response.status} - ${errorText}`);
+ }
+
+ const reader = response.body?.getReader();
+ if (!reader) {
+ throw new Error('No response body');
+ }
+
+ const decoder = new TextDecoder();
+ let fullContent = '';
+ let buffer = '';
+
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+
+ buffer += decoder.decode(value, { stream: true });
+
+ // Process complete lines
+ const lines = buffer.split('\n');
+ buffer = lines.pop() || ''; // Keep incomplete line in buffer
+
+ for (const line of lines) {
+ const trimmed = line.trim();
+ if (!trimmed) continue;
+
+ if (trimmed.startsWith('data: ')) {
+ const data = trimmed.slice(6);
+ if (data === '[DONE]') continue;
+
+ try {
+ const parsed = JSON.parse(data);
+ const tokenContent = parsed.choices?.[0]?.delta?.content;
+ if (tokenContent) {
+ // Track first token for TTFT
+ if (firstTokenTime === null) {
+ firstTokenTime = performance.now();
+ this.ttftMs = firstTokenTime - requestStartTime;
+ }
+
+ // Count tokens (each SSE chunk is typically one token)
+ tokenCount += 1;
+ this.totalTokens = tokenCount;
+
+ // Update real-time TPS during streaming
+ if (firstTokenTime !== null && tokenCount > 1) {
+ const elapsed = performance.now() - firstTokenTime;
+ this.tps = (tokenCount / elapsed) * 1000;
+ }
+
+ fullContent += tokenContent;
+
+ // Strip thinking tags for display and extract thinking content
+ const { displayContent, thinkingContent } = this.stripThinkingTags(fullContent);
+ this.currentResponse = displayContent;
+
+ // Update the assistant message in place
+ const idx = this.messages.findIndex(m => m.id === assistantMessage.id);
+ if (idx !== -1) {
+ this.messages[idx].content = displayContent;
+ this.messages[idx].thinking = thinkingContent || undefined;
+ }
+ this.persistActiveConversation();
+ }
+ } catch {
+ // Skip invalid JSON lines
+ }
+ }
+ }
+ }
+
+ // Process any remaining buffer
+ if (buffer.trim()) {
+ const trimmed = buffer.trim();
+ if (trimmed.startsWith('data: ') && trimmed.slice(6) !== '[DONE]') {
+ try {
+ const parsed = JSON.parse(trimmed.slice(6));
+ const tokenContent = parsed.choices?.[0]?.delta?.content;
+ if (tokenContent) {
+ fullContent += tokenContent;
+ this.persistActiveConversation();
+ }
+ } catch {
+ // Skip
+ }
+ }
+ }
+
+ // Calculate final TPS
+ if (firstTokenTime !== null && tokenCount > 1) {
+ const totalGenerationTime = performance.now() - firstTokenTime;
+ this.tps = (tokenCount / totalGenerationTime) * 1000; // tokens per second
+ }
+
+ // Final cleanup of the message
+ const { displayContent, thinkingContent } = this.stripThinkingTags(fullContent);
+ const idx = this.messages.findIndex(m => m.id === assistantMessage.id);
+ if (idx !== -1) {
+ this.messages[idx].content = displayContent;
+ this.messages[idx].thinking = thinkingContent || undefined;
+ // Store performance metrics on the message
+ if (this.ttftMs !== null) {
+ this.messages[idx].ttftMs = this.ttftMs;
+ }
+ if (this.tps !== null) {
+ this.messages[idx].tps = this.tps;
+ }
+ }
+ this.persistActiveConversation();
+
+ } catch (error) {
+ console.error('Error sending message:', error);
+ // Update the assistant message with error
+ const idx = this.messages.findIndex(m => m.id === assistantMessage.id);
+ if (idx !== -1) {
+ this.messages[idx].content = `Error: ${error instanceof Error ? error.message : 'Failed to get response'}`;
+ }
+ this.persistActiveConversation();
+ } finally {
+ this.isLoading = false;
+ this.currentResponse = '';
+ this.updateActiveConversation();
+ }
+ }
+
+ /**
+ * Clear current chat and go back to welcome state
+ */
+ clearChat() {
+ this.activeConversationId = null;
+ this.messages = [];
+ this.hasStartedChat = false;
+ this.isTopologyMinimized = false;
+ this.currentResponse = '';
+ // Clear performance stats
+ this.ttftMs = null;
+ this.tps = null;
+ }
+
+ /**
+ * Get the active conversation
+ */
+ getActiveConversation(): Conversation | null {
+ if (!this.activeConversationId) return null;
+ return this.conversations.find(c => c.id === this.activeConversationId) || null;
+ }
+}
+
+export const appStore = new AppStore();
+
+// Reactive exports
+export const hasStartedChat = () => appStore.hasStartedChat;
+export const messages = () => appStore.messages;
+export const currentResponse = () => appStore.currentResponse;
+export const isLoading = () => appStore.isLoading;
+export const ttftMs = () => appStore.ttftMs;
+export const tps = () => appStore.tps;
+export const totalTokens = () => appStore.totalTokens;
+export const topologyData = () => appStore.topologyData;
+export const instances = () => appStore.instances;
+export const runners = () => appStore.runners;
+export const downloads = () => appStore.downloads;
+export const placementPreviews = () => appStore.placementPreviews;
+export const selectedPreviewModelId = () => appStore.selectedPreviewModelId;
+export const isLoadingPreviews = () => appStore.isLoadingPreviews;
+export const lastUpdate = () => appStore.lastUpdate;
+export const isTopologyMinimized = () => appStore.isTopologyMinimized;
+export const selectedChatModel = () => appStore.selectedChatModel;
+export const debugMode = () => appStore.getDebugMode();
+
+// Actions
+export const startChat = () => appStore.startChat();
+export const sendMessage = (content: string, files?: { id: string; name: string; type: string; textContent?: string; preview?: string }[]) => appStore.sendMessage(content, files);
+export const clearChat = () => appStore.clearChat();
+export const setSelectedChatModel = (modelId: string) => appStore.setSelectedModel(modelId);
+export const selectPreviewModel = (modelId: string | null) => appStore.selectPreviewModel(modelId);
+export const deleteMessage = (messageId: string) => appStore.deleteMessage(messageId);
+export const editMessage = (messageId: string, newContent: string) => appStore.editMessage(messageId, newContent);
+export const editAndRegenerate = (messageId: string, newContent: string) => appStore.editAndRegenerate(messageId, newContent);
+export const regenerateLastResponse = () => appStore.regenerateLastResponse();
+
+// Conversation actions
+export const conversations = () => appStore.conversations;
+export const activeConversationId = () => appStore.activeConversationId;
+export const createConversation = (name?: string) => appStore.createConversation(name);
+export const loadConversation = (id: string) => appStore.loadConversation(id);
+export const deleteConversation = (id: string) => appStore.deleteConversation(id);
+export const deleteAllConversations = () => appStore.deleteAllConversations();
+export const renameConversation = (id: string, name: string) => appStore.renameConversation(id, name);
+export const getActiveConversation = () => appStore.getActiveConversation();
+
+// Sidebar actions
+export const isSidebarOpen = () => appStore.isSidebarOpen;
+export const toggleSidebar = () => appStore.toggleSidebar();
+export const toggleDebugMode = () => appStore.toggleDebugMode();
+export const setDebugMode = (enabled: boolean) => appStore.setDebugMode(enabled);
+export const refreshState = () => appStore.fetchState();
+
diff --git a/dashboard/src/lib/types/files.ts b/dashboard/src/lib/types/files.ts
new file mode 100644
index 00000000..b92e269e
--- /dev/null
+++ b/dashboard/src/lib/types/files.ts
@@ -0,0 +1,169 @@
+/**
+ * File attachment types for the chat interface
+ */
+
+export interface ChatUploadedFile {
+ id: string;
+ name: string;
+ size: number;
+ type: string;
+ file: File;
+ preview?: string;
+ textContent?: string;
+}
+
+export interface ChatAttachment {
+ type: 'image' | 'text' | 'pdf' | 'audio';
+ name: string;
+ content?: string;
+ base64Url?: string;
+ mimeType?: string;
+}
+
+export type FileCategory = 'image' | 'text' | 'pdf' | 'audio' | 'unknown';
+
+export const IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg'];
+export const IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml'];
+
+export const TEXT_EXTENSIONS = [
+ '.txt', '.md', '.json', '.xml', '.yaml', '.yml', '.csv', '.log',
+ '.js', '.ts', '.jsx', '.tsx', '.py', '.java', '.cpp', '.c', '.h',
+ '.css', '.html', '.htm', '.sql', '.sh', '.bat', '.rs', '.go',
+ '.rb', '.php', '.swift', '.kt', '.scala', '.r', '.dart', '.vue', '.svelte'
+];
+export const TEXT_MIME_TYPES = [
+ 'text/plain', 'text/markdown', 'text/csv', 'text/html', 'text/css',
+ 'application/json', 'application/xml', 'text/xml', 'application/javascript',
+ 'text/javascript', 'application/typescript'
+];
+
+export const PDF_EXTENSIONS = ['.pdf'];
+export const PDF_MIME_TYPES = ['application/pdf'];
+
+export const AUDIO_EXTENSIONS = ['.mp3', '.wav', '.ogg', '.m4a'];
+export const AUDIO_MIME_TYPES = ['audio/mpeg', 'audio/wav', 'audio/ogg', 'audio/mp4'];
+
+/**
+ * Get file category based on MIME type and extension
+ */
+export function getFileCategory(mimeType: string, fileName: string): FileCategory {
+ const extension = fileName.toLowerCase().slice(fileName.lastIndexOf('.'));
+
+ if (IMAGE_MIME_TYPES.includes(mimeType) || IMAGE_EXTENSIONS.includes(extension)) {
+ return 'image';
+ }
+ if (PDF_MIME_TYPES.includes(mimeType) || PDF_EXTENSIONS.includes(extension)) {
+ return 'pdf';
+ }
+ if (AUDIO_MIME_TYPES.includes(mimeType) || AUDIO_EXTENSIONS.includes(extension)) {
+ return 'audio';
+ }
+ if (TEXT_MIME_TYPES.includes(mimeType) || TEXT_EXTENSIONS.includes(extension) || mimeType.startsWith('text/')) {
+ return 'text';
+ }
+ return 'unknown';
+}
+
+/**
+ * Get accept string for file input based on categories
+ */
+export function getAcceptString(categories: FileCategory[]): string {
+ const accepts: string[] = [];
+
+ for (const category of categories) {
+ switch (category) {
+ case 'image':
+ accepts.push(...IMAGE_EXTENSIONS, ...IMAGE_MIME_TYPES);
+ break;
+ case 'text':
+ accepts.push(...TEXT_EXTENSIONS, ...TEXT_MIME_TYPES);
+ break;
+ case 'pdf':
+ accepts.push(...PDF_EXTENSIONS, ...PDF_MIME_TYPES);
+ break;
+ case 'audio':
+ accepts.push(...AUDIO_EXTENSIONS, ...AUDIO_MIME_TYPES);
+ break;
+ }
+ }
+
+ return accepts.join(',');
+}
+
+/**
+ * Format file size for display
+ */
+export function formatFileSize(bytes: number): string {
+ if (bytes === 0) return '0 B';
+ const k = 1024;
+ const sizes = ['B', 'KB', 'MB', 'GB'];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
+}
+
+/**
+ * Read file as data URL (base64)
+ */
+export function readFileAsDataURL(file: File): Promise<string> {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onload = () => resolve(reader.result as string);
+ reader.onerror = () => reject(reader.error);
+ reader.readAsDataURL(file);
+ });
+}
+
+/**
+ * Read file as text
+ */
+export function readFileAsText(file: File): Promise<string> {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onload = () => resolve(reader.result as string);
+ reader.onerror = () => reject(reader.error);
+ reader.readAsText(file);
+ });
+}
+
+/**
+ * Process uploaded files into ChatUploadedFile format
+ */
+export async function processUploadedFiles(files: File[]): Promise<ChatUploadedFile[]> {
+ const results: ChatUploadedFile[] = [];
+
+ for (const file of files) {
+ const id = Date.now().toString() + Math.random().toString(36).substring(2, 9);
+ const category = getFileCategory(file.type, file.name);
+
+ const base: ChatUploadedFile = {
+ id,
+ name: file.name,
+ size: file.size,
+ type: file.type,
+ file
+ };
+
+ try {
+ if (category === 'image') {
+ const preview = await readFileAsDataURL(file);
+ results.push({ ...base, preview });
+ } else if (category === 'text' || category === 'unknown') {
+ const textContent = await readFileAsText(file);
+ results.push({ ...base, textContent });
+ } else if (category === 'pdf') {
+ results.push(base);
+ } else if (category === 'audio') {
+ const preview = await readFileAsDataURL(file);
+ results.push({ ...base, preview });
+ } else {
+ results.push(base);
+ }
+ } catch (error) {
+ console.error('Error processing file:', file.name, error);
+ results.push(base);
+ }
+ }
+
+ return results;
+}
+
diff --git a/dashboard/src/routes/+layout.svelte b/dashboard/src/routes/+layout.svelte
new file mode 100644
index 00000000..7e75b676
--- /dev/null
+++ b/dashboard/src/routes/+layout.svelte
@@ -0,0 +1,15 @@
+<script lang="ts">
+ import '../app.css';
+
+ let { children } = $props();
+</script>
+
+<svelte:head>
+ <title>EXO</title>
+ <meta name="description" content="EXO - Distributed AI Cluster Dashboard" />
+</svelte:head>
+
+<div class="min-h-screen bg-background text-foreground">
+ {@render children?.()}
+</div>
+
diff --git a/dashboard/src/routes/+page.svelte b/dashboard/src/routes/+page.svelte
new file mode 100644
index 00000000..082d1138
--- /dev/null
+++ b/dashboard/src/routes/+page.svelte
@@ -0,0 +1,1840 @@
+<script lang="ts">
+ import { TopologyGraph, ChatForm, ChatMessages, ChatSidebar, ModelCard } from '$lib/components';
+ import {
+ hasStartedChat,
+ isTopologyMinimized,
+ topologyData,
+ lastUpdate,
+ clearChat,
+ instances,
+ runners,
+ downloads,
+ placementPreviews,
+ selectedPreviewModelId,
+ isLoadingPreviews,
+ selectPreviewModel,
+ createConversation,
+ setSelectedChatModel,
+ selectedChatModel,
+ debugMode,
+ toggleDebugMode,
+ type DownloadProgress,
+ type PlacementPreview
+ } from '$lib/stores/app.svelte';
+ import HeaderNav from '$lib/components/HeaderNav.svelte';
+ import { fade, fly } from 'svelte/transition';
+ import { cubicInOut } from 'svelte/easing';
+ import { onMount } from 'svelte';
+
+ const chatStarted = $derived(hasStartedChat());
+ const minimized = $derived(isTopologyMinimized());
+ const data = $derived(topologyData());
+ const update = $derived(lastUpdate());
+ const instanceData = $derived(instances());
+ const runnersData = $derived(runners());
+ const downloadsData = $derived(downloads());
+ const previewsData = $derived(placementPreviews());
+ const selectedModelId = $derived(selectedPreviewModelId());
+ const loadingPreviews = $derived(isLoadingPreviews());
+const debugEnabled = $derived(debugMode());
+
+ let mounted = $state(false);
+
+ // Instance launch state
+ let models = $state<Array<{id: string, name?: string, storage_size_megabytes?: number}>>([]);
+ let selectedSharding = $state<'Pipeline' | 'Tensor'>('Pipeline');
+ type InstanceMeta = 'MlxRing' | 'MlxIbv' | 'MlxJaccl';
+
+ let selectedInstanceType = $state<InstanceMeta>('MlxRing');
+ let selectedMinNodes = $state<number>(1);
+ let minNodesInitialized = $state(false);
+ let launchingModelId = $state<string | null>(null);
+let instanceDownloadExpandedNodes = $state<Set<string>>(new Set());
+
+ // Custom dropdown state
+ let isModelDropdownOpen = $state(false);
+ let modelDropdownSearch = $state('');
+
+ // Slider dragging state
+ let isDraggingSlider = $state(false);
+ let sliderTrackElement: HTMLDivElement | null = $state(null);
+
+ // Instances container ref for scrolling
+ let instancesContainerRef: HTMLDivElement | null = $state(null);
+// Chat scroll container ref for precise scroll behavior
+let chatScrollRef: HTMLDivElement | null = $state(null);
+
+ // Instance hover state for highlighting nodes in topology
+ let hoveredInstanceId = $state<string | null>(null);
+
+ // Preview card hover state for highlighting nodes in topology
+ let hoveredPreviewNodes = $state<Set<string>>(new Set());
+
+ // Helper to unwrap tagged instance for hover highlighting
+ function unwrapInstanceNodes(instanceWrapped: unknown): Set<string> {
+ if (!instanceWrapped || typeof instanceWrapped !== 'object') return new Set();
+ const keys = Object.keys(instanceWrapped as Record<string, unknown>);
+ if (keys.length !== 1) return new Set();
+ const instance = (instanceWrapped as Record<string, unknown>)[keys[0]];
+ if (!instance || typeof instance !== 'object') return new Set();
+ const inst = instance as { shardAssignments?: { nodeToRunner?: Record<string, string> } };
+ if (!inst.shardAssignments?.nodeToRunner) return new Set();
+ return new Set(Object.keys(inst.shardAssignments.nodeToRunner));
+ }
+
+function toggleInstanceDownloadDetails(nodeId: string): void {
+ const next = new Set(instanceDownloadExpandedNodes);
+ if (next.has(nodeId)) {
+ next.delete(nodeId);
+ } else {
+ next.add(nodeId);
+ }
+ instanceDownloadExpandedNodes = next;
+}
+
+ // Compute highlighted nodes from hovered instance or hovered preview
+ const highlightedNodes = $derived(() => {
+ // First check instance hover
+ if (hoveredInstanceId) {
+ const instanceWrapped = instanceData[hoveredInstanceId];
+ return unwrapInstanceNodes(instanceWrapped);
+ }
+ // Then check preview hover
+ if (hoveredPreviewNodes.size > 0) {
+ return hoveredPreviewNodes;
+ }
+ return new Set<string>();
+ });
+
+ // Helper to estimate memory from model ID (mirrors ModelCard logic)
+ // Uses regex with word boundaries to avoid false matches like '4bit' matching '4b'
+ function estimateMemoryGB(modelId: string, modelName?: string): number {
+ // Check both ID and name for quantization info
+ const combined = `${modelId} ${modelName || ''}`.toLowerCase();
+
+ // Detect quantization level - affects memory by roughly 2x between levels
+ const is4bit = combined.includes('4bit') || combined.includes('4-bit') || combined.includes(':4bit');
+ const is8bit = combined.includes('8bit') || combined.includes('8-bit') || combined.includes(':8bit');
+ // 4-bit = 0.5 bytes/param, 8-bit = 1 byte/param, fp16 = 2 bytes/param
+ const quantMultiplier = is4bit ? 0.5 : is8bit ? 1 : 2;
+ const id = modelId.toLowerCase();
+
+ // Known large models that don't follow the standard naming pattern
+ // DeepSeek V3 has 685B parameters
+ if (id.includes('deepseek-v3')) {
+ return Math.round(685 * quantMultiplier);
+ }
+ // DeepSeek V2 has 236B parameters
+ if (id.includes('deepseek-v2')) {
+ return Math.round(236 * quantMultiplier);
+ }
+ // Llama 4 Scout/Maverick are large models
+ if (id.includes('llama-4')) {
+ return Math.round(400 * quantMultiplier);
+ }
+
+ // Match parameter counts with word boundaries (e.g., "70b" but not "4bit")
+ const paramMatch = id.match(/(\d+(?:\.\d+)?)\s*b(?![a-z])/i);
+ if (paramMatch) {
+ const params = parseFloat(paramMatch[1]);
+ return Math.max(4, Math.round(params * quantMultiplier));
+ }
+
+ // Fallback patterns for explicit size markers (assume fp16 baseline, adjust for quant)
+ if (id.includes('405b') || id.includes('400b')) return Math.round(405 * quantMultiplier);
+ if (id.includes('180b')) return Math.round(180 * quantMultiplier);
+ if (id.includes('141b') || id.includes('140b')) return Math.round(140 * quantMultiplier);
+ if (id.includes('123b') || id.includes('120b')) return Math.round(123 * quantMultiplier);
+ if (id.includes('72b') || id.includes('70b')) return Math.round(70 * quantMultiplier);
+ if (id.includes('67b') || id.includes('65b')) return Math.round(65 * quantMultiplier);
+ if (id.includes('35b') || id.includes('34b') || id.includes('32b') || id.includes('30b')) return Math.round(32 * quantMultiplier);
+ if (id.includes('27b') || id.includes('26b') || id.includes('22b')) return Math.round(24 * quantMultiplier);
+ if (id.includes('14b') || id.includes('13b') || id.includes('15b')) return Math.round(14 * quantMultiplier);
+ if (id.includes('8b') || id.includes('9b') || id.includes('7b')) return Math.round(8 * quantMultiplier);
+ if (id.includes('3b') || id.includes('3.8b')) return Math.round(4 * quantMultiplier);
+ if (id.includes('2b') || id.includes('1b') || id.includes('1.5b') || id.includes('0.5b')) return Math.round(2 * quantMultiplier);
+ return 16; // Default fallback
+ }
+
+ // Helper to estimate performance from model ID
+ function estimatePerformance(modelId: string): { ttft: number; tps: number } {
+ const id = modelId.toLowerCase();
+ if (id.includes('405b') || id.includes('400b')) return { ttft: 8000, tps: 3 };
+ if (id.includes('180b')) return { ttft: 4000, tps: 5 };
+ if (id.includes('141b') || id.includes('140b')) return { ttft: 3500, tps: 6 };
+ if (id.includes('123b') || id.includes('120b')) return { ttft: 3000, tps: 7 };
+ if (id.includes('72b') || id.includes('70b')) return { ttft: 1800, tps: 12 };
+ if (id.includes('67b') || id.includes('65b')) return { ttft: 1600, tps: 14 };
+ if (id.includes('35b') || id.includes('34b') || id.includes('32b') || id.includes('30b')) return { ttft: 900, tps: 22 };
+ if (id.includes('27b') || id.includes('26b') || id.includes('22b')) return { ttft: 700, tps: 28 };
+ if (id.includes('14b') || id.includes('13b') || id.includes('15b')) return { ttft: 400, tps: 45 };
+ if (id.includes('8b') || id.includes('9b') || id.includes('7b')) return { ttft: 200, tps: 65 };
+ if (id.includes('4b') || id.includes('3b') || id.includes('3.8b')) return { ttft: 100, tps: 95 };
+ if (id.includes('2b') || id.includes('1b') || id.includes('1.5b') || id.includes('0.5b')) return { ttft: 50, tps: 150 };
+ return { ttft: 300, tps: 50 };
+ }
+
+ const matchesSelectedRuntime = (runtime: InstanceMeta): boolean =>
+ selectedInstanceType === 'MlxRing'
+ ? runtime === 'MlxRing'
+ : runtime === 'MlxIbv' || runtime === 'MlxJaccl';
+
+ // Helper to check if a model can be launched (has valid placement with >= minNodes)
+ function canModelFit(modelId: string): boolean {
+
+ // Find previews matching the model, sharding, and instance type
+ const matchingPreviews = previewsData.filter(
+ (p: PlacementPreview) =>
+ p.model_id === modelId &&
+ p.sharding === selectedSharding &&
+ matchesSelectedRuntime(p.instance_meta) &&
+ p.error === null &&
+ p.memory_delta_by_node !== null
+ );
+
+ // Check if any preview has node count >= selectedMinNodes
+ return matchingPreviews.some((p: PlacementPreview) => getPreviewNodeCount(p) >= selectedMinNodes);
+ }
+
+ // Helper to get model size in GB (from megabytes)
+ function getModelSizeGB(model: {id: string, name?: string, storage_size_megabytes?: number}): number {
+ if (model.storage_size_megabytes) {
+ return model.storage_size_megabytes / 1024;
+ }
+ return estimateMemoryGB(model.id, model.name);
+ }
+
+ // Calculate available memory in the cluster (in GB)
+ const availableMemoryGB = $derived(() => {
+ if (!data) return 0;
+ return Object.values(data.nodes).reduce((acc, n) => {
+ const total = n.macmon_info?.memory?.ram_total ?? n.system_info?.memory ?? 0;
+ const used = n.macmon_info?.memory?.ram_usage ?? 0;
+ return acc + (total - used);
+ }, 0) / (1024 * 1024 * 1024);
+ });
+
+ // Check if a model has enough memory to run
+ function hasEnoughMemory(model: {id: string, name?: string, storage_size_megabytes?: number}): boolean {
+ const modelSizeGB = getModelSizeGB(model);
+ return modelSizeGB <= availableMemoryGB();
+ }
+
+ // Sorted models for dropdown - biggest first, unrunnable at the end
+ const sortedModels = $derived(() => {
+ return [...models].sort((a, b) => {
+ // First: models that have enough memory come before those that don't
+ const aCanFit = hasEnoughMemory(a);
+ const bCanFit = hasEnoughMemory(b);
+ if (aCanFit && !bCanFit) return -1;
+ if (!aCanFit && bCanFit) return 1;
+
+ // Then: sort by size (biggest first)
+ const aSize = getModelSizeGB(a);
+ const bSize = getModelSizeGB(b);
+ return bSize - aSize;
+ });
+ });
+
+ // Compute model tags (FASTEST, BIGGEST)
+ const modelTags = $derived(() => {
+ const tags: Record<string, string[]> = {};
+ if (models.length === 0) return tags;
+
+ // Find the fastest model (highest TPS)
+ let fastestId = '';
+ let highestTps = 0;
+
+ // Find the biggest model (most memory)
+ let biggestId = '';
+ let highestMemory = 0;
+
+ for (const model of models) {
+ const perf = estimatePerformance(model.id);
+ const mem = getModelSizeGB(model);
+
+ if (perf.tps > highestTps) {
+ highestTps = perf.tps;
+ fastestId = model.id;
+ }
+
+ if (mem > highestMemory) {
+ highestMemory = mem;
+ biggestId = model.id;
+ }
+ }
+
+ if (fastestId) {
+ tags[fastestId] = tags[fastestId] || [];
+ tags[fastestId].push('FASTEST');
+ }
+
+ if (biggestId && biggestId !== fastestId) {
+ tags[biggestId] = tags[biggestId] || [];
+ tags[biggestId].push('BIGGEST');
+ } else if (biggestId === fastestId && biggestId) {
+ // Same model is both - unlikely but handle it
+ tags[biggestId].push('BIGGEST');
+ }
+
+ return tags;
+ });
+
+ onMount(() => {
+ mounted = true;
+ fetchModels();
+ });
+
+ async function fetchModels() {
+ try {
+ const response = await fetch('/models');
+ if (response.ok) {
+ const data = await response.json();
+ // API returns { data: [{ id, name }] } format
+ models = data.data || [];
+ }
+ } catch (error) {
+ console.error('Failed to fetch models:', error);
+ }
+ }
+
+ async function launchInstance(modelId: string, specificPreview?: PlacementPreview | null) {
+ if (!modelId || launchingModelId) return;
+
+ launchingModelId = modelId;
+
+ try {
+ // Use the specific preview if provided, otherwise fall back to filtered preview
+ const preview = specificPreview ?? filteredPreview();
+
+ let instanceData: unknown;
+
+ if (preview?.instance) {
+ // Use the instance from the preview
+ instanceData = preview.instance;
+ } else {
+ // Fallback: GET placement from API
+ const placementResponse = await fetch(
+ `/instance/placement?model_id=${encodeURIComponent(modelId)}&sharding=${selectedSharding}&instance_meta=${selectedInstanceType}&min_nodes=${selectedMinNodes}`
+ );
+
+ if (!placementResponse.ok) {
+ const errorText = await placementResponse.text();
+ console.error('Failed to get placement:', errorText);
+ return;
+ }
+
+ instanceData = await placementResponse.json();
+ }
+
+ // POST the instance to create it
+ const response = await fetch('/instance', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ instance: instanceData })
+ });
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ console.error('Failed to launch instance:', errorText);
+ } else {
+ // Auto-select the launched model only if no model is currently selected
+ if (!selectedChatModel()) {
+ setSelectedChatModel(modelId);
+ }
+
+ // Scroll to the bottom of instances container to show the new instance
+ // Use multiple attempts to ensure DOM has updated with the new instance
+ const scrollToBottom = () => {
+ if (instancesContainerRef) {
+ instancesContainerRef.scrollTo({ top: instancesContainerRef.scrollHeight, behavior: 'smooth' });
+ }
+ };
+ setTimeout(scrollToBottom, 200);
+ setTimeout(scrollToBottom, 500);
+ setTimeout(scrollToBottom, 1000);
+ }
+ } catch (error) {
+ console.error('Error launching instance:', error);
+ } finally {
+ launchingModelId = null;
+ }
+ }
+
+ // Helper to extract model ID from download shard metadata
+ function extractModelIdFromDownload(downloadPayload: Record<string, unknown>): string | null {
+ const shardMetadata = downloadPayload.shard_metadata ?? downloadPayload.shardMetadata;
+ if (!shardMetadata || typeof shardMetadata !== 'object') return null;
+
+ // Shard metadata is a tagged union: { PipelineShardMetadata: {...} } or { TensorShardMetadata: {...} }
+ const shardObj = shardMetadata as Record<string, unknown>;
+ const shardKeys = Object.keys(shardObj);
+ if (shardKeys.length !== 1) return null;
+
+ const shardData = shardObj[shardKeys[0]] as Record<string, unknown>;
+ if (!shardData) return null;
+
+ // Model meta is nested: shard.model_meta.model_id
+ const modelMeta = shardData.model_meta ?? shardData.modelMeta;
+ if (!modelMeta || typeof modelMeta !== 'object') return null;
+
+ const meta = modelMeta as Record<string, unknown>;
+ return (meta.model_id as string) ?? (meta.modelId as string) ?? null;
+ }
+
+ // Helper to parse download progress from payload
+ function parseDownloadProgress(payload: Record<string, unknown>): DownloadProgress | null {
+ const progress = payload.download_progress ?? payload.downloadProgress;
+ if (!progress || typeof progress !== 'object') return null;
+
+ const prog = progress as Record<string, unknown>;
+ const totalBytes = getBytes(prog.total_bytes ?? prog.totalBytes);
+ const downloadedBytes = getBytes(prog.downloaded_bytes ?? prog.downloadedBytes);
+ const speed = (prog.speed as number) ?? 0;
+ const completedFiles = (prog.completed_files as number) ?? (prog.completedFiles as number) ?? 0;
+ const totalFiles = (prog.total_files as number) ?? (prog.totalFiles as number) ?? 0;
+ const etaMs = (prog.eta_ms as number) ?? (prog.etaMs as number) ?? 0;
+
+ const files: DownloadProgress['files'] = [];
+ const filesObj = (prog.files ?? {}) as Record<string, unknown>;
+ for (const [fileName, fileData] of Object.entries(filesObj)) {
+ if (!fileData || typeof fileData !== 'object') continue;
+ const fd = fileData as Record<string, unknown>;
+ const fTotal = getBytes(fd.total_bytes ?? fd.totalBytes);
+ const fDownloaded = getBytes(fd.downloaded_bytes ?? fd.downloadedBytes);
+ files.push({
+ name: fileName,
+ totalBytes: fTotal,
+ downloadedBytes: fDownloaded,
+ speed: (fd.speed as number) ?? 0,
+ etaMs: (fd.eta_ms as number) ?? (fd.etaMs as number) ?? 0,
+ percentage: fTotal > 0 ? (fDownloaded / fTotal) * 100 : 0
+ });
+ }
+
+ return {
+ totalBytes,
+ downloadedBytes,
+ speed,
+ etaMs: etaMs || (speed > 0 ? ((totalBytes - downloadedBytes) / speed) * 1000 : 0),
+ percentage: totalBytes > 0 ? (downloadedBytes / totalBytes) * 100 : 0,
+ completedFiles,
+ totalFiles,
+ files
+ };
+ }
+
+ // Helper to get download status for a model (checks all downloads for matching model ID)
+ function getModelDownloadStatus(modelId: string): {
+ isDownloading: boolean;
+ progress: DownloadProgress | null;
+ perNode: Array<{ nodeId: string; nodeName: string; progress: DownloadProgress }>;
+ } {
+ if (!downloadsData || Object.keys(downloadsData).length === 0) {
+ return { isDownloading: false, progress: null, perNode: [] };
+ }
+
+ let totalBytes = 0;
+ let downloadedBytes = 0;
+ let totalSpeed = 0;
+ let completedFiles = 0;
+ let totalFiles = 0;
+ let isDownloading = false;
+ const allFiles: DownloadProgress['files'] = [];
+ const perNode: Array<{ nodeId: string; nodeName: string; progress: DownloadProgress }> = [];
+
+ // Check all nodes for downloads matching this model
+ for (const [nodeId, nodeDownloads] of Object.entries(downloadsData)) {
+ if (!Array.isArray(nodeDownloads)) continue;
+
+ for (const downloadWrapped of nodeDownloads) {
+ if (!downloadWrapped || typeof downloadWrapped !== 'object') continue;
+
+ const keys = Object.keys(downloadWrapped as Record<string, unknown>);
+ if (keys.length !== 1) continue;
+
+ const downloadKind = keys[0];
+ const downloadPayload = (downloadWrapped as Record<string, unknown>)[downloadKind] as Record<string, unknown>;
+
+ if (downloadKind !== 'DownloadOngoing') continue;
+ if (!downloadPayload) continue;
+
+ const downloadModelId = extractModelIdFromDownload(downloadPayload);
+
+ // Match if the model ID contains or equals the requested model
+ // (handles cases like "mlx-community/Meta-Llama..." matching)
+ if (!downloadModelId || !downloadModelId.includes(modelId.split('/').pop() || modelId)) {
+ // Try exact match or partial match
+ if (downloadModelId !== modelId) continue;
+ }
+
+ isDownloading = true;
+
+ const progress = parseDownloadProgress(downloadPayload);
+ if (progress) {
+ totalBytes += progress.totalBytes;
+ downloadedBytes += progress.downloadedBytes;
+ totalSpeed += progress.speed;
+ completedFiles += progress.completedFiles;
+ totalFiles += progress.totalFiles;
+ allFiles.push(...progress.files);
+
+ const nodeName = data?.nodes?.[nodeId]?.friendly_name ?? nodeId.slice(0, 8);
+ perNode.push({ nodeId, nodeName, progress });
+ }
+ }
+ }
+
+ if (!isDownloading) {
+ return { isDownloading: false, progress: null, perNode: [] };
+ }
+
+ return {
+ isDownloading: true,
+ progress: {
+ totalBytes,
+ downloadedBytes,
+ speed: totalSpeed,
+ etaMs: totalSpeed > 0 ? ((totalBytes - downloadedBytes) / totalSpeed) * 1000 : 0,
+ percentage: totalBytes > 0 ? (downloadedBytes / totalBytes) * 100 : 0,
+ completedFiles,
+ totalFiles,
+ files: allFiles
+ },
+ perNode
+ };
+ }
+
+ // Debug: Log downloads data when it changes
+ $effect(() => {
+ if (downloadsData && Object.keys(downloadsData).length > 0) {
+ console.log('[Download Debug] Current downloads:', downloadsData);
+ }
+ });
+
+ // Helper to get download status for an instance
+ function getInstanceDownloadStatus(instanceId: string, instanceWrapped: unknown): {
+ isDownloading: boolean;
+ progress: DownloadProgress | null;
+ statusText: string;
+ perNode: Array<{ nodeId: string; nodeName: string; progress: DownloadProgress }>;
+ } {
+ if (!downloadsData || Object.keys(downloadsData).length === 0) {
+ return { isDownloading: false, progress: null, statusText: 'RUNNING', perNode: [] };
+ }
+
+ // Unwrap the instance
+ const [instanceTag, instance] = getTagged(instanceWrapped);
+ if (!instance || typeof instance !== 'object') {
+ return { isDownloading: false, progress: null, statusText: 'UNKNOWN', perNode: [] };
+ }
+
+ const inst = instance as { shardAssignments?: { nodeToRunner?: Record<string, string>; runnerToShard?: Record<string, unknown>; modelId?: string } };
+ const nodeToRunner = inst.shardAssignments?.nodeToRunner || {};
+ const runnerToShard = inst.shardAssignments?.runnerToShard || {};
+ const instanceModelId = inst.shardAssignments?.modelId;
+
+ // Build reverse mapping: runnerId -> nodeId
+ const runnerToNode: Record<string, string> = {};
+ for (const [nodeId, runnerId] of Object.entries(nodeToRunner)) {
+ runnerToNode[runnerId] = nodeId;
+ }
+
+ let totalBytes = 0;
+ let downloadedBytes = 0;
+ let totalSpeed = 0;
+ let completedFiles = 0;
+ let totalFiles = 0;
+ let isDownloading = false;
+ const allFiles: DownloadProgress['files'] = [];
+ const perNode: Array<{ nodeId: string; nodeName: string; progress: DownloadProgress }> = [];
+
+ // Check downloads for nodes that are part of this instance
+ for (const runnerId of Object.keys(runnerToShard)) {
+ const nodeId = runnerToNode[runnerId];
+ if (!nodeId) continue;
+
+ const nodeDownloads = downloadsData[nodeId];
+ if (!Array.isArray(nodeDownloads)) continue;
+
+ for (const downloadWrapped of nodeDownloads) {
+ if (!downloadWrapped || typeof downloadWrapped !== 'object') continue;
+
+ const keys = Object.keys(downloadWrapped as Record<string, unknown>);
+ if (keys.length !== 1) continue;
+
+ const downloadKind = keys[0];
+ const downloadPayload = (downloadWrapped as Record<string, unknown>)[downloadKind] as Record<string, unknown>;
+
+ if (downloadKind !== 'DownloadOngoing') continue;
+ if (!downloadPayload) continue;
+
+ // Check if this download is for this instance's model
+ const downloadModelId = extractModelIdFromDownload(downloadPayload);
+ if (instanceModelId && downloadModelId && downloadModelId === instanceModelId) {
+ isDownloading = true;
+
+ const progress = parseDownloadProgress(downloadPayload);
+ if (progress) {
+ totalBytes += progress.totalBytes;
+ downloadedBytes += progress.downloadedBytes;
+ totalSpeed += progress.speed;
+ completedFiles += progress.completedFiles;
+ totalFiles += progress.totalFiles;
+ allFiles.push(...progress.files);
+
+ const nodeName = data?.nodes?.[nodeId]?.friendly_name ?? nodeId.slice(0, 8);
+ perNode.push({ nodeId, nodeName, progress });
+ }
+ }
+ }
+ }
+
+ if (!isDownloading) {
+ // Check runner status for other states
+ const statusInfo = deriveInstanceStatus(instanceWrapped);
+ return { isDownloading: false, progress: null, statusText: statusInfo.statusText, perNode: [] };
+ }
+
+ return {
+ isDownloading: true,
+ progress: {
+ totalBytes,
+ downloadedBytes,
+ speed: totalSpeed,
+ etaMs: totalSpeed > 0 ? ((totalBytes - downloadedBytes) / totalSpeed) * 1000 : 0,
+ percentage: totalBytes > 0 ? (downloadedBytes / totalBytes) * 100 : 0,
+ completedFiles,
+ totalFiles,
+ files: allFiles
+ },
+ statusText: 'DOWNLOADING',
+ perNode
+ };
+ }
+
+ // Derive instance status from runners
+ // Get color class for a status
+ function getStatusColor(statusText: string): string {
+ switch (statusText) {
+ case 'FAILED': return 'text-red-400';
+ case 'DOWNLOADING': return 'text-blue-400';
+ case 'LOADING':
+ case 'WARMING UP':
+ case 'WAITING': return 'text-yellow-400';
+ case 'RUNNING': return 'text-teal-400';
+ case 'READY':
+ case 'LOADED': return 'text-green-400';
+ default: return 'text-exo-light-gray';
+ }
+ }
+
+ function deriveInstanceStatus(instanceWrapped: unknown): { statusText: string; statusClass: string } {
+ const [, instance] = getTagged(instanceWrapped);
+ if (!instance || typeof instance !== 'object') {
+ return { statusText: 'UNKNOWN', statusClass: 'inactive' };
+ }
+
+ const inst = instance as { shardAssignments?: { runnerToShard?: Record<string, unknown> } };
+ const runnerIds = Object.keys(inst.shardAssignments?.runnerToShard || {});
+
+ const statuses = runnerIds
+ .map(rid => {
+ const r = runnersData[rid];
+ if (!r) return null;
+ const [kind] = getTagged(r);
+ const statusMap: Record<string, string> = {
+ RunnerWaitingForModel: 'WaitingForModel',
+ RunnerLoading: 'Loading',
+ RunnerLoaded: 'Loaded',
+ RunnerWarmingUp: 'WarmingUp',
+ RunnerReady: 'Ready',
+ RunnerRunning: 'Running',
+ RunnerFailed: 'Failed',
+ };
+ return kind ? statusMap[kind] || null : null;
+ })
+ .filter((s): s is string => s !== null);
+
+ const has = (s: string) => statuses.includes(s);
+
+ if (statuses.length === 0) return { statusText: 'UNKNOWN', statusClass: 'inactive' };
+ if (has('Failed')) return { statusText: 'FAILED', statusClass: 'failed' };
+ if (has('Loading')) return { statusText: 'LOADING', statusClass: 'starting' };
+ if (has('WarmingUp')) return { statusText: 'WARMING UP', statusClass: 'starting' };
+ if (has('Running')) return { statusText: 'RUNNING', statusClass: 'running' };
+ if (has('Ready')) return { statusText: 'READY', statusClass: 'loaded' };
+ if (has('Loaded')) return { statusText: 'LOADED', statusClass: 'loaded' };
+ if (has('WaitingForModel')) return { statusText: 'WAITING', statusClass: 'starting' };
+
+ return { statusText: 'RUNNING', statusClass: 'active' };
+ }
+
+ function getBytes(value: unknown): number {
+ if (typeof value === 'number') return value;
+ if (value && typeof value === 'object') {
+ const v = value as Record<string, unknown>;
+ if (typeof v.in_bytes === 'number') return v.in_bytes;
+ if (typeof v.inBytes === 'number') return v.inBytes;
+ }
+ return 0;
+ }
+
+ async function deleteInstance(instanceId: string) {
+ if (!confirm(`Delete instance ${instanceId.slice(0, 8)}...?`)) return;
+
+ try {
+ const response = await fetch(`/instance/${instanceId}`, {
+ method: 'DELETE',
+ headers: { 'Content-Type': 'application/json' }
+ });
+
+ if (!response.ok) {
+ console.error('Failed to delete instance:', response.status);
+ }
+ } catch (error) {
+ console.error('Error deleting instance:', error);
+ }
+ }
+
+ // Helper to unwrap tagged unions like { MlxRingInstance: {...} }
+ function getTagged(obj: unknown): [string | null, unknown] {
+ if (!obj || typeof obj !== 'object') return [null, null];
+ const keys = Object.keys(obj as Record<string, unknown>);
+ if (keys.length === 1) {
+ return [keys[0], (obj as Record<string, unknown>)[keys[0]]];
+ }
+ return [null, null];
+ }
+
+ // Get model ID from an instance
+ function getInstanceModelId(instanceWrapped: unknown): string {
+ const [, instance] = getTagged(instanceWrapped);
+ if (!instance || typeof instance !== 'object') return 'Unknown';
+ const inst = instance as { shardAssignments?: { modelId?: string } };
+ return inst.shardAssignments?.modelId || 'Unknown Model';
+ }
+
+ // Get instance details: type (MLX Ring/IBV), sharding (Pipeline/Tensor), and node names
+ function getInstanceInfo(instanceWrapped: unknown): {
+ instanceType: string;
+ sharding: string;
+ nodeNames: string[];
+ nodeIds: string[];
+ nodeCount: number;
+ } {
+ const [instanceTag, instance] = getTagged(instanceWrapped);
+ if (!instance || typeof instance !== 'object') {
+ return { instanceType: 'Unknown', sharding: 'Unknown', nodeNames: [], nodeIds: [], nodeCount: 0 };
+ }
+
+ // Instance type from tag
+ let instanceType = 'Unknown';
+ if (instanceTag === 'MlxRingInstance') instanceType = 'MLX Ring';
+ else if (instanceTag === 'MlxIbvInstance' || instanceTag === 'MlxJacclInstance') instanceType = 'MLX RDMA';
+
+ const inst = instance as {
+ shardAssignments?: {
+ nodeToRunner?: Record<string, string>;
+ runnerToShard?: Record<string, unknown>;
+ }
+ };
+
+ // Sharding strategy from first shard
+ let sharding = 'Unknown';
+ const runnerToShard = inst.shardAssignments?.runnerToShard || {};
+ const firstShardWrapped = Object.values(runnerToShard)[0];
+ if (firstShardWrapped) {
+ const [shardTag] = getTagged(firstShardWrapped);
+ if (shardTag === 'PipelineShardMetadata') sharding = 'Pipeline';
+ else if (shardTag === 'TensorShardMetadata') sharding = 'Tensor';
+ else if (shardTag === 'PrefillDecodeShardMetadata') sharding = 'Prefill/Decode';
+ }
+
+ // Node names from topology
+ const nodeToRunner = inst.shardAssignments?.nodeToRunner || {};
+ const nodeIds = Object.keys(nodeToRunner);
+ const nodeNames = nodeIds.map(nodeId => {
+ const node = data?.nodes?.[nodeId];
+ return node?.friendly_name || nodeId.slice(0, 8);
+ });
+
+ return { instanceType, sharding, nodeNames, nodeIds, nodeCount: nodeIds.length };
+ }
+
+ function formatLastUpdate(): string {
+ if (!update) return 'ACQUIRING...';
+ const seconds = Math.floor((Date.now() - update) / 1000);
+ if (seconds < 5) return 'LIVE';
+ return `${seconds}s AGO`;
+ }
+
+ function formatBytes(bytes: number, decimals = 2): string {
+ if (!bytes || bytes === 0) return '0 B';
+ const k = 1024;
+ const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)) + ' ' + sizes[i];
+ }
+
+ function formatSpeed(bps: number): string {
+ if (!bps || bps <= 0) return '0 B/s';
+ return formatBytes(bps, 1) + '/s';
+ }
+
+ function getNodeLabel(nodeId: string): string {
+ const node = data?.nodes?.[nodeId];
+ return node?.friendly_name || nodeId.slice(0, 8);
+ }
+
+ function getInterfaceLabel(nodeId: string, ip?: string): { label: string; missing: boolean } {
+ if (!ip) return { label: '?', missing: true };
+ const node = data?.nodes?.[nodeId];
+ if (!node) return { label: '?', missing: true };
+
+ // Prefer explicit network_interfaces from NodePerformanceProfile
+ const matchFromInterfaces = node.network_interfaces?.find((iface) =>
+ (iface.addresses || []).some((addr) => addr === ip)
+ );
+ if (matchFromInterfaces?.name) {
+ return { label: `${matchFromInterfaces.name} on ${getNodeLabel(nodeId)}`, missing: false };
+ }
+
+ // Fallback to derived ip_to_interface map
+ const mapped = node.ip_to_interface?.[ip];
+ if (mapped && mapped.trim().length > 0) {
+ return { label: `${mapped} on ${getNodeLabel(nodeId)}`, missing: false };
+ }
+
+ return { label: '?', missing: true };
+ }
+
+ function getOrderedRunnerNodes(instance: Record<string, unknown>, shardType: 'Pipeline' | 'Tensor') {
+ const runnerToShard = (instance.shardAssignments as { runnerToShard?: Record<string, unknown> } | undefined)?.runnerToShard || {};
+ const nodeToRunner = (instance.shardAssignments as { nodeToRunner?: Record<string, string> } | undefined)?.nodeToRunner || {};
+ const runnerEntries = Object.entries(runnerToShard).map(([runnerId, shardWrapped]) => {
+ const [tag, shard] = getTagged(shardWrapped);
+ const meta = (shard as { modelMeta?: { worldSize?: number; nLayers?: number; deviceRank?: number } } | undefined);
+ const deviceRank = (meta?.deviceRank as number | undefined) ?? 0;
+ return { runnerId, tag, deviceRank };
+ });
+
+ const ordered = runnerEntries
+ .filter(r => (shardType === 'Pipeline' ? r.tag === 'PipelineShardMetadata' : r.tag === 'TensorShardMetadata'))
+ .sort((a, b) => a.deviceRank - b.deviceRank)
+ .map((r, idx) => {
+ const nodeId = Object.entries(nodeToRunner).find(([, rid]) => rid === r.runnerId)?.[0];
+ return { nodeId, runnerId: r.runnerId, order: idx };
+ })
+ .filter(item => item.nodeId);
+
+ return ordered as Array<{ nodeId: string; runnerId: string; order: number }>;
+ }
+
+ function pickHost(hosts?: Array<{ ip: string; port: number }>): { ip: string; port: number } | null {
+ if (!hosts || hosts.length === 0) return null;
+ const scored = hosts
+ .filter(h => h.ip && h.ip !== '0.0.0.0' && h.port && h.port > 0)
+ .map(h => {
+ const ip = h.ip;
+ const score = ip.startsWith('10.') || ip.startsWith('172.') || ip.startsWith('192.168') ? 3
+ : ip.startsWith('169.254') ? 2
+ : 1;
+ return { host: h, score };
+ });
+ if (scored.length === 0) return null;
+ scored.sort((a, b) => b.score - a.score);
+ return scored[0].host;
+ }
+
+ function getInstanceConnections(instanceWrapped: unknown): Array<{ from: string; to: string; ip: string; ifaceLabel: string; missingIface: boolean }> {
+ const [instanceTag, instance] = getTagged(instanceWrapped);
+ if (!instance || typeof instance !== 'object') return [];
+
+ // Jaccl (RDMA) – show RDMA interfaces from ibvDevices
+ if (instanceTag === 'MlxJacclInstance') {
+ const ordered = getOrderedRunnerNodes(instance as Record<string, unknown>, 'Tensor');
+ const ibvDevices = (instance as { ibvDevices?: Array<Array<string | null>> }).ibvDevices || [];
+ const rows: Array<{ from: string; to: string; ip: string; ifaceLabel: string; missingIface: boolean }> = [];
+
+ for (let i = 0; i < ordered.length; i++) {
+ for (let j = i + 1; j < ordered.length; j++) {
+ const iface = ibvDevices[i]?.[j] ?? ibvDevices[j]?.[i] ?? null;
+ if (!iface) continue;
+ const fromId = ordered[i].nodeId;
+ const toId = ordered[j].nodeId;
+ rows.push({
+ from: getNodeLabel(fromId),
+ to: getNodeLabel(toId),
+ ip: iface,
+ ifaceLabel: `RDMA ${iface}`,
+ missingIface: false
+ });
+ }
+ }
+ return rows;
+ }
+
+ // Ring – derive ring order from pipeline shard ranks and pick host IPs from hostsByNode
+ if (instanceTag === 'MlxRingInstance') {
+ const ordered = getOrderedRunnerNodes(instance as Record<string, unknown>, 'Pipeline');
+ const hostsByNode = (instance as { hostsByNode?: Record<string, Array<{ ip: string; port: number }>> }).hostsByNode || {};
+ const rows: Array<{ from: string; to: string; ip: string; ifaceLabel: string; missingIface: boolean }> = [];
+ if (ordered.length === 0) return rows;
+
+ for (let idx = 0; idx < ordered.length; idx++) {
+ const current = ordered[idx];
+ const next = ordered[(idx + 1) % ordered.length];
+ const host = pickHost(hostsByNode[next.nodeId]);
+ const ip = host ? `${host.ip}:${host.port}` : '?';
+ const ifaceInfo = host ? getInterfaceLabel(next.nodeId, host.ip) : { label: '?', missing: true };
+ rows.push({
+ from: getNodeLabel(current.nodeId),
+ to: getNodeLabel(next.nodeId),
+ ip,
+ ifaceLabel: ifaceInfo.label,
+ missingIface: ifaceInfo.missing
+ });
+ }
+ return rows;
+ }
+
+ return [];
+ }
+
+ function formatEta(ms: number): string {
+ if (!ms || ms <= 0) return '--';
+ const totalSeconds = Math.round(ms / 1000);
+ const s = totalSeconds % 60;
+ const m = Math.floor(totalSeconds / 60) % 60;
+ const h = Math.floor(totalSeconds / 3600);
+ if (h > 0) return `${h}h ${m}m`;
+ if (m > 0) return `${m}m ${s}s`;
+ return `${s}s`;
+ }
+
+ function handleNewChat() {
+ createConversation();
+ }
+
+ function handleGoHome() {
+ clearChat();
+ }
+
+ // Slider drag handlers
+ function handleSliderDrag(clientX: number) {
+ if (!sliderTrackElement || availableMinNodes <= 1) return;
+
+ const rect = sliderTrackElement.getBoundingClientRect();
+ const percentage = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
+ const rawValue = Math.round(percentage * (availableMinNodes - 1)) + 1;
+ const clampedValue = Math.max(1, Math.min(availableMinNodes, rawValue));
+
+ // Find nearest valid value
+ const validCounts = validMinNodeCounts();
+ if (validCounts.has(clampedValue)) {
+ selectedMinNodes = clampedValue;
+ } else {
+ // Find nearest valid value
+ let nearest = clampedValue;
+ let minDist = Infinity;
+ for (const v of validCounts) {
+ const dist = Math.abs(v - clampedValue);
+ if (dist < minDist) {
+ minDist = dist;
+ nearest = v;
+ }
+ }
+ if (validCounts.size > 0) {
+ selectedMinNodes = nearest;
+ }
+ }
+ }
+
+ function handleSliderMouseDown(event: MouseEvent) {
+ isDraggingSlider = true;
+ handleSliderDrag(event.clientX);
+ }
+
+ function handleSliderMouseMove(event: MouseEvent) {
+ if (isDraggingSlider) {
+ handleSliderDrag(event.clientX);
+ }
+ }
+
+ function handleSliderMouseUp() {
+ isDraggingSlider = false;
+ }
+
+ // Handle touch events for mobile
+ function handleSliderTouchStart(event: TouchEvent) {
+ isDraggingSlider = true;
+ if (event.touches.length > 0) {
+ handleSliderDrag(event.touches[0].clientX);
+ }
+ }
+
+ function handleSliderTouchMove(event: TouchEvent) {
+ if (isDraggingSlider && event.touches.length > 0) {
+ event.preventDefault();
+ handleSliderDrag(event.touches[0].clientX);
+ }
+ }
+
+ function handleSliderTouchEnd() {
+ isDraggingSlider = false;
+ }
+
+ const nodeCount = $derived(data ? Object.keys(data.nodes).length : 0);
+ const instanceCount = $derived(Object.keys(instanceData).length);
+
+ // Helper to get the number of nodes in a placement preview
+ function getPreviewNodeCount(preview: PlacementPreview): number {
+ if (!preview.memory_delta_by_node) return 0;
+ // Count nodes that have non-zero memory delta (i.e. nodes actually used)
+ return Object.entries(preview.memory_delta_by_node).filter(([_, delta]) => delta > 0).length;
+ }
+
+ // Available min nodes options based on topology (like old dashboard)
+ const availableMinNodes = $derived(Math.max(1, nodeCount));
+
+ // Compute which min node values have valid previews for the current model/sharding/instance type
+ // A minNodes value N is valid if there exists a placement with nodeCount >= N
+ // Note: previewsData already contains previews for the selected model (fetched via API)
+ const validMinNodeCounts = $derived(() => {
+ if (!selectedModelId || previewsData.length === 0) {
+ // If no model selected or no previews, allow all node counts (UI shows all as clickable)
+ return new Set(Array.from({ length: availableMinNodes }, (_, i) => i + 1));
+ }
+
+ // Find the max node count among valid placements for this model/sharding/instance
+ // (model_id filter not needed since previewsData is already for selected model)
+ let maxValidNodes = 0;
+ for (const preview of previewsData) {
+ if (preview.sharding !== selectedSharding) continue;
+ if (!matchesSelectedRuntime(preview.instance_meta)) continue;
+ if (preview.error !== null) continue;
+ if (!preview.memory_delta_by_node) continue;
+
+ const previewNodes = getPreviewNodeCount(preview);
+ if (previewNodes > maxValidNodes) {
+ maxValidNodes = previewNodes;
+ }
+ }
+
+ // All values from 1 to maxValidNodes are valid (since there's a placement with >= that many nodes)
+ if (maxValidNodes === 0) return new Set<number>();
+ return new Set(Array.from({ length: maxValidNodes }, (_, i) => i + 1));
+ });
+
+ // 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
+ const filteredPreviews = $derived(() => {
+ if (!selectedModelId || previewsData.length === 0) return [];
+
+ // Find previews matching sharding/instance type (model_id filter not needed since previewsData is already for selected model)
+ const matchingPreviews = previewsData.filter((p: PlacementPreview) =>
+ p.sharding === selectedSharding &&
+ matchesSelectedRuntime(p.instance_meta) &&
+ p.error === null &&
+ p.memory_delta_by_node !== null
+ );
+
+ // Filter to previews with node count >= selectedMinNodes, sorted by node count (ascending)
+ return matchingPreviews
+ .filter((p: PlacementPreview) => getPreviewNodeCount(p) >= selectedMinNodes)
+ .sort((a: PlacementPreview, b: PlacementPreview) => getPreviewNodeCount(a) - getPreviewNodeCount(b));
+ });
+
+ // Get the first filtered preview (for launch function compatibility)
+ const filteredPreview = $derived(() => filteredPreviews()[0] ?? null);
+
+ // Auto-update selectedMinNodes when node count changes (default to 1 = show all placements)
+ $effect(() => {
+ const maxNodes = availableMinNodes;
+ if (!minNodesInitialized && maxNodes > 0) {
+ // On initial load, default to 1 (minimum) to show all valid placements
+ selectedMinNodes = 1;
+ minNodesInitialized = true;
+ } else if (selectedMinNodes > maxNodes) {
+ // If current selection exceeds available nodes, cap it
+ selectedMinNodes = maxNodes;
+ }
+ });
+
+ // Auto-adjust selectedMinNodes to a valid value when it becomes invalid
+ $effect(() => {
+ const valid = validMinNodeCounts();
+ if (valid.size > 0 && !valid.has(selectedMinNodes)) {
+ // Find the smallest valid count >= current selection, or the largest valid count
+ const validArray = Array.from(valid).sort((a, b) => a - b);
+ const nextValid = validArray.find(n => n >= selectedMinNodes) ?? validArray[validArray.length - 1];
+ if (nextValid !== undefined) {
+ selectedMinNodes = nextValid;
+ }
+ }
+ });
+
+ // Calculate total memory usage across all nodes
+ const clusterMemory = $derived(() => {
+ if (!data) return { used: 0, total: 0 };
+ return Object.values(data.nodes).reduce((acc, n) => {
+ const total = n.macmon_info?.memory?.ram_total ?? n.system_info?.memory ?? 0;
+ const used = n.macmon_info?.memory?.ram_usage ?? 0;
+ return { used: acc.used + used, total: acc.total + total };
+ }, { used: 0, total: 0 });
+ });
+
+</script>
+
+<!-- Global event listeners for slider dragging -->
+<svelte:window
+ onmousemove={handleSliderMouseMove}
+ onmouseup={handleSliderMouseUp}
+ ontouchmove={handleSliderTouchMove}
+ ontouchend={handleSliderTouchEnd}
+/>
+
+<div class="relative h-screen w-full flex flex-col bg-exo-dark-gray overflow-hidden">
+ <!-- Scanline overlay -->
+ <div class="fixed inset-0 pointer-events-none z-50 scanlines opacity-20"></div>
+
+ <!-- Shooting Stars Background - one every ~15s -->
+ <div class="shooting-stars">
+ <div class="shooting-star" style="top: 10%; left: 20%; --duration: 45s; --delay: 0s;"></div>
+ <div class="shooting-star" style="top: 30%; left: 65%; --duration: 45s; --delay: 15s;"></div>
+ <div class="shooting-star" style="top: 50%; left: 40%; --duration: 45s; --delay: 30s;"></div>
+ </div>
+
+ <HeaderNav showHome={chatStarted} onHome={handleGoHome} />
+
+ <!-- Main Content -->
+ <main class="flex-1 flex overflow-hidden relative">
+ <!-- Left: Conversation History Sidebar (always visible) -->
+ <div class="w-80 flex-shrink-0 border-r border-exo-yellow/10">
+ <ChatSidebar class="h-full" />
+ </div>
+
+ {#if !chatStarted}
+ <!-- WELCOME STATE: Topology + Instance Controls (no left sidebar for cleaner look) -->
+ <div class="flex-1 flex overflow-visible relative" in:fade={{ duration: 300 }} out:fade={{ duration: 200 }}>
+
+ <!-- Center: MAIN TOPOLOGY DISPLAY -->
+ <div class="flex-1 flex flex-col min-h-0 min-w-0 py-4">
+
+ <!-- Topology Container - Takes most of the space -->
+ <div class="flex-1 relative bg-exo-dark-gray/40 mx-4 mb-4 rounded-lg overflow-hidden">
+
+ <!-- The main topology graph - full container -->
+ <TopologyGraph class="w-full h-full" highlightedNodes={highlightedNodes()} />
+ </div>
+
+ <!-- Chat Input - Below topology -->
+ <div class="px-4 pt-6 pb-8">
+ <div class="max-w-3xl mx-auto">
+ <ChatForm
+ placeholder="Ask anything"
+ showHelperText={false}
+ showModelSelector={true}
+ />
+ </div>
+ </div>
+ </div>
+
+ <!-- Right Sidebar: Instance Controls (wider on welcome page for better visibility) -->
+ <aside class="w-80 border-l border-exo-yellow/10 bg-exo-dark-gray flex flex-col flex-shrink-0">
+ <!-- Running Instances Panel (only shown when instances exist) - Scrollable -->
+ {#if instanceCount > 0}
+ <div class="p-4 flex-shrink-0">
+ <!-- Panel Header -->
+ <div class="flex items-center gap-2 mb-4">
+ <div class="w-2 h-2 bg-exo-yellow rounded-full shadow-[0_0_8px_rgba(255,215,0,0.6)] animate-pulse"></div>
+ <h3 class="text-xs text-exo-yellow font-mono tracking-[0.2em] uppercase">Instances</h3>
+ <div class="flex-1 h-px bg-gradient-to-r from-exo-yellow/30 to-transparent"></div>
+ </div>
+
+ <div
+ bind:this={instancesContainerRef}
+ class="max-h-72 space-y-3 overflow-y-auto"
+ >
+ {#each Object.entries(instanceData) as [id, instance]}
+ {@const downloadInfo = getInstanceDownloadStatus(id, instance)}
+ {@const statusText = downloadInfo.statusText}
+ {@const isDownloading = downloadInfo.isDownloading}
+ {@const isFailed = statusText === 'FAILED'}
+ {@const isLoading = statusText === 'LOADING' || statusText === 'WARMING UP' || statusText === 'WAITING'}
+ {@const isReady = statusText === 'READY' || statusText === 'LOADED'}
+ {@const isRunning = statusText === 'RUNNING'}
+ <!-- Instance Card -->
+ {@const instanceModelId = getInstanceModelId(instance)}
+ {@const instanceInfo = getInstanceInfo(instance)}
+ {@const instanceConnections = getInstanceConnections(instance)}
+ <div
+ class="relative group cursor-pointer"
+ role="button"
+ tabindex="0"
+ onmouseenter={() => hoveredInstanceId = id}
+ onmouseleave={() => hoveredInstanceId = null}
+ onclick={() => {
+ if (instanceModelId && instanceModelId !== 'Unknown' && instanceModelId !== 'Unknown Model') {
+ setSelectedChatModel(instanceModelId);
+ }
+ }}
+ onkeydown={(e) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ if (instanceModelId && instanceModelId !== 'Unknown' && instanceModelId !== 'Unknown Model') {
+ setSelectedChatModel(instanceModelId);
+ }
+ }
+ }}
+ >
+ <!-- Corner accents -->
+ <div class="absolute -top-px -left-px w-2 h-2 border-l border-t {isDownloading ? 'border-blue-500/50' : isFailed ? 'border-red-500/50' : isLoading ? 'border-yellow-500/50' : isReady ? 'border-green-500/50' : 'border-teal-500/50'}"></div>
+ <div class="absolute -top-px -right-px w-2 h-2 border-r border-t {isDownloading ? 'border-blue-500/50' : isFailed ? 'border-red-500/50' : isLoading ? 'border-yellow-500/50' : isReady ? 'border-green-500/50' : 'border-teal-500/50'}"></div>
+ <div class="absolute -bottom-px -left-px w-2 h-2 border-l border-b {isDownloading ? 'border-blue-500/50' : isFailed ? 'border-red-500/50' : isLoading ? 'border-yellow-500/50' : isReady ? 'border-green-500/50' : 'border-teal-500/50'}"></div>
+ <div class="absolute -bottom-px -right-px w-2 h-2 border-r border-b {isDownloading ? 'border-blue-500/50' : isFailed ? 'border-red-500/50' : isLoading ? 'border-yellow-500/50' : isReady ? 'border-green-500/50' : 'border-teal-500/50'}"></div>
+
+ <div class="bg-exo-dark-gray/60 border border-l-2 {isDownloading ? 'border-blue-500/30 border-l-blue-400' : isFailed ? 'border-red-500/30 border-l-red-400' : isLoading ? 'border-exo-yellow/30 border-l-yellow-400' : isReady ? 'border-green-500/30 border-l-green-400' : 'border-teal-500/30 border-l-teal-400'} p-3">
+
+ <div class="flex justify-between items-start mb-2 pl-2">
+ <div class="flex items-center gap-2">
+ <div class="w-1.5 h-1.5 {isDownloading ? 'bg-blue-400 animate-pulse' : isFailed ? 'bg-red-400' : isLoading ? 'bg-yellow-400 animate-pulse' : isReady ? 'bg-green-400' : 'bg-teal-400'} rounded-full shadow-[0_0_6px_currentColor]"></div>
+ <span class="text-exo-light-gray font-mono text-sm tracking-wider">{id.slice(0, 8).toUpperCase()}</span>
+ </div>
+ <button
+ onclick={() => deleteInstance(id)}
+ class="text-xs px-2 py-1 font-mono tracking-wider uppercase border border-red-500/30 text-red-400 hover:bg-red-500/20 hover:text-red-400 hover:border-red-500/50 transition-all duration-200 cursor-pointer"
+ >
+ DELETE
+ </button>
+ </div>
+ <div class="pl-2">
+ <div class="text-exo-yellow text-xs font-mono tracking-wide truncate">{getInstanceModelId(instance)}</div>
+ <div class="text-white/60 text-xs font-mono">Strategy: <span class="text-white/80">{instanceInfo.sharding} ({instanceInfo.instanceType})</span></div>
+ {#if instanceModelId && instanceModelId !== 'Unknown' && instanceModelId !== 'Unknown Model'}
+ <a
+ class="inline-flex items-center gap-1 text-[11px] text-white/60 hover:text-exo-yellow transition-colors mt-1"
+ href={`https://huggingface.co/${instanceModelId}`}
+ target="_blank"
+ rel="noreferrer noopener"
+ aria-label="View model on Hugging Face"
+ >
+ <span>Hugging Face</span>
+ <svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+ <path d="M14 3h7v7"/>
+ <path d="M10 14l11-11"/>
+ <path d="M21 14v6a1 1 0 0 1-1 1h-16a1 1 0 0 1-1-1v-16a1 1 0 0 1 1-1h6"/>
+ </svg>
+ </a>
+ {/if}
+ {#if instanceInfo.nodeNames.length > 0}
+ <div class="text-white/60 text-xs font-mono">{instanceInfo.nodeNames.join(', ')}</div>
+ {/if}
+ {#if debugEnabled && instanceConnections.length > 0}
+ <div class="mt-2 space-y-1">
+ {#each instanceConnections as conn}
+ <div class="text-[11px] leading-snug font-mono text-white/70">
+ <span>{conn.from} -> {conn.to}: {conn.ip}</span>
+ <span class="{conn.missingIface ? 'text-red-400' : 'text-white/60'}"> ({conn.ifaceLabel})</span>
+ </div>
+ {/each}
+ </div>
+ {/if}
+
+ <!-- Download Progress -->
+ {#if downloadInfo.isDownloading && downloadInfo.progress}
+ <div class="mt-2 space-y-1">
+ <div class="flex justify-between text-xs font-mono">
+ <span class="text-blue-400">{downloadInfo.progress.percentage.toFixed(1)}%</span>
+ <span class="text-exo-light-gray">{formatBytes(downloadInfo.progress.downloadedBytes)}/{formatBytes(downloadInfo.progress.totalBytes)}</span>
+ </div>
+ <div class="relative h-1.5 bg-exo-black/60 rounded-sm overflow-hidden">
+ <div
+ class="absolute inset-y-0 left-0 bg-gradient-to-r from-blue-500 to-blue-400 transition-all duration-300"
+ style="width: {downloadInfo.progress.percentage}%"
+ ></div>
+ </div>
+ <div class="flex justify-between text-xs font-mono text-exo-light-gray">
+ <span>{formatSpeed(downloadInfo.progress.speed)}</span>
+ <span>ETA: {formatEta(downloadInfo.progress.etaMs)}</span>
+ <span>{downloadInfo.progress.completedFiles}/{downloadInfo.progress.totalFiles} files</span>
+ </div>
+ </div>
+ {#if downloadInfo.perNode.length > 0}
+ <div class="mt-2 space-y-2 max-h-48 overflow-y-auto pr-1">
+ {#each downloadInfo.perNode as nodeProg}
+ {@const nodePercent = Math.min(100, Math.max(0, nodeProg.progress.percentage))}
+ {@const isExpanded = instanceDownloadExpandedNodes.has(nodeProg.nodeId)}
+ <div class="rounded border border-exo-medium-gray/40 bg-exo-black/30 p-2">
+ <button
+ type="button"
+ class="w-full text-left space-y-1.5"
+ onclick={() => toggleInstanceDownloadDetails(nodeProg.nodeId)}
+ >
+ <div class="flex items-center justify-between text-[11px] font-mono text-exo-light-gray">
+ <span class="text-white/80 truncate pr-2">{nodeProg.nodeName}</span>
+ <span class="flex items-center gap-1 text-blue-300">
+ {nodePercent.toFixed(1)}%
+ <svg class="w-3 h-3 text-exo-light-gray" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2">
+ <path d="M6 8l4 4 4-4" class={isExpanded ? 'transform rotate-180 origin-center transition-transform duration-150' : 'transition-transform duration-150'}></path>
+ </svg>
+ </span>
+ </div>
+ <div class="relative h-1.5 bg-exo-black/60 rounded-sm overflow-hidden">
+ <div
+ class="absolute inset-y-0 left-0 bg-gradient-to-r from-blue-500 to-blue-400 transition-all duration-300"
+ style="width: {nodePercent.toFixed(1)}%"
+ ></div>
+ </div>
+ <div class="flex items-center justify-between text-[11px] font-mono text-exo-light-gray">
+ <span>{formatBytes(nodeProg.progress.downloadedBytes)} / {formatBytes(nodeProg.progress.totalBytes)}</span>
+ <span>{formatSpeed(nodeProg.progress.speed)} • ETA {formatEta(nodeProg.progress.etaMs)}</span>
+ </div>
+ </button>
+
+ {#if isExpanded}
+ <div class="mt-2 space-y-1.5">
+ {#if nodeProg.progress.files.length === 0}
+ <div class="text-[11px] font-mono text-exo-light-gray/70">No file details reported.</div>
+ {:else}
+ {#each nodeProg.progress.files as f}
+ {@const filePercent = Math.min(100, Math.max(0, f.percentage ?? 0))}
+ <div class="rounded border border-exo-medium-gray/30 bg-exo-black/40 p-2">
+ <div class="flex items-center justify-between text-[10px] font-mono text-exo-light-gray/90">
+ <span class="truncate pr-2">{f.name}</span>
+ <span class="text-white/80">{filePercent.toFixed(1)}%</span>
+ </div>
+ <div class="relative h-1 bg-exo-black/60 rounded-sm overflow-hidden mt-1">
+ <div
+ class="absolute inset-y-0 left-0 bg-gradient-to-r from-exo-yellow to-exo-yellow/70 transition-all duration-300"
+ style="width: {filePercent.toFixed(1)}%"
+ ></div>
+ </div>
+ <div class="flex items-center justify-between text-[10px] text-exo-light-gray/70 mt-0.5">
+ <span>{formatBytes(f.downloadedBytes)} / {formatBytes(f.totalBytes)}</span>
+ <span>{formatSpeed(f.speed)} • ETA {formatEta(f.etaMs)}</span>
+ </div>
+ </div>
+ {/each}
+ {/if}
+ </div>
+ {/if}
+ </div>
+ {/each}
+ </div>
+ {/if}
+ <div class="text-xs text-blue-400 font-mono tracking-wider mt-1">DOWNLOADING</div>
+ {:else}
+ <div class="text-xs {getStatusColor(downloadInfo.statusText)} font-mono tracking-wider mt-1">{downloadInfo.statusText}</div>
+ {/if}
+ </div>
+ </div>
+ </div>
+ {/each}
+ </div>
+ </div>
+ {/if}
+
+ <!-- Models Panel - Scrollable -->
+ <div class="p-4 flex-1 overflow-y-auto">
+ <!-- Panel Header -->
+ <div class="flex items-center gap-2 mb-3 flex-shrink-0">
+ <div class="w-2 h-2 border border-exo-yellow/60 rotate-45"></div>
+ <h3 class="text-xs text-exo-yellow font-mono tracking-[0.2em] uppercase">Launch Instance</h3>
+ <div class="flex-1 h-px bg-gradient-to-r from-exo-yellow/30 to-transparent"></div>
+ <span class="text-sm text-white/70 font-mono">{models.length} models</span>
+ </div>
+
+ <!-- Model Dropdown (Custom) -->
+ <div class="flex-shrink-0 mb-3 relative">
+ <button
+ type="button"
+ onclick={() => isModelDropdownOpen = !isModelDropdownOpen}
+ class="w-full bg-exo-medium-gray/50 border border-exo-yellow/30 rounded pl-3 pr-8 py-2.5 text-sm font-mono text-left tracking-wide cursor-pointer transition-all duration-200 hover:border-exo-yellow/50 focus:outline-none focus:border-exo-yellow/70 {isModelDropdownOpen ? 'border-exo-yellow/70' : ''}"
+ >
+ {#if selectedModelId}
+ {@const foundModel = models.find(m => m.id === selectedModelId)}
+ {#if foundModel}
+ {@const sizeGB = getModelSizeGB(foundModel)}
+ <span class="flex items-center justify-between gap-2 w-full pr-4">
+ <span class="text-exo-light-gray truncate">{foundModel.name || foundModel.id}</span>
+ <span class="text-white/50 text-xs flex-shrink-0">{sizeGB >= 1 ? sizeGB.toFixed(0) : sizeGB.toFixed(1)}GB</span>
+ </span>
+ {:else}
+ <span class="text-exo-light-gray">{selectedModelId}</span>
+ {/if}
+ {:else}
+ <span class="text-white/50">— SELECT MODEL —</span>
+ {/if}
+ </button>
+ <div class="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none transition-transform duration-200 {isModelDropdownOpen ? 'rotate-180' : ''}">
+ <svg class="w-4 h-4 text-exo-yellow/60" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
+ </svg>
+ </div>
+
+ {#if isModelDropdownOpen}
+ <!-- Backdrop to close dropdown -->
+ <button
+ type="button"
+ class="fixed inset-0 z-40 cursor-default"
+ onclick={() => isModelDropdownOpen = false}
+ aria-label="Close dropdown"
+ ></button>
+
+ <!-- Dropdown Panel -->
+ <div class="absolute top-full left-0 right-0 mt-1 bg-exo-dark-gray border border-exo-yellow/30 rounded shadow-lg shadow-black/50 z-50 max-h-64 overflow-y-auto">
+ <!-- Search within dropdown -->
+ <div class="sticky top-0 bg-exo-dark-gray border-b border-exo-medium-gray/30 p-2">
+ <input
+ type="text"
+ placeholder="Search models..."
+ bind:value={modelDropdownSearch}
+ class="w-full bg-exo-dark-gray/60 border border-exo-medium-gray/30 rounded px-2 py-1.5 text-xs font-mono text-white/80 placeholder:text-white/40 focus:outline-none focus:border-exo-yellow/50"
+ />
+ </div>
+
+ <!-- Options -->
+ <div class="py-1">
+ {#each sortedModels().filter(m =>
+ !modelDropdownSearch ||
+ (m.name || m.id).toLowerCase().includes(modelDropdownSearch.toLowerCase())
+ ) as model}
+ {@const sizeGB = getModelSizeGB(model)}
+ {@const modelCanFit = hasEnoughMemory(model)}
+ <button
+ type="button"
+ onclick={() => {
+ if (modelCanFit) {
+ selectPreviewModel(model.id);
+ isModelDropdownOpen = false;
+ modelDropdownSearch = '';
+ }
+ }}
+ disabled={!modelCanFit}
+ class="w-full px-3 py-2 text-left text-sm font-mono tracking-wide transition-colors duration-100 flex items-center justify-between gap-2 {
+ selectedModelId === model.id
+ ? 'bg-transparent text-exo-yellow cursor-pointer'
+ : modelCanFit
+ ? 'text-white/80 hover:text-exo-yellow cursor-pointer'
+ : 'text-white/30 cursor-default'
+ }"
+ >
+ <span class="truncate">{model.name || model.id}</span>
+ <span class="flex-shrink-0 text-xs {modelCanFit ? 'text-white/50' : 'text-red-400/60'}">
+ {sizeGB >= 1 ? sizeGB.toFixed(0) : sizeGB.toFixed(1)}GB
+ </span>
+ </button>
+ {:else}
+ <div class="px-3 py-2 text-xs text-white/50 font-mono">No models found</div>
+ {/each}
+ </div>
+ </div>
+ {/if}
+ </div>
+
+ <!-- Configuration Options -->
+ <div class="flex-shrink-0 mb-4 space-y-3">
+ <!-- Sharding -->
+ <div>
+ <div class="text-xs text-white/70 font-mono mb-2">Sharding:</div>
+ <div class="flex gap-2">
+ <button
+ onclick={() => selectedSharding = 'Pipeline'}
+ class="flex items-center gap-2 py-2 px-4 text-sm font-mono border rounded transition-all duration-200 cursor-pointer {selectedSharding === 'Pipeline' ? 'bg-transparent text-exo-yellow border-exo-yellow' : 'bg-transparent text-white/70 border-exo-medium-gray/50 hover:border-exo-yellow/50'}"
+ >
+ <span class="w-4 h-4 rounded-full border-2 flex items-center justify-center {selectedSharding === 'Pipeline' ? 'border-exo-yellow' : 'border-exo-medium-gray'}">
+ {#if selectedSharding === 'Pipeline'}
+ <span class="w-2 h-2 rounded-full bg-exo-yellow"></span>
+ {/if}
+ </span>
+ Pipeline
+ </button>
+ <button
+ onclick={() => selectedSharding = 'Tensor'}
+ class="flex items-center gap-2 py-2 px-4 text-sm font-mono border rounded transition-all duration-200 cursor-pointer {selectedSharding === 'Tensor' ? 'bg-transparent text-exo-yellow border-exo-yellow' : 'bg-transparent text-white/70 border-exo-medium-gray/50 hover:border-exo-yellow/50'}"
+ >
+ <span class="w-4 h-4 rounded-full border-2 flex items-center justify-center {selectedSharding === 'Tensor' ? 'border-exo-yellow' : 'border-exo-medium-gray'}">
+ {#if selectedSharding === 'Tensor'}
+ <span class="w-2 h-2 rounded-full bg-exo-yellow"></span>
+ {/if}
+ </span>
+ Tensor
+ </button>
+ </div>
+ </div>
+
+ <!-- Instance Type -->
+ <div>
+ <div class="text-xs text-white/70 font-mono mb-2">Instance Type:</div>
+ <div class="flex gap-2">
+ <button
+ onclick={() => selectedInstanceType = 'MlxRing'}
+ class="flex items-center gap-2 py-2 px-4 text-sm font-mono border rounded transition-all duration-200 cursor-pointer {selectedInstanceType === 'MlxRing' ? 'bg-transparent text-exo-yellow border-exo-yellow' : 'bg-transparent text-white/70 border-exo-medium-gray/50 hover:border-exo-yellow/50'}"
+ >
+ <span class="w-4 h-4 rounded-full border-2 flex items-center justify-center {selectedInstanceType === 'MlxRing' ? 'border-exo-yellow' : 'border-exo-medium-gray'}">
+ {#if selectedInstanceType === 'MlxRing'}
+ <span class="w-2 h-2 rounded-full bg-exo-yellow"></span>
+ {/if}
+ </span>
+ MLX Ring
+ </button>
+ <button
+ onclick={() => selectedInstanceType = 'MlxIbv'}
+ class="flex items-center gap-2 py-2 px-4 text-sm font-mono border rounded transition-all duration-200 cursor-pointer {selectedInstanceType === 'MlxIbv' ? 'bg-transparent text-exo-yellow border-exo-yellow' : 'bg-transparent text-white/70 border-exo-medium-gray/50 hover:border-exo-yellow/50'}"
+ >
+ <span class="w-4 h-4 rounded-full border-2 flex items-center justify-center {selectedInstanceType === 'MlxIbv' ? 'border-exo-yellow' : 'border-exo-medium-gray'}">
+ {#if selectedInstanceType === 'MlxIbv'}
+ <span class="w-2 h-2 rounded-full bg-exo-yellow"></span>
+ {/if}
+ </span>
+ MLX RDMA
+ </button>
+ </div>
+ </div>
+
+ <!-- Minimum Nodes (discrete slider with drag support) -->
+ <div>
+ <div class="text-xs text-white/70 font-mono mb-2">Minimum Nodes:</div>
+ <!-- Discrete slider track with drag support -->
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
+ <div
+ bind:this={sliderTrackElement}
+ class="relative h-16 cursor-pointer select-none px-2 pr-6"
+ onmousedown={handleSliderMouseDown}
+ ontouchstart={handleSliderTouchStart}
+ >
+ <!-- Track background - extends full width to align with edge dots -->
+ <div class="absolute top-6 left-0 right-0 h-2 bg-exo-medium-gray/50 rounded-full"></div>
+ <!-- Active track (fills up to selected) -->
+ {#if availableMinNodes > 1}
+ <div
+ class="absolute top-6 left-0 h-2 bg-white/30 rounded-full transition-all pointer-events-none"
+ style="width: {((selectedMinNodes - 1) / (availableMinNodes - 1)) * 100}%"
+ ></div>
+ {/if}
+ <!-- Dots and labels for each node count -->
+ {#each Array.from({ length: availableMinNodes }, (_, i) => i + 1) as n}
+ {@const isValid = validMinNodeCounts().has(n)}
+ {@const isSelected = selectedMinNodes === n}
+ {@const position = availableMinNodes > 1 ? ((n - 1) / (availableMinNodes - 1)) * 100 : 50}
+ <div
+ class="absolute flex flex-col items-center pointer-events-none"
+ style="left: {position}%; top: 0; transform: translateX(-50%);"
+ >
+ <!-- Dot -->
+ <span
+ class="rounded-full transition-all {isSelected
+ ? 'w-6 h-6 bg-exo-yellow shadow-[0_0_10px_rgba(255,215,0,0.6)]'
+ : isValid
+ ? 'w-4 h-4 bg-exo-light-gray/70 mt-1'
+ : 'w-3 h-3 bg-exo-medium-gray/50 mt-1.5'}"
+ ></span>
+ <!-- Number label below dot -->
+ <span
+ class="text-sm font-mono mt-1.5 tabular-nums transition-colors {isSelected
+ ? 'text-exo-yellow font-bold'
+ : isValid
+ ? 'text-white/70'
+ : 'text-white/30'}"
+ >{n}</span>
+ </div>
+ {/each}
+ </div>
+ </div>
+ </div>
+
+ <!-- Selected Model Preview -->
+ <div class="space-y-3">
+ {#if models.length === 0}
+ <div class="text-center py-8">
+ <div class="text-xs text-white/70 font-mono tracking-wider uppercase">Loading models...</div>
+ </div>
+ {:else if loadingPreviews}
+ <div class="text-center py-8">
+ <div class="text-xs text-exo-yellow font-mono tracking-wider uppercase animate-pulse">Loading preview...</div>
+ </div>
+ {:else}
+ {@const selectedModel = models.find(m => m.id === selectedModelId)}
+ {@const allPreviews = filteredPreviews()}
+ {#if selectedModel && allPreviews.length > 0}
+ {@const downloadStatus = getModelDownloadStatus(selectedModel.id)}
+ {@const tags = modelTags()[selectedModel.id] || []}
+ <div class="space-y-3">
+ {#each allPreviews as apiPreview, i}
+ <div
+ role="group"
+ onmouseenter={() => {
+ if (apiPreview.memory_delta_by_node) {
+ hoveredPreviewNodes = new Set(
+ Object.entries(apiPreview.memory_delta_by_node)
+ .filter(([, delta]) => (delta ?? 0) > 0)
+ .map(([nodeId]) => nodeId)
+ );
+ }
+ }}
+ onmouseleave={() => hoveredPreviewNodes = new Set()}
+ >
+ <ModelCard
+ model={selectedModel}
+ isLaunching={launchingModelId === selectedModel.id}
+ {downloadStatus}
+ nodes={data?.nodes ?? {}}
+ sharding={apiPreview.sharding}
+ runtime={apiPreview.instance_meta}
+ onLaunch={() => launchInstance(selectedModel.id, apiPreview)}
+ {tags}
+ {apiPreview}
+ modelIdOverride={apiPreview.model_id}
+ />
+ </div>
+ {/each}
+ </div>
+ {:else if selectedModel}
+ <div class="text-center py-4">
+ <div class="text-xs text-white/50 font-mono">No valid configurations for current settings</div>
+ </div>
+ {/if}
+ {/if}
+ </div>
+ </div>
+ </aside>
+
+ </div>
+ {:else}
+ <!-- CHAT STATE: Chat + Mini-Map -->
+ <div class="flex-1 flex overflow-hidden">
+ <!-- Chat Area -->
+ <div
+ class="flex-1 flex flex-col min-w-0 overflow-hidden"
+ in:fade={{ duration: 300, delay: 100 }}
+ >
+ <div class="flex-1 overflow-y-auto px-8 py-6" bind:this={chatScrollRef}>
+ <div class="max-w-3xl mx-auto">
+ <ChatMessages scrollParent={chatScrollRef} />
+ </div>
+ </div>
+
+ <div class="flex-shrink-0 px-8 pb-6 pt-4 bg-gradient-to-t from-exo-black via-exo-black to-transparent">
+ <div class="max-w-3xl mx-auto">
+ <ChatForm placeholder="Ask anything" showModelSelector={true} />
+ </div>
+ </div>
+ </div>
+
+ <!-- Right: Mini-Map Sidebar -->
+ {#if minimized}
+ <aside
+ class="w-80 border-l border-exo-yellow/20 bg-exo-dark-gray flex flex-col flex-shrink-0 overflow-y-auto"
+ in:fly={{ x: 100, duration: 400, easing: cubicInOut }}
+ >
+ <!-- Topology Section - clickable to go back to main view -->
+ <button
+ class="p-4 border-b border-exo-medium-gray/30 w-full text-left cursor-pointer hover:bg-exo-medium-gray/10 transition-colors"
+ onclick={handleGoHome}
+ title="Click to return to main topology view"
+ >
+ <div class="flex items-center justify-between mb-3">
+ <div class="text-xs text-exo-yellow tracking-[0.2em] uppercase flex items-center gap-2">
+ <span class="w-1.5 h-1.5 bg-exo-yellow rounded-full status-pulse"></span>
+ TOPOLOGY
+ </div>
+ <span class="text-xs text-white/70 tabular-nums">{nodeCount} {nodeCount === 1 ? 'NODE' : 'NODES'}</span>
+ </div>
+
+ <div class="relative aspect-square bg-exo-dark-gray rounded-lg overflow-hidden">
+
+ <TopologyGraph highlightedNodes={highlightedNodes()} />
+ </div>
+ </button>
+
+ <!-- Instances Section (only shown when instances exist) -->
+ {#if instanceCount > 0}
+ <div class="p-4 flex-1">
+ <!-- Panel Header -->
+ <div class="flex items-center gap-2 mb-4">
+ <div class="w-2 h-2 bg-exo-yellow rounded-full shadow-[0_0_8px_rgba(255,215,0,0.6)] animate-pulse"></div>
+ <h3 class="text-sm text-exo-yellow font-mono tracking-[0.2em] uppercase">Instances</h3>
+ <div class="flex-1 h-px bg-gradient-to-r from-exo-yellow/30 to-transparent"></div>
+ </div>
+ <div class="space-y-3 max-h-72 overflow-y-auto pr-1">
+ {#each Object.entries(instanceData) as [id, instance]}
+ {@const downloadInfo = getInstanceDownloadStatus(id, instance)}
+ {@const statusText = downloadInfo.statusText}
+ {@const isDownloading = downloadInfo.isDownloading}
+ {@const isFailed = statusText === 'FAILED'}
+ {@const isLoading = statusText === 'LOADING' || statusText === 'WARMING UP' || statusText === 'WAITING'}
+ {@const isReady = statusText === 'READY' || statusText === 'LOADED'}
+ {@const isRunning = statusText === 'RUNNING'}
+ <!-- Instance Card -->
+ {@const instanceModelId = getInstanceModelId(instance)}
+ {@const instanceInfo = getInstanceInfo(instance)}
+ {@const instanceConnections = getInstanceConnections(instance)}
+ <div
+ class="relative group cursor-pointer"
+ role="button"
+ tabindex="0"
+ onmouseenter={() => hoveredInstanceId = id}
+ onmouseleave={() => hoveredInstanceId = null}
+ onclick={() => {
+ if (instanceModelId && instanceModelId !== 'Unknown' && instanceModelId !== 'Unknown Model') {
+ setSelectedChatModel(instanceModelId);
+ }
+ }}
+ onkeydown={(e) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ if (instanceModelId && instanceModelId !== 'Unknown' && instanceModelId !== 'Unknown Model') {
+ setSelectedChatModel(instanceModelId);
+ }
+ }
+ }}
+ >
+ <!-- Corner accents -->
+ <div class="absolute -top-px -left-px w-2 h-2 border-l border-t {isDownloading ? 'border-blue-500/50' : isFailed ? 'border-red-500/50' : isLoading ? 'border-yellow-500/50' : isReady ? 'border-green-500/50' : 'border-teal-500/50'}"></div>
+ <div class="absolute -top-px -right-px w-2 h-2 border-r border-t {isDownloading ? 'border-blue-500/50' : isFailed ? 'border-red-500/50' : isLoading ? 'border-yellow-500/50' : isReady ? 'border-green-500/50' : 'border-teal-500/50'}"></div>
+ <div class="absolute -bottom-px -left-px w-2 h-2 border-l border-b {isDownloading ? 'border-blue-500/50' : isFailed ? 'border-red-500/50' : isLoading ? 'border-yellow-500/50' : isReady ? 'border-green-500/50' : 'border-teal-500/50'}"></div>
+ <div class="absolute -bottom-px -right-px w-2 h-2 border-r border-b {isDownloading ? 'border-blue-500/50' : isFailed ? 'border-red-500/50' : isLoading ? 'border-yellow-500/50' : isReady ? 'border-green-500/50' : 'border-teal-500/50'}"></div>
+
+ <div class="bg-exo-dark-gray/60 border border-l-2 {isDownloading ? 'border-blue-500/30 border-l-blue-400' : isFailed ? 'border-red-500/30 border-l-red-400' : isLoading ? 'border-exo-yellow/30 border-l-yellow-400' : isReady ? 'border-green-500/30 border-l-green-400' : 'border-teal-500/30 border-l-teal-400'} p-3">
+
+ <div class="flex justify-between items-start mb-2 pl-2">
+ <div class="flex items-center gap-2">
+ <div class="w-1.5 h-1.5 {isDownloading ? 'bg-blue-400 animate-pulse' : isFailed ? 'bg-red-400' : isLoading ? 'bg-yellow-400 animate-pulse' : isReady ? 'bg-green-400' : 'bg-teal-400'} rounded-full shadow-[0_0_6px_currentColor]"></div>
+ <span class="text-exo-light-gray font-mono text-xs tracking-wider">{id.slice(0, 8).toUpperCase()}</span>
+ </div>
+ <button
+ onclick={() => deleteInstance(id)}
+ class="text-xs px-2 py-1 font-mono tracking-wider uppercase border border-red-500/30 text-red-400/80 hover:bg-red-500/20 hover:text-red-400 hover:border-red-500/50 transition-all duration-200 cursor-pointer"
+ >
+ DELETE
+ </button>
+ </div>
+ <div class="pl-2">
+ <div class="text-exo-yellow text-sm font-mono tracking-wide truncate">{getInstanceModelId(instance)}</div>
+ <div class="text-white/60 text-xs font-mono">Strategy: <span class="text-white/80">{instanceInfo.sharding} ({instanceInfo.instanceType})</span></div>
+ {#if instanceModelId && instanceModelId !== 'Unknown' && instanceModelId !== 'Unknown Model'}
+ <a
+ class="inline-flex items-center gap-1 text-[10px] text-white/60 hover:text-exo-yellow transition-colors mt-0.5"
+ href={`https://huggingface.co/${instanceModelId}`}
+ target="_blank"
+ rel="noreferrer noopener"
+ aria-label="View model on Hugging Face"
+ >
+ <span>Hugging Face</span>
+ <svg class="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+ <path d="M14 3h7v7"/>
+ <path d="M10 14l11-11"/>
+ <path d="M21 14v6a1 1 0 0 1-1 1h-16a1 1 0 0 1-1-1v-16a1 1 0 0 1 1-1h6"/>
+ </svg>
+ </a>
+ {/if}
+ {#if instanceInfo.nodeNames.length > 0}
+ <div class="text-white/60 text-xs font-mono">{instanceInfo.nodeNames.join(', ')}</div>
+ {/if}
+ {#if debugEnabled && instanceConnections.length > 0}
+ <div class="mt-1 space-y-0.5">
+ {#each instanceConnections as conn}
+ <div class="text-[10px] leading-snug font-mono text-white/70">
+ <span>{conn.from} -> {conn.to}: {conn.ip}</span>
+ <span class="{conn.missingIface ? 'text-red-400' : 'text-white/60'}"> ({conn.ifaceLabel})</span>
+ </div>
+ {/each}
+ </div>
+ {/if}
+
+ <!-- Download Progress -->
+ {#if downloadInfo.isDownloading && downloadInfo.progress}
+ <div class="mt-2 space-y-1">
+ <div class="flex justify-between text-sm font-mono">
+ <span class="text-blue-400">{downloadInfo.progress.percentage.toFixed(1)}%</span>
+ <span class="text-exo-light-gray">{formatBytes(downloadInfo.progress.downloadedBytes)}/{formatBytes(downloadInfo.progress.totalBytes)}</span>
+ </div>
+ <div class="relative h-1 bg-exo-black/60 rounded-sm overflow-hidden">
+ <div
+ class="absolute inset-y-0 left-0 bg-gradient-to-r from-blue-500 to-blue-400 transition-all duration-300"
+ style="width: {downloadInfo.progress.percentage}%"
+ ></div>
+ </div>
+ <div class="flex justify-between text-xs font-mono text-exo-light-gray">
+ <span>{formatSpeed(downloadInfo.progress.speed)}</span>
+ <span>ETA: {formatEta(downloadInfo.progress.etaMs)}</span>
+ <span>{downloadInfo.progress.completedFiles}/{downloadInfo.progress.totalFiles} files</span>
+ </div>
+ </div>
+ {#if downloadInfo.perNode.length > 0}
+ <div class="mt-2 space-y-1.5 max-h-48 overflow-y-auto pr-1">
+ {#each downloadInfo.perNode as nodeProg}
+ <div class="rounded border border-exo-medium-gray/40 bg-exo-black/30 p-2">
+ <div class="flex items-center justify-between text-[11px] font-mono text-exo-light-gray mb-1">
+ <span class="text-white/80 truncate pr-2">{nodeProg.nodeName}</span>
+ <span class="text-blue-300">{Math.min(100, Math.max(0, nodeProg.progress.percentage)).toFixed(1)}%</span>
+ </div>
+ <div class="relative h-1 bg-exo-black/60 rounded-sm overflow-hidden mb-1.5">
+ <div
+ class="absolute inset-y-0 left-0 bg-blue-500/80 transition-all duration-300"
+ style="width: {Math.min(100, Math.max(0, nodeProg.progress.percentage)).toFixed(1)}%"
+ ></div>
+ </div>
+ <div class="flex items-center justify-between text-[11px] font-mono text-exo-light-gray mb-1">
+ <span>{formatBytes(nodeProg.progress.downloadedBytes)} / {formatBytes(nodeProg.progress.totalBytes)}</span>
+ <span>{formatSpeed(nodeProg.progress.speed)} • ETA {formatEta(nodeProg.progress.etaMs)}</span>
+ </div>
+ {#if nodeProg.progress.files.length > 0}
+ {@const inProgressFiles = nodeProg.progress.files.filter(f => (f.percentage ?? 0) < 100)}
+ {@const completedFiles = nodeProg.progress.files.filter(f => (f.percentage ?? 0) >= 100)}
+ {#if inProgressFiles.length > 0}
+ <div class="space-y-1">
+ {#each inProgressFiles as f}
+ <div class="text-[10px] font-mono text-exo-light-gray/80">
+ <div class="flex items-center justify-between">
+ <span class="truncate pr-2">{f.name}</span>
+ <span class="text-white/70">{Math.min(100, Math.max(0, f.percentage)).toFixed(1)}%</span>
+ </div>
+ <div class="relative h-1 bg-exo-black/50 rounded-sm overflow-hidden mt-0.5">
+ <div
+ class="absolute inset-y-0 left-0 bg-gradient-to-r from-exo-yellow to-exo-yellow/70"
+ style="width: {Math.min(100, Math.max(0, f.percentage)).toFixed(1)}%"
+ ></div>
+ </div>
+ <div class="flex items-center justify-between text-[10px] text-exo-light-gray/70 mt-0.5">
+ <span>{formatBytes(f.downloadedBytes)} / {formatBytes(f.totalBytes)}</span>
+ <span>{formatSpeed(f.speed)} • ETA {formatEta(f.etaMs)}</span>
+ </div>
+ </div>
+ {/each}
+ </div>
+ {/if}
+ {#if completedFiles.length > 0}
+ <div class="pt-1 space-y-0.5">
+ {#each completedFiles as f}
+ <div class="text-[10px] font-mono text-exo-light-gray/70 flex items-center justify-between">
+ <span class="truncate pr-2">{f.name}</span>
+ <span class="text-white/60">100%</span>
+ </div>
+ {/each}
+ </div>
+ {/if}
+ {/if}
+ </div>
+ {/each}
+ </div>
+ {/if}
+ <div class="text-sm text-blue-400 font-mono tracking-wider mt-1">DOWNLOADING</div>
+ {:else}
+ <div class="text-sm {getStatusColor(downloadInfo.statusText)} font-mono tracking-wider mt-1">{downloadInfo.statusText}</div>
+ {/if}
+ </div>
+ </div>
+ </div>
+ {/each}
+ </div>
+ </div>
+ {/if}
+ </aside>
+ {/if}
+ </div>
+ {/if}
+ </main>
+
+</div>
diff --git a/dashboard/src/routes/downloads/+page.svelte b/dashboard/src/routes/downloads/+page.svelte
new file mode 100644
index 00000000..81e29ed9
--- /dev/null
+++ b/dashboard/src/routes/downloads/+page.svelte
@@ -0,0 +1,441 @@
+<script lang="ts">
+ import { onMount } from 'svelte';
+ import {
+ topologyData,
+ downloads,
+ type DownloadProgress,
+ refreshState,
+ lastUpdate as lastUpdateStore
+ } from '$lib/stores/app.svelte';
+ import HeaderNav from '$lib/components/HeaderNav.svelte';
+
+ type FileProgress = {
+ name: string;
+ totalBytes: number;
+ downloadedBytes: number;
+ speed: number;
+ etaMs: number;
+ percentage: number;
+ };
+
+ type ModelEntry = {
+ modelId: string;
+ prettyName?: string | null;
+ percentage: number;
+ downloadedBytes: number;
+ totalBytes: number;
+ speed: number;
+ etaMs: number;
+ status: 'completed' | 'downloading';
+ files: FileProgress[];
+ };
+
+ type NodeEntry = {
+ nodeId: string;
+ nodeName: string;
+ models: ModelEntry[];
+ };
+
+ const data = $derived(topologyData());
+ const downloadsData = $derived(downloads());
+
+ function getNodeLabel(nodeId: string): string {
+ const node = data?.nodes?.[nodeId];
+ if (!node) return nodeId.slice(0, 8);
+ return node.friendly_name || node.system_info?.model_id || nodeId.slice(0, 8);
+ }
+
+ function getBytes(value: unknown): number {
+ if (typeof value === 'number') return value;
+ if (value && typeof value === 'object') {
+ const v = value as Record<string, unknown>;
+ if (typeof v.in_bytes === 'number') return v.in_bytes;
+ if (typeof v.inBytes === 'number') return v.inBytes;
+ }
+ return 0;
+ }
+
+ function formatBytes(bytes: number): string {
+ if (!bytes || bytes <= 0) return '0B';
+ const units = ['B', 'KB', 'MB', 'GB', 'TB'];
+ const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
+ const val = bytes / Math.pow(1024, i);
+ return `${val.toFixed(val >= 10 ? 0 : 1)}${units[i]}`;
+ }
+
+ function formatEta(ms: number): string {
+ if (!ms || ms <= 0) return '--';
+ const totalSeconds = Math.round(ms / 1000);
+ const s = totalSeconds % 60;
+ const m = Math.floor(totalSeconds / 60) % 60;
+ const h = Math.floor(totalSeconds / 3600);
+ if (h > 0) return `${h}h ${m}m`;
+ if (m > 0) return `${m}m ${s}s`;
+ return `${s}s`;
+ }
+
+ function formatSpeed(bytesPerSecond: number): string {
+ if (!bytesPerSecond || bytesPerSecond <= 0) return '--';
+ const units = ['B/s', 'KB/s', 'MB/s', 'GB/s'];
+ const i = Math.min(Math.floor(Math.log(bytesPerSecond) / Math.log(1024)), units.length - 1);
+ const val = bytesPerSecond / Math.pow(1024, i);
+ return `${val.toFixed(val >= 10 ? 0 : 1)}${units[i]}`;
+ }
+
+ function clampPercent(value: number | undefined): number {
+ if (!Number.isFinite(value)) return 0;
+ return Math.min(100, Math.max(0, value as number));
+ }
+
+ function extractModelIdFromDownload(downloadPayload: Record<string, unknown>): string | null {
+ const shardMetadata = downloadPayload.shard_metadata ?? downloadPayload.shardMetadata;
+ if (!shardMetadata || typeof shardMetadata !== 'object') return null;
+
+ const shardObj = shardMetadata as Record<string, unknown>;
+ const shardKeys = Object.keys(shardObj);
+ if (shardKeys.length !== 1) return null;
+
+ const shardData = shardObj[shardKeys[0]] as Record<string, unknown>;
+ if (!shardData) return null;
+
+ const modelMeta = shardData.model_meta ?? shardData.modelMeta;
+ if (!modelMeta || typeof modelMeta !== 'object') return null;
+
+ const meta = modelMeta as Record<string, unknown>;
+ return (meta.model_id as string) ?? (meta.modelId as string) ?? null;
+ }
+
+ function parseDownloadProgress(payload: Record<string, unknown>): DownloadProgress | null {
+ const progress = payload.download_progress ?? payload.downloadProgress;
+ if (!progress || typeof progress !== 'object') return null;
+
+ const prog = progress as Record<string, unknown>;
+ const totalBytes = getBytes(prog.total_bytes ?? prog.totalBytes);
+ const downloadedBytes = getBytes(prog.downloaded_bytes ?? prog.downloadedBytes);
+ const speed = (prog.speed as number) ?? 0;
+ const completedFiles = (prog.completed_files as number) ?? (prog.completedFiles as number) ?? 0;
+ const totalFiles = (prog.total_files as number) ?? (prog.totalFiles as number) ?? 0;
+ const etaMs = (prog.eta_ms as number) ?? (prog.etaMs as number) ?? 0;
+
+ const files: DownloadProgress['files'] = [];
+ const filesObj = (prog.files ?? {}) as Record<string, unknown>;
+ for (const [fileName, fileData] of Object.entries(filesObj)) {
+ if (!fileData || typeof fileData !== 'object') continue;
+ const fd = fileData as Record<string, unknown>;
+ const fTotal = getBytes(fd.total_bytes ?? fd.totalBytes);
+ const fDownloaded = getBytes(fd.downloaded_bytes ?? fd.downloadedBytes);
+ files.push({
+ name: fileName,
+ totalBytes: fTotal,
+ downloadedBytes: fDownloaded,
+ speed: (fd.speed as number) ?? 0,
+ etaMs: (fd.eta_ms as number) ?? (fd.etaMs as number) ?? 0,
+ percentage: fTotal > 0 ? (fDownloaded / fTotal) * 100 : 0
+ });
+ }
+
+ return {
+ totalBytes,
+ downloadedBytes,
+ speed,
+ etaMs: etaMs || (speed > 0 ? ((totalBytes - downloadedBytes) / speed) * 1000 : 0),
+ percentage: totalBytes > 0 ? (downloadedBytes / totalBytes) * 100 : 0,
+ completedFiles,
+ totalFiles,
+ files
+ };
+ }
+
+ function getBarGradient(percentage: number): string {
+ if (percentage >= 100) return 'from-green-500 to-green-400';
+ if (percentage <= 0) return 'from-red-500 to-red-400';
+ return 'from-exo-yellow to-exo-yellow/70';
+ }
+
+ let downloadOverview = $state<NodeEntry[]>([]);
+
+ $effect(() => {
+ try {
+ if (!downloadsData || Object.keys(downloadsData).length === 0) {
+ downloadOverview = [];
+ return;
+ }
+
+ const entries = Object.entries(downloadsData);
+ const built: NodeEntry[] = [];
+
+ for (const [nodeId, nodeDownloads] of entries) {
+ const modelMap = new Map<string, ModelEntry>();
+ const nodeEntries = Array.isArray(nodeDownloads)
+ ? nodeDownloads
+ : nodeDownloads && typeof nodeDownloads === 'object'
+ ? Object.values(nodeDownloads as Record<string, unknown>)
+ : [];
+
+ for (const downloadWrapped of nodeEntries) {
+ if (!downloadWrapped || typeof downloadWrapped !== 'object') continue;
+
+ const keys = Object.keys(downloadWrapped as Record<string, unknown>);
+ if (keys.length !== 1) continue;
+
+ const downloadKind = keys[0];
+ const downloadPayload = (downloadWrapped as Record<string, unknown>)[downloadKind] as Record<string, unknown>;
+ if (!downloadPayload) continue;
+
+ const modelId = extractModelIdFromDownload(downloadPayload) ?? 'unknown-model';
+ const prettyName = (() => {
+ const shardMetadata = downloadPayload.shard_metadata ?? downloadPayload.shardMetadata;
+ if (!shardMetadata || typeof shardMetadata !== 'object') return null;
+ const shardObj = shardMetadata as Record<string, unknown>;
+ const shardKeys = Object.keys(shardObj);
+ if (shardKeys.length !== 1) return null;
+ const shardData = shardObj[shardKeys[0]] as Record<string, unknown>;
+ const modelMeta = shardData?.model_meta ?? shardData?.modelMeta;
+ if (!modelMeta || typeof modelMeta !== 'object') return null;
+ const meta = modelMeta as Record<string, unknown>;
+ return (meta.prettyName as string) ?? null;
+ })();
+
+ const rawProgress = (downloadPayload as Record<string, unknown>).download_progress
+ ?? (downloadPayload as Record<string, unknown>).downloadProgress
+ ?? {};
+ const totalBytes = getBytes((rawProgress as Record<string, unknown>).total_bytes ?? (rawProgress as Record<string, unknown>).totalBytes);
+ const downloadedBytes = getBytes((rawProgress as Record<string, unknown>).downloaded_bytes ?? (rawProgress as Record<string, unknown>).downloadedBytes);
+ const speed = (rawProgress as Record<string, unknown>).speed as number ?? 0;
+ const etaMs = (rawProgress as Record<string, unknown>).eta_ms as number ?? (rawProgress as Record<string, unknown>).etaMs as number ?? 0;
+ const percentage = totalBytes > 0 ? (downloadedBytes / totalBytes) * 100 : 0;
+
+ const files: FileProgress[] = [];
+ const filesObj = (rawProgress as Record<string, unknown>).files as Record<string, unknown> | undefined;
+ if (filesObj && typeof filesObj === 'object') {
+ for (const [fileName, fileData] of Object.entries(filesObj)) {
+ if (!fileData || typeof fileData !== 'object') continue;
+ const fd = fileData as Record<string, unknown>;
+ const fTotal = getBytes(fd.total_bytes ?? fd.totalBytes);
+ const fDownloaded = getBytes(fd.downloaded_bytes ?? fd.downloadedBytes);
+ files.push({
+ name: fileName,
+ totalBytes: fTotal,
+ downloadedBytes: fDownloaded,
+ speed: (fd.speed as number) ?? 0,
+ etaMs: (fd.eta_ms as number) ?? (fd.etaMs as number) ?? 0,
+ percentage: clampPercent(fTotal > 0 ? (fDownloaded / fTotal) * 100 : 0)
+ });
+ }
+ }
+
+ const entry: ModelEntry = {
+ modelId,
+ prettyName,
+ percentage: downloadKind === 'DownloadCompleted' ? 100 : clampPercent(percentage),
+ downloadedBytes,
+ totalBytes,
+ speed,
+ etaMs,
+ status: downloadKind === 'DownloadCompleted' ? 'completed' : 'downloading',
+ files
+ };
+
+ const existing = modelMap.get(modelId);
+ if (!existing) {
+ modelMap.set(modelId, entry);
+ } else if (
+ (entry.status === 'completed' && existing.status !== 'completed') ||
+ (entry.status === existing.status && entry.downloadedBytes > existing.downloadedBytes)
+ ) {
+ modelMap.set(modelId, entry);
+ }
+ }
+
+ let models = Array.from(modelMap.values()).sort((a, b) => b.percentage - a.percentage);
+ if (models.length === 0 && nodeEntries.length > 0) {
+ models = [{
+ modelId: 'Unknown download',
+ percentage: 0,
+ downloadedBytes: 0,
+ totalBytes: 0,
+ speed: 0,
+ etaMs: 0,
+ status: 'downloading',
+ files: []
+ }];
+ }
+
+ built.push({
+ nodeId,
+ nodeName: getNodeLabel(nodeId),
+ models
+ });
+ }
+
+ downloadOverview = built;
+ } catch (err) {
+ console.error('Parse downloads error', err);
+ downloadOverview = [];
+ }
+ });
+
+ const hasDownloads = $derived(downloadOverview.length > 0);
+ const lastUpdateTs = $derived(lastUpdateStore());
+ const downloadKeys = $derived(Object.keys(downloadsData || {}));
+
+ let expanded = $state<Set<string>>(new Set());
+ function toggleExpand(key: string): void {
+ const next = new Set(expanded);
+ if (next.has(key)) next.delete(key);
+ else next.add(key);
+ expanded = next;
+ }
+
+ onMount(() => {
+ // Ensure we fetch at least once when visiting downloads directly
+ refreshState();
+ });
+</script>
+
+<div class="min-h-screen bg-exo-dark-gray text-white">
+ <HeaderNav showHome={true} />
+ <div class="max-w-7xl mx-auto px-4 lg:px-8 py-6 space-y-6">
+ <div class="flex items-center justify-between gap-4 flex-wrap">
+ <div>
+ <h1 class="text-2xl font-mono tracking-[0.2em] uppercase text-exo-yellow">Downloads</h1>
+ <p class="text-sm text-exo-light-gray">Overview of models on each node</p>
+ </div>
+ <div class="flex items-center gap-3">
+ <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"
+ onclick={() => refreshState()}
+ title="Force refresh from /state"
+ >
+ Refresh
+ </button>
+ <div class="text-[11px] font-mono text-exo-light-gray">
+ Last update: {lastUpdateTs ? new Date(lastUpdateTs).toLocaleTimeString() : 'n/a'}
+ </div>
+ </div>
+ </div>
+
+ {#if !hasDownloads}
+ <div class="rounded border border-exo-medium-gray/30 bg-exo-black/30 p-6 text-center text-exo-light-gray space-y-2">
+ <div class="text-sm">No downloads found. Start a model download to see progress here.</div>
+ <div class="text-[11px] text-exo-light-gray/70">
+ Download keys detected: {downloadKeys.length === 0 ? 'none' : downloadKeys.join(', ')}
+ </div>
+ </div>
+ {:else}
+ <div class="downloads-grid gap-4">
+ {#each downloadOverview as node}
+ <div class="rounded border border-exo-medium-gray/30 bg-exo-black/30 p-4 space-y-3 flex flex-col">
+ <div class="flex items-center justify-between gap-3">
+ <div class="min-w-0 flex-1">
+ <div class="text-lg font-mono text-white truncate">{node.nodeName}</div>
+ <div class="text-xs text-exo-light-gray font-mono truncate">{node.nodeId}</div>
+ </div>
+ <div class="text-xs font-mono uppercase tracking-wider whitespace-nowrap shrink-0">
+ <span class="text-green-400">{node.models.filter(m => m.status === 'completed').length}</span><span class="text-exo-yellow"> /{node.models.length} models</span>
+ </div>
+ </div>
+
+ {#each node.models as model}
+ {@const key = `${node.nodeId}|${model.modelId}`}
+ {@const pct = clampPercent(model.percentage)}
+ {@const gradient = getBarGradient(pct)}
+ {@const isExpanded = expanded.has(key)}
+ <div class="rounded border border-exo-medium-gray/30 bg-exo-dark-gray/60 p-3 space-y-2">
+ <div class="flex items-center justify-between gap-3">
+ <div class="min-w-0 space-y-0.5">
+ <div class="text-sm font-mono text-white truncate">{model.prettyName ?? model.modelId}</div>
+ <div class="text-[11px] text-exo-light-gray font-mono truncate">
+ {model.modelId}
+ </div>
+ <div class="text-[11px] text-exo-light-gray font-mono">
+ {formatBytes(model.downloadedBytes)} / {formatBytes(model.totalBytes)}
+ </div>
+ </div>
+ <div class="flex items-center gap-2">
+ <span class="text-xs font-mono {pct >= 100 ? 'text-green-400' : pct <= 0 ? 'text-red-400' : 'text-exo-yellow'}">
+ {pct.toFixed(1)}%
+ </span>
+ <button
+ type="button"
+ class="text-exo-light-gray hover:text-exo-yellow transition-colors"
+ onclick={() => toggleExpand(key)}
+ aria-expanded={isExpanded}
+ title="Toggle file details"
+ >
+ <svg class="w-4 h-4" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2">
+ <path d="M6 8l4 4 4-4" class={isExpanded ? 'transform rotate-180 origin-center transition-transform duration-150' : 'transition-transform duration-150'}></path>
+ </svg>
+ </button>
+ </div>
+ </div>
+
+ <div class="relative h-2 bg-exo-black/60 rounded-sm overflow-hidden">
+ <div
+ class={`absolute inset-y-0 left-0 bg-gradient-to-r ${gradient} transition-all duration-300`}
+ style={`width: ${pct.toFixed(1)}%`}
+ ></div>
+ </div>
+
+ <div class="flex items-center justify-between text-xs font-mono text-exo-light-gray">
+ <span>{model.status === 'completed' ? 'Completed' : `${formatSpeed(model.speed)} • ETA ${formatEta(model.etaMs)}`}</span>
+ {#if model.status !== 'completed'}
+ <span>{model.files.length} file{model.files.length === 1 ? '' : 's'}</span>
+ {/if}
+ </div>
+
+ {#if isExpanded}
+ <div class="mt-2 space-y-1.5">
+ {#if model.files.length === 0}
+ <div class="text-[11px] font-mono text-exo-light-gray/70">No file details reported.</div>
+ {:else}
+ {#each model.files as f}
+ {@const fpct = clampPercent(f.percentage)}
+ {@const fgradient = getBarGradient(fpct)}
+ <div class="rounded border border-exo-medium-gray/20 bg-exo-black/40 p-2 space-y-1">
+ <div class="flex items-center justify-between text-[11px] font-mono text-exo-light-gray/90">
+ <span class="truncate pr-2">{f.name}</span>
+ <span class="{fpct >= 100 ? 'text-green-400' : fpct <= 0 ? 'text-red-400' : 'text-exo-yellow'}">{fpct.toFixed(1)}%</span>
+ </div>
+ <div class="relative h-1.5 bg-exo-black/60 rounded-sm overflow-hidden">
+ <div
+ class={`absolute inset-y-0 left-0 bg-gradient-to-r ${fgradient} transition-all duration-300`}
+ style={`width: ${fpct.toFixed(1)}%`}
+ ></div>
+ </div>
+ <div class="flex items-center justify-between text-[10px] text-exo-light-gray/70">
+ <span>{formatBytes(f.downloadedBytes)} / {formatBytes(f.totalBytes)}</span>
+ <span>{formatSpeed(f.speed)} • ETA {formatEta(f.etaMs)}</span>
+ </div>
+ </div>
+ {/each}
+ {/if}
+ </div>
+ {/if}
+ </div>
+ {/each}
+ </div>
+ {/each}
+ </div>
+ {/if}
+
+ </div>
+</div>
+
+<style>
+ .downloads-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
+ }
+ @media (min-width: 1024px) {
+ .downloads-grid {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ }
+ }
+ @media (min-width: 1440px) {
+ .downloads-grid {
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ }
+ }
+</style>
diff --git a/dashboard/static/exo-logo.png b/dashboard/static/exo-logo.png
new file mode 100644
index 00000000..199bcfdd
Binary files /dev/null and b/dashboard/static/exo-logo.png differ
diff --git a/dashboard/static/favicon.ico b/dashboard/static/favicon.ico
new file mode 100644
index 00000000..c0ae2099
Binary files /dev/null and b/dashboard/static/favicon.ico differ
diff --git a/dashboard/svelte.config.js b/dashboard/svelte.config.js
new file mode 100644
index 00000000..991b07b0
--- /dev/null
+++ b/dashboard/svelte.config.js
@@ -0,0 +1,28 @@
+import adapter from '@sveltejs/adapter-static';
+import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
+
+/** @type {import('@sveltejs/kit').Config} */
+const config = {
+ preprocess: [vitePreprocess()],
+
+ kit: {
+ paths: {
+ relative: true
+ },
+ router: { type: 'hash' },
+ adapter: adapter({
+ pages: 'build',
+ assets: 'build',
+ fallback: 'index.html',
+ precompress: false,
+ strict: true
+ }),
+ alias: {
+ $lib: 'src/lib',
+ $components: 'src/lib/components'
+ }
+ }
+};
+
+export default config;
+
diff --git a/dashboard/tsconfig.json b/dashboard/tsconfig.json
new file mode 100644
index 00000000..51db996c
--- /dev/null
+++ b/dashboard/tsconfig.json
@@ -0,0 +1,15 @@
+{
+ "extends": "./.svelte-kit/tsconfig.json",
+ "compilerOptions": {
+ "allowJs": true,
+ "checkJs": true,
+ "esModuleInterop": true,
+ "forceConsistentCasingInFileNames": true,
+ "resolveJsonModule": true,
+ "skipLibCheck": true,
+ "sourceMap": true,
+ "strict": true,
+ "moduleResolution": "bundler"
+ }
+}
+
diff --git a/dashboard/vite.config.ts b/dashboard/vite.config.ts
new file mode 100644
index 00000000..4d22f688
--- /dev/null
+++ b/dashboard/vite.config.ts
@@ -0,0 +1,16 @@
+import tailwindcss from '@tailwindcss/vite';
+import { sveltekit } from '@sveltejs/kit/vite';
+import { defineConfig } from 'vite';
+
+export default defineConfig({
+ plugins: [tailwindcss(), sveltekit()],
+ server: {
+ proxy: {
+ '/v1': 'http://localhost:8000',
+ '/state': 'http://localhost:8000',
+ '/models': 'http://localhost:8000',
+ '/instance': 'http://localhost:8000'
+ }
+ }
+});
+
diff --git a/flake.nix b/flake.nix
index c6141754..9d3ade75 100644
--- a/flake.nix
+++ b/flake.nix
@@ -81,6 +81,9 @@
# NIX
nixpkgs-fmt
+ # SVELTE
+ nodejs
+
# MISC
just
jq
@@ -96,7 +99,6 @@
shellHook = ''
# PYTHON
- export DASHBOARD_DIR="$(git rev-parse --show-toplevel)/dashboard"
export LD_LIBRARY_PATH="$LD_LIBRARY_PATH:${pkgs.python313}/lib"
echo
echo "🍎🍎 Run 'just <recipe>' to get started"
diff --git a/justfile b/justfile
index 6f4e67e9..0a82d616 100644
--- a/justfile
+++ b/justfile
@@ -20,7 +20,19 @@ rust-rebuild:
cargo run --bin stub_gen
just sync-clean
+build-dashboard:
+ #!/usr/bin/env bash
+ cd dashboard
+ npm install
+ npm run build
+
+package:
+ uv run pyinstaller packaging/pyinstaller/exo.spec
+
clean:
rm -rf **/__pycache__
rm -rf target/
rm -rf .venv
+ rm -rf dashboard/node_modules
+ rm -rf dashboard/.svelte-kit
+ rm -rf dashboard/build
diff --git a/src/exo/master/api.py b/src/exo/master/api.py
index 172ae5c1..ffbf3fde 100644
--- a/src/exo/master/api.py
+++ b/src/exo/master/api.py
@@ -1,4 +1,3 @@
-import os
import time
from collections.abc import AsyncGenerator
from typing import cast
@@ -15,6 +14,7 @@ from hypercorn.config import Config
from hypercorn.typing import ASGIFramework
from loguru import logger
+from exo.master.placement import place_instance as get_instance_placements
from exo.shared.apply import apply
from exo.shared.election import ElectionMessage
from exo.shared.logging import InterceptLogger
@@ -23,11 +23,14 @@ from exo.shared.models.model_meta import get_model_meta
from exo.shared.types.api import (
ChatCompletionMessage,
ChatCompletionResponse,
+ CreateInstanceParams,
CreateInstanceResponse,
- CreateInstanceTaskParams,
DeleteInstanceResponse,
ModelList,
ModelListModel,
+ PlaceInstanceParams,
+ PlacementPreview,
+ PlacementPreviewResponse,
StreamingChoiceResponse,
)
from exo.shared.types.chunks import TokenChunk
@@ -37,17 +40,20 @@ from exo.shared.types.commands import (
CreateInstance,
DeleteInstance,
ForwarderCommand,
+ PlaceInstance,
TaskFinished,
)
from exo.shared.types.common import CommandId, NodeId, SessionId
from exo.shared.types.events import ChunkGenerated, Event, ForwarderEvent, IndexedEvent
from exo.shared.types.memory import Memory
-from exo.shared.types.models import ModelMetadata
+from exo.shared.types.models import ModelId, ModelMetadata
from exo.shared.types.state import State
from exo.shared.types.tasks import ChatCompletionTaskParams
-from exo.shared.types.worker.instances import Instance, InstanceId
+from exo.shared.types.worker.instances import Instance, InstanceId, InstanceMeta
+from exo.shared.types.worker.shards import Sharding
from exo.utils.banner import print_startup_banner
from exo.utils.channels import Receiver, Sender, channel
+from exo.utils.dashboard_path import find_dashboard
from exo.utils.event_buffer import OrderedBuffer
HIDE_THINKING = False
@@ -91,7 +97,8 @@ class API:
# This lets us pause the API if an election is running
election_receiver: Receiver[ElectionMessage],
) -> None:
- self._state = State()
+ self.state = State()
+ self._event_log: list[Event] = []
self.command_sender = command_sender
self.global_event_receiver = global_event_receiver
self.election_receiver = election_receiver
@@ -111,12 +118,7 @@ class API:
self.app.mount(
"/",
StaticFiles(
- directory=os.environ.get(
- "DASHBOARD_DIR",
- os.path.abspath(
- os.path.join(os.path.dirname(__file__), "../../../dashboard")
- ),
- ),
+ directory=find_dashboard(),
html=True,
),
name="dashboard",
@@ -127,7 +129,7 @@ class API:
def reset(self, new_session_id: SessionId, result_clock: int):
logger.info("Resetting API State")
- self._state = State()
+ self.state = State()
self.session_id = new_session_id
self.event_buffer = OrderedBuffer[Event]()
self._chat_completion_queues = {}
@@ -150,51 +152,194 @@ class API:
)
def _setup_routes(self) -> None:
+ self.app.get("/node_id")(lambda: self.node_id)
self.app.post("/instance")(self.create_instance)
+ self.app.post("/place_instance")(self.place_instance)
+ self.app.get("/instance/placement")(self.get_placement)
+ self.app.get("/instance/previews")(self.get_placement_previews)
self.app.get("/instance/{instance_id}")(self.get_instance)
self.app.delete("/instance/{instance_id}")(self.delete_instance)
self.app.get("/models")(self.get_models)
self.app.get("/v1/models")(self.get_models)
self.app.post("/v1/chat/completions")(self.chat_completions)
- self.app.get("/state")(self.state)
-
- async def state(self) -> State:
- return self._state
-
- async def create_instance(
- self, payload: CreateInstanceTaskParams
- ) -> CreateInstanceResponse:
- model_meta = await resolve_model_meta(payload.model_id)
- required_memory = model_meta.storage_size
- available_memory = self._calculate_total_available_memory()
+ self.app.get("/state")(lambda: self.state)
+ self.app.get("/events")(lambda: self._event_log)
- if required_memory > available_memory:
- raise HTTPException(
- status_code=400,
- detail=f"Insufficient memory to create instance. Required: {required_memory.in_gb:.1f}GB, Available: {available_memory.in_gb:.1f}GB",
- )
-
- command = CreateInstance(
- model_meta=model_meta,
+ async def place_instance(self, payload: PlaceInstanceParams):
+ command = PlaceInstance(
+ model_meta=await resolve_model_meta(payload.model_id),
+ sharding=payload.sharding,
instance_meta=payload.instance_meta,
min_nodes=payload.min_nodes,
- sharding=payload.sharding,
)
await self._send(command)
return CreateInstanceResponse(
message="Command received.",
command_id=command.command_id,
- model_meta=model_meta,
)
+ async def create_instance(
+ self, payload: CreateInstanceParams
+ ) -> CreateInstanceResponse:
+ command = CreateInstance(instance=payload.instance)
+ await self._send(command)
+
+ return CreateInstanceResponse(
+ message="Command received.",
+ command_id=command.command_id,
+ )
+
+ async def get_placement(
+ self,
+ model_id: str,
+ sharding: Sharding = Sharding.Pipeline,
+ instance_meta: InstanceMeta = InstanceMeta.MlxRing,
+ min_nodes: int = 1,
+ ) -> Instance:
+ model_meta = await resolve_model_meta(model_id)
+
+ try:
+ placements = get_instance_placements(
+ PlaceInstance(
+ model_meta=model_meta,
+ sharding=sharding,
+ instance_meta=instance_meta,
+ min_nodes=min_nodes,
+ ),
+ topology=self.state.topology,
+ current_instances=self.state.instances,
+ )
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+
+ current_ids = set(self.state.instances.keys())
+ new_ids = [
+ instance_id for instance_id in placements if instance_id not in current_ids
+ ]
+ if len(new_ids) != 1:
+ raise HTTPException(
+ status_code=500,
+ detail="Expected exactly one new instance from placement",
+ )
+
+ return placements[new_ids[0]]
+
+ async def get_placement_previews(
+ self, model_id: ModelId
+ ) -> PlacementPreviewResponse:
+ seen: set[tuple[ModelId, Sharding, InstanceMeta, int]] = set()
+ previews: list[PlacementPreview] = []
+ if len(list(self.state.topology.list_nodes())) == 0:
+ return PlacementPreviewResponse(previews=[])
+
+ cards = [card for card in MODEL_CARDS.values() if card.short_id == model_id]
+ if not cards:
+ raise HTTPException(status_code=404, detail=f"Model {model_id} not found")
+
+ instance_combinations: list[tuple[Sharding, InstanceMeta, int]] = []
+ for sharding in (Sharding.Pipeline, Sharding.Tensor):
+ for instance_meta in (InstanceMeta.MlxRing, InstanceMeta.MlxJaccl):
+ instance_combinations.extend(
+ [
+ (sharding, instance_meta, i)
+ for i in range(
+ 1, len(list(self.state.topology.list_nodes())) + 1
+ )
+ ]
+ )
+ # TODO: PDD
+ # instance_combinations.append((Sharding.PrefillDecodeDisaggregation, InstanceMeta.MlxRing, 1))
+
+ for card in cards:
+ model_meta = card.metadata
+ for sharding, instance_meta, min_nodes in instance_combinations:
+ try:
+ placements = get_instance_placements(
+ PlaceInstance(
+ model_meta=model_meta,
+ sharding=sharding,
+ instance_meta=instance_meta,
+ min_nodes=min_nodes,
+ ),
+ topology=self.state.topology,
+ current_instances=self.state.instances,
+ )
+ except ValueError as exc:
+ if (card.model_id, sharding, instance_meta, 0) not in seen:
+ previews.append(
+ PlacementPreview(
+ model_id=card.model_id,
+ sharding=sharding,
+ instance_meta=instance_meta,
+ instance=None,
+ error=str(exc),
+ )
+ )
+ seen.add((card.model_id, sharding, instance_meta, 0))
+ continue
+
+ current_ids = set(self.state.instances.keys())
+ new_instances = [
+ instance
+ for instance_id, instance in placements.items()
+ if instance_id not in current_ids
+ ]
+
+ if len(new_instances) != 1:
+ if (card.model_id, sharding, instance_meta, 0) not in seen:
+ previews.append(
+ PlacementPreview(
+ model_id=card.model_id,
+ sharding=sharding,
+ instance_meta=instance_meta,
+ instance=None,
+ error="Expected exactly one new instance from placement",
+ )
+ )
+ seen.add((card.model_id, sharding, instance_meta, 0))
+ continue
+
+ instance = new_instances[0]
+ shard_assignments = instance.shard_assignments
+ node_ids = list(shard_assignments.node_to_runner.keys())
+
+ memory_delta_by_node: dict[str, int] = {}
+ if node_ids:
+ total_bytes = model_meta.storage_size.in_bytes
+ per_node = total_bytes // len(node_ids)
+ remainder = total_bytes % len(node_ids)
+ for index, node_id in enumerate(sorted(node_ids, key=str)):
+ extra = 1 if index < remainder else 0
+ memory_delta_by_node[str(node_id)] = per_node + extra
+
+ if (
+ card.model_id,
+ sharding,
+ instance_meta,
+ len(node_ids),
+ ) not in seen:
+ previews.append(
+ PlacementPreview(
+ model_id=card.model_id,
+ sharding=sharding,
+ instance_meta=instance_meta,
+ instance=instance,
+ memory_delta_by_node=memory_delta_by_node or None,
+ error=None,
+ )
+ )
+ seen.add((card.model_id, sharding, instance_meta, len(node_ids)))
+
+ return PlacementPreviewResponse(previews=previews)
+
def get_instance(self, instance_id: InstanceId) -> Instance:
- if instance_id not in self._state.instances:
+ if instance_id not in self.state.instances:
raise HTTPException(status_code=404, detail="Instance not found")
- return self._state.instances[instance_id]
+ return self.state.instances[instance_id]
async def delete_instance(self, instance_id: InstanceId) -> DeleteInstanceResponse:
- if instance_id not in self._state.instances:
+ if instance_id not in self.state.instances:
raise HTTPException(status_code=404, detail="Instance not found")
command = DeleteInstance(
@@ -261,7 +406,7 @@ class API:
if not any(
instance.shard_assignments.model_id == payload.model
- for instance in self._state.instances.values()
+ for instance in self.state.instances.values()
):
await self._trigger_notify_user_to_download_model(payload.model)
raise HTTPException(
@@ -281,7 +426,7 @@ class API:
"""Calculate total available memory across all nodes in bytes."""
total_available = Memory()
- for node in self._state.topology.list_nodes():
+ for node in self.state.topology.list_nodes():
if node.node_profile is not None:
total_available += node.node_profile.memory.ram_available
@@ -313,7 +458,7 @@ class API:
async with create_task_group() as tg:
self._tg = tg
logger.info("Starting API")
- tg.start_soon(self._apply_state)
+ tg.start_soon(self._applystate)
tg.start_soon(self._pause_on_new_election)
print_startup_banner(self.port)
await serve(
@@ -325,14 +470,15 @@ class API:
self.command_sender.close()
self.global_event_receiver.close()
- async def _apply_state(self):
+ async def _applystate(self):
with self.global_event_receiver as events:
async for f_event in events:
if f_event.origin != self.session_id.master_node_id:
continue
self.event_buffer.ingest(f_event.origin_idx, f_event.event)
for idx, event in self.event_buffer.drain_indexed():
- self._state = apply(self._state, IndexedEvent(event=event, idx=idx))
+ self._event_log.append(event)
+ self.state = apply(self.state, IndexedEvent(event=event, idx=idx))
if (
isinstance(event, ChunkGenerated)
and event.command_id in self._chat_completion_queues
diff --git a/src/exo/master/main.py b/src/exo/master/main.py
index 149bfbd2..55b72d7d 100644
--- a/src/exo/master/main.py
+++ b/src/exo/master/main.py
@@ -5,9 +5,10 @@ from anyio.abc import TaskGroup
from loguru import logger
from exo.master.placement import (
- get_instance_placements_after_create,
- get_instance_placements_after_delete,
+ add_instance_to_placements,
+ delete_instance,
get_transition_events,
+ place_instance,
)
from exo.shared.apply import apply
from exo.shared.types.commands import (
@@ -15,6 +16,7 @@ from exo.shared.types.commands import (
CreateInstance,
DeleteInstance,
ForwarderCommand,
+ PlaceInstance,
RequestEventLog,
TaskFinished,
TestCommand,
@@ -148,19 +150,26 @@ class Master:
self.command_task_mapping[command.command_id] = task_id
case DeleteInstance():
- placement = get_instance_placements_after_delete(
- command, self.state.instances
+ placement = delete_instance(command, self.state.instances)
+ transition_events = get_transition_events(
+ self.state.instances, placement
+ )
+ generated_events.extend(transition_events)
+ case PlaceInstance():
+ placement = place_instance(
+ command,
+ self.state.topology,
+ self.state.instances,
)
transition_events = get_transition_events(
self.state.instances, placement
)
generated_events.extend(transition_events)
case CreateInstance():
- placement = get_instance_placements_after_create(
+ placement = add_instance_to_placements(
command,
self.state.topology,
self.state.instances,
- tb_only=self.tb_only,
)
transition_events = get_transition_events(
self.state.instances, placement
diff --git a/src/exo/master/placement.py b/src/exo/master/placement.py
index c0862c10..f3856f93 100644
--- a/src/exo/master/placement.py
+++ b/src/exo/master/placement.py
@@ -17,6 +17,7 @@ from exo.shared.topology import Topology
from exo.shared.types.commands import (
CreateInstance,
DeleteInstance,
+ PlaceInstance,
)
from exo.shared.types.common import Host
from exo.shared.types.events import Event, InstanceCreated, InstanceDeleted
@@ -35,12 +36,20 @@ def random_ephemeral_port() -> int:
return random.randint(49152, 65535)
-def get_instance_placements_after_create(
+def add_instance_to_placements(
command: CreateInstance,
topology: Topology,
current_instances: Mapping[InstanceId, Instance],
- *,
- tb_only: bool = False,
+) -> Mapping[InstanceId, Instance]:
+ # TODO: validate against topology
+
+ return {**current_instances, command.instance.instance_id: command.instance}
+
+
+def place_instance(
+ command: PlaceInstance,
+ topology: Topology,
+ current_instances: Mapping[InstanceId, Instance],
) -> dict[InstanceId, Instance]:
all_nodes = list(topology.list_nodes())
@@ -64,9 +73,7 @@ def get_instance_placements_after_create(
if topology.get_subgraph_from_nodes(cycle).is_thunderbolt_cycle(cycle)
]
- if tb_only and smallest_tb_cycles == []:
- raise ValueError("No TB cycles found with sufficient memory")
- elif smallest_tb_cycles != []:
+ if smallest_tb_cycles != []:
smallest_cycles = smallest_tb_cycles
cycles_with_leaf_nodes: list[list[NodeInfo]] = [
@@ -138,7 +145,7 @@ def get_instance_placements_after_create(
return target_instances
-def get_instance_placements_after_delete(
+def delete_instance(
command: DeleteInstance,
current_instances: Mapping[InstanceId, Instance],
) -> dict[InstanceId, Instance]:
diff --git a/src/exo/master/tests/test_master.py b/src/exo/master/tests/test_master.py
index 948bcb1f..c2111baf 100644
--- a/src/exo/master/tests/test_master.py
+++ b/src/exo/master/tests/test_master.py
@@ -11,8 +11,8 @@ from exo.shared.types.api import ChatCompletionMessage, ChatCompletionTaskParams
from exo.shared.types.commands import (
ChatCompletion,
CommandId,
- CreateInstance,
ForwarderCommand,
+ PlaceInstance,
)
from exo.shared.types.common import NodeId, SessionId
from exo.shared.types.events import (
@@ -117,7 +117,7 @@ async def test_master():
ForwarderCommand(
origin=node_id,
command=(
- CreateInstance(
+ PlaceInstance(
command_id=CommandId(),
model_meta=ModelMetadata(
model_id=ModelId("llama-3.2-1b"),
diff --git a/src/exo/master/tests/test_placement.py b/src/exo/master/tests/test_placement.py
index 1bfdf4e2..c688e8ff 100644
--- a/src/exo/master/tests/test_placement.py
+++ b/src/exo/master/tests/test_placement.py
@@ -4,11 +4,11 @@ import pytest
from loguru import logger
from exo.master.placement import (
- get_instance_placements_after_create,
get_transition_events,
+ place_instance,
)
from exo.shared.topology import Topology
-from exo.shared.types.commands import CreateInstance
+from exo.shared.types.commands import PlaceInstance
from exo.shared.types.common import CommandId, NodeId
from exo.shared.types.events import InstanceCreated, InstanceDeleted
from exo.shared.types.memory import Memory
@@ -52,8 +52,8 @@ def model_meta() -> ModelMetadata:
)
-def create_instance_command(model_meta: ModelMetadata) -> CreateInstance:
- return CreateInstance(
+def place_instance_command(model_meta: ModelMetadata) -> PlaceInstance:
+ return PlaceInstance(
command_id=CommandId(),
model_meta=model_meta,
sharding=Sharding.Pipeline,
@@ -85,7 +85,7 @@ def test_get_instance_placements_create_instance(
available_memory
) # make it exactly fit across all nodes
- cic = create_instance_command(model_meta)
+ cic = place_instance_command(model_meta)
node_id_a = NodeId()
node_id_b = NodeId()
node_id_c = NodeId()
@@ -97,7 +97,7 @@ def test_get_instance_placements_create_instance(
topology.add_connection(create_connection(node_id_c, node_id_a))
# act
- placements = get_instance_placements_after_create(cic, topology, {})
+ placements = place_instance(cic, topology, {})
# assert
assert len(placements) == 1
@@ -129,7 +129,7 @@ def test_get_instance_placements_one_node_exact_fit(
topology = Topology()
node_id = NodeId()
topology.add_node(create_node(1000 * 1024, node_id))
- cic = create_instance_command(
+ cic = place_instance_command(
ModelMetadata(
model_id=ModelId("test-model"),
storage_size=Memory.from_kb(1000),
@@ -137,7 +137,7 @@ def test_get_instance_placements_one_node_exact_fit(
n_layers=10,
),
)
- placements = get_instance_placements_after_create(cic, topology, {})
+ placements = place_instance(cic, topology, {})
assert len(placements) == 1
instance_id = list(placements.keys())[0]
@@ -154,7 +154,7 @@ def test_get_instance_placements_one_node_fits_with_extra_memory(
topology = Topology()
node_id = NodeId()
topology.add_node(create_node(1001 * 1024, node_id))
- cic = create_instance_command(
+ cic = place_instance_command(
ModelMetadata(
model_id=ModelId("test-model"),
storage_size=Memory.from_kb(1000),
@@ -162,7 +162,7 @@ def test_get_instance_placements_one_node_fits_with_extra_memory(
n_layers=10,
),
)
- placements = get_instance_placements_after_create(cic, topology, {})
+ placements = place_instance(cic, topology, {})
assert len(placements) == 1
instance_id = list(placements.keys())[0]
@@ -179,7 +179,7 @@ def test_get_instance_placements_one_node_not_fit(
topology = Topology()
node_id = NodeId()
topology.add_node(create_node(1000 * 1024, node_id))
- cic = create_instance_command(
+ cic = place_instance_command(
model_meta=ModelMetadata(
model_id=ModelId("test-model"),
storage_size=Memory.from_kb(1001),
@@ -189,7 +189,7 @@ def test_get_instance_placements_one_node_not_fit(
)
with pytest.raises(ValueError, match="No cycles found with sufficient memory"):
- get_instance_placements_after_create(cic, topology, {})
+ place_instance(cic, topology, {})
def test_get_transition_events_no_change(instance: Instance):
@@ -292,12 +292,12 @@ def test_placement_prioritizes_leaf_cycle_with_less_memory(
topology.add_connection(create_connection(node_id_e, node_id_y))
topology.add_connection(create_connection(node_id_f, node_id_z))
- cic = create_instance_command(
+ cic = place_instance_command(
model_meta=model_meta,
)
# Act
- placements = get_instance_placements_after_create(cic, topology, {})
+ placements = place_instance(cic, topology, {})
# Assert the chosen cycle is A-B-C (contains at least one leaf node), even though
# D-E-F has more total memory.
@@ -420,7 +420,7 @@ def test_tensor_rdma_backend_connectivity_matrix(
topology.add_connection(conn_c_b)
topology.add_connection(conn_a_c)
- cic = CreateInstance(
+ cic = PlaceInstance(
sharding=Sharding.Tensor,
instance_meta=InstanceMeta.MlxJaccl,
command_id=CommandId(),
@@ -428,7 +428,7 @@ def test_tensor_rdma_backend_connectivity_matrix(
min_nodes=1,
)
- placements = get_instance_placements_after_create(cic, topology, {})
+ placements = place_instance(cic, topology, {})
assert len(placements) == 1
instance_id = list(placements.keys())[0]
diff --git a/src/exo/shared/models/model_cards.py b/src/exo/shared/models/model_cards.py
index 6368a72d..17f00e4c 100644
--- a/src/exo/shared/models/model_cards.py
+++ b/src/exo/shared/models/model_cards.py
@@ -5,7 +5,7 @@ from exo.utils.pydantic_ext import CamelCaseModel
class ModelCard(CamelCaseModel):
short_id: str
- model_id: str
+ model_id: ModelId
name: str
description: str
tags: list[str]
@@ -40,35 +40,63 @@ MODEL_CARDS: dict[str, ModelCard] = {
# n_layers=61,
# ),
# ),
- "deepseek-v3.1": ModelCard(
- short_id="deepseek-v3.1",
- model_id="mlx-community/DeepSeek-V3.1-8bit",
- name="DeepSeek V3.1 (8-bit)",
+ "deepseek-v3.1-4bit": ModelCard(
+ short_id="deepseek-v3.1-4bit",
+ model_id=ModelId("mlx-community/DeepSeek-V3.1-4bit"),
+ name="DeepSeek V3.1 (4-bit)",
description="""DeepSeek V3.1 is a large language model trained on the DeepSeek V3.1 dataset.""",
tags=[],
metadata=ModelMetadata(
- model_id=ModelId("mlx-community/DeepSeek-V3.1-8bit"),
- pretty_name="DeepSeek V3.1 (8-bit)",
- storage_size=Memory.from_kb(754706307),
+ model_id=ModelId("mlx-community/DeepSeek-V3.1-4bit"),
+ pretty_name="DeepSeek V3.1 (4-bit)",
+ storage_size=Memory.from_gb(378),
n_layers=61,
),
),
- "deepseek-v3.1:4bit": ModelCard(
- short_id="deepseek-v3.1:4bit",
- model_id="mlx-community/DeepSeek-V3.1-4bit",
- name="DeepSeek V3.1 (4-bit)",
+ "deepseek-v3.1-8bit": ModelCard(
+ short_id="deepseek-v3.1-8bit",
+ model_id=ModelId("mlx-community/DeepSeek-V3.1-8bit"),
+ name="DeepSeek V3.1 (8-bit)",
description="""DeepSeek V3.1 is a large language model trained on the DeepSeek V3.1 dataset.""",
tags=[],
metadata=ModelMetadata(
- model_id=ModelId("mlx-community/DeepSeek-V3.1-4bit"),
- pretty_name="DeepSeek V3.1 (4-bit)",
- storage_size=Memory.from_kb(754706307 // 2), # TODO !!!!!
+ model_id=ModelId("mlx-community/DeepSeek-V3.1-8bit"),
+ pretty_name="DeepSeek V3.1 (8-bit)",
+ storage_size=Memory.from_gb(713),
n_layers=61,
),
),
+ # "deepseek-v3.2": ModelCard(
+ # short_id="deepseek-v3.2",
+ # model_id=ModelId("mlx-community/DeepSeek-V3.2-8bit"),
+ # name="DeepSeek V3.2 (8-bit)",
+ # description="""DeepSeek V3.2 is a large language model trained on the DeepSeek V3.2 dataset.""",
+ # tags=[],
+ # metadata=ModelMetadata(
+ # model_id=ModelId("mlx-community/DeepSeek-V3.2-8bit"),
+ # pretty_name="DeepSeek V3.2 (8-bit)",
+ # storage_size=Memory.from_kb(754706307),
+ # n_layers=61,
+ # hidden_size=7168,
+ # ),
+ # ),
+ # "deepseek-v3.2-4bit": ModelCard(
+ # short_id="deepseek-v3.2-4bit",
+ # model_id=ModelId("mlx-community/DeepSeek-V3.2-4bit"),
+ # name="DeepSeek V3.2 (4-bit)",
+ # description="""DeepSeek V3.2 is a large language model trained on the DeepSeek V3.2 dataset.""",
+ # tags=[],
+ # metadata=ModelMetadata(
+ # model_id=ModelId("mlx-community/DeepSeek-V3.2-4bit"),
+ # pretty_name="DeepSeek V3.2 (4-bit)",
+ # storage_size=Memory.from_kb(754706307 // 2), # TODO !!!!!
+ # n_layers=61,
+ # hidden_size=7168,
+ # ),
+ # ),
# deepseek r1
- # "deepseek-r1-0528:4bit": ModelCard(
- # short_id="deepseek-r1-0528:4bit",
+ # "deepseek-r1-0528-4bit": ModelCard(
+ # short_id="deepseek-r1-0528-4bit",
# model_id="mlx-community/DeepSeek-R1-0528-4bit",
# name="DeepSeek-R1-0528 (4-bit)",
# description="""DeepSeek R1 is a large language model trained on the DeepSeek R1 dataset.""",
@@ -78,6 +106,7 @@ MODEL_CARDS: dict[str, ModelCard] = {
# pretty_name="DeepSeek R1 671B (4-bit)",
# storage_size=Memory.from_kb(409706307),
# n_layers=61,
+ # hidden_size=7168,
# ),
# ),
# "deepseek-r1-0528": ModelCard(
@@ -91,226 +120,279 @@ MODEL_CARDS: dict[str, ModelCard] = {
# pretty_name="DeepSeek R1 671B (8-bit)",
# storage_size=Memory.from_bytes(754998771712),
# n_layers=61,
+ # . hidden_size=7168,
# ),
# ),
# kimi k2
"kimi-k2-instruct-4bit": ModelCard(
short_id="kimi-k2-instruct-4bit",
- model_id="mlx-community/Kimi-K2-Instruct-4bit",
+ model_id=ModelId("mlx-community/Kimi-K2-Instruct-4bit"),
name="Kimi K2 Instruct (4-bit)",
description="""Kimi K2 is a large language model trained on the Kimi K2 dataset.""",
tags=[],
metadata=ModelMetadata(
model_id=ModelId("mlx-community/Kimi-K2-Instruct-4bit"),
pretty_name="Kimi K2 Instruct (4-bit)",
- storage_size=Memory.from_bytes(577597603840),
+ storage_size=Memory.from_gb(578),
n_layers=61,
),
),
"kimi-k2-thinking": ModelCard(
short_id="kimi-k2-thinking",
- model_id="mlx-community/Kimi-K2-Thinking",
- name="Kimi K2 Thinking",
+ model_id=ModelId("mlx-community/Kimi-K2-Thinking"),
+ name="Kimi K2 Thinking (4-bit)",
description="""Kimi K2 Thinking is the latest, most capable version of open-source thinking model.""",
tags=[],
metadata=ModelMetadata(
model_id=ModelId("mlx-community/Kimi-K2-Thinking"),
- pretty_name="Kimi K2 Thinking",
- storage_size=Memory.from_bytes(577597603840),
+ pretty_name="Kimi K2 Thinking (4-bit)",
+ storage_size=Memory.from_gb(658),
n_layers=61,
),
),
# llama-3.1
"llama-3.1-8b": ModelCard(
short_id="llama-3.1-8b",
- model_id="mlx-community/Meta-Llama-3.1-8B-Instruct-4bit",
- name="Llama 3.1 8B",
+ model_id=ModelId("mlx-community/Meta-Llama-3.1-8B-Instruct-4bit"),
+ name="Llama 3.1 8B (4-bit)",
description="""Llama 3.1 is a large language model trained on the Llama 3.1 dataset.""",
tags=[],
metadata=ModelMetadata(
model_id=ModelId("mlx-community/Meta-Llama-3.1-8B-Instruct-4bit"),
- pretty_name="Llama 3.1 8B",
- storage_size=Memory.from_kb(4411528),
+ pretty_name="Llama 3.1 8B (4-bit)",
+ storage_size=Memory.from_mb(4423),
n_layers=32,
),
),
"llama-3.1-70b": ModelCard(
short_id="llama-3.1-70b",
- model_id="mlx-community/Meta-Llama-3.1-70B-Instruct-4bit",
- name="Llama 3.1 70B",
+ model_id=ModelId("mlx-community/Meta-Llama-3.1-70B-Instruct-4bit"),
+ name="Llama 3.1 70B (4-bit)",
description="""Llama 3.1 is a large language model trained on the Llama 3.1 dataset.""",
tags=[],
metadata=ModelMetadata(
model_id=ModelId("mlx-community/Meta-Llama-3.1-70B-Instruct-4bit"),
- pretty_name="Llama 3.1 70B",
- storage_size=Memory.from_kb(38758160),
+ pretty_name="Llama 3.1 70B (4-bit)",
+ storage_size=Memory.from_mb(38769),
n_layers=80,
),
),
# llama-3.2
"llama-3.2-1b": ModelCard(
short_id="llama-3.2-1b",
- model_id="mlx-community/Llama-3.2-1B-Instruct-4bit",
- name="Llama 3.2 1B",
+ model_id=ModelId("mlx-community/Llama-3.2-1B-Instruct-4bit"),
+ name="Llama 3.2 1B (4-bit)",
description="""Llama 3.2 is a large language model trained on the Llama 3.2 dataset.""",
tags=[],
metadata=ModelMetadata(
model_id=ModelId("mlx-community/Llama-3.2-1B-Instruct-4bit"),
- pretty_name="Llama 3.2 1B",
- storage_size=Memory.from_kb(678948),
+ pretty_name="Llama 3.2 1B (4-bit)",
+ storage_size=Memory.from_mb(696),
n_layers=16,
),
),
"llama-3.2-3b": ModelCard(
short_id="llama-3.2-3b",
- model_id="mlx-community/Llama-3.2-3B-Instruct-4bit",
- name="Llama 3.2 3B",
+ model_id=ModelId("mlx-community/Llama-3.2-3B-Instruct-4bit"),
+ name="Llama 3.2 3B (4-bit)",
description="""Llama 3.2 is a large language model trained on the Llama 3.2 dataset.""",
tags=[],
metadata=ModelMetadata(
model_id=ModelId("mlx-community/Llama-3.2-3B-Instruct-4bit"),
- pretty_name="Llama 3.2 3B",
- storage_size=Memory.from_kb(1765062),
+ pretty_name="Llama 3.2 3B (4-bit)",
+ storage_size=Memory.from_mb(1777),
+ n_layers=28,
+ ),
+ ),
+ "llama-3.2-3b-8bit": ModelCard(
+ short_id="llama-3.2-3b-8bit",
+ model_id=ModelId("mlx-community/Llama-3.2-3B-Instruct-8bit"),
+ name="Llama 3.2 3B (8-bit)",
+ description="""Llama 3.2 is a large language model trained on the Llama 3.2 dataset.""",
+ tags=[],
+ metadata=ModelMetadata(
+ model_id=ModelId("mlx-community/Llama-3.2-3B-Instruct-8bit"),
+ pretty_name="Llama 3.2 3B (8-bit)",
+ storage_size=Memory.from_mb(3339),
n_layers=28,
),
),
# llama-3.3
"llama-3.3-70b": ModelCard(
short_id="llama-3.3-70b",
- model_id="mlx-community/Llama-3.3-70B-Instruct-4bit",
+ model_id=ModelId("mlx-community/Llama-3.3-70B-Instruct-4bit"),
name="Llama 3.3 70B (4-bit)",
description="""The Meta Llama 3.3 multilingual large language model (LLM) is an instruction tuned generative model in 70B (text in/text out)""",
tags=[],
metadata=ModelMetadata(
model_id=ModelId("mlx-community/Llama-3.3-70B-Instruct-4bit"),
pretty_name="Llama 3.3 70B",
- storage_size=Memory.from_kb(38758160),
+ storage_size=Memory.from_mb(38769),
n_layers=80,
),
),
"llama-3.3-70b-8bit": ModelCard(
short_id="llama-3.3-70b-8bit",
- model_id="mlx-community/Llama-3.3-70B-Instruct-8bit",
+ model_id=ModelId("mlx-community/Llama-3.3-70B-Instruct-8bit"),
name="Llama 3.3 70B (8-bit)",
description="""The Meta Llama 3.3 multilingual large language model (LLM) is an instruction tuned generative model in 70B (text in/text out)""",
tags=[],
metadata=ModelMetadata(
model_id=ModelId("mlx-community/Llama-3.3-70B-Instruct-8bit"),
pretty_name="Llama 3.3 70B (8-bit)",
- storage_size=Memory.from_kb(77516320),
+ storage_size=Memory.from_mb(73242),
n_layers=80,
),
),
"llama-3.3-70b-fp16": ModelCard(
short_id="llama-3.3-70b-fp16",
- model_id="mlx-community/llama-3.3-70b-instruct-fp16",
+ model_id=ModelId("mlx-community/llama-3.3-70b-instruct-fp16"),
name="Llama 3.3 70B (FP16)",
description="""The Meta Llama 3.3 multilingual large language model (LLM) is an instruction tuned generative model in 70B (text in/text out)""",
tags=[],
metadata=ModelMetadata(
model_id=ModelId("mlx-community/llama-3.3-70b-instruct-fp16"),
pretty_name="Llama 3.3 70B (FP16)",
- storage_size=Memory.from_kb(155032640),
+ storage_size=Memory.from_mb(137695),
n_layers=80,
),
),
# phi-3
"phi-3-mini": ModelCard(
short_id="phi-3-mini",
- model_id="mlx-community/Phi-3-mini-128k-instruct-4bit",
- name="Phi 3 Mini 128k",
+ model_id=ModelId("mlx-community/Phi-3-mini-128k-instruct-4bit"),
+ name="Phi 3 Mini 128k (4-bit)",
description="""Phi 3 Mini is a large language model trained on the Phi 3 Mini dataset.""",
tags=[],
metadata=ModelMetadata(
model_id=ModelId("mlx-community/Phi-3-mini-128k-instruct-4bit"),
- pretty_name="Phi 3 Mini 128k",
- storage_size=Memory.from_kb(2099262),
+ pretty_name="Phi 3 Mini 128k (4-bit)",
+ storage_size=Memory.from_mb(2099),
n_layers=32,
),
),
- # "phi-3-mini:128k": ModelCard(
- # short_id="phi-3-mini:128k",
- # model_id="mlx-community/Phi-3-mini-128k-instruct-4bit",
- # name="Phi 3 Mini 128k",
- # description="""Phi 3 Mini is a large language model trained on the Phi 3 Mini dataset.""",
- # tags=[],
- # metadata=ModelMetadata(
- # model_id=ModelId("mlx-community/Phi-3-mini-128k-instruct-4bit"),
- # pretty_name="Phi 3 Mini 128k",
- # storage_size=Memory.from_kb(2099262),
- # n_layers=32,
- # ),
- # ),
# qwen3
"qwen3-0.6b": ModelCard(
short_id="qwen3-0.6b",
- model_id="mlx-community/Qwen3-0.6B-4bit",
- name="Qwen3 0.6B",
+ model_id=ModelId("mlx-community/Qwen3-0.6B-4bit"),
+ name="Qwen3 0.6B (4-bit)",
description="""Qwen3 0.6B is a large language model trained on the Qwen3 0.6B dataset.""",
tags=[],
metadata=ModelMetadata(
model_id=ModelId("mlx-community/Qwen3-0.6B-4bit"),
- pretty_name="Qwen3 0.6B",
- storage_size=Memory.from_kb(327512),
+ pretty_name="Qwen3 0.6B (4-bit)",
+ storage_size=Memory.from_mb(327),
+ n_layers=28,
+ ),
+ ),
+ "qwen3-0.6b-8bit": ModelCard(
+ short_id="qwen3-0.6b-8bit",
+ model_id=ModelId("mlx-community/Qwen3-0.6B-8bit"),
+ name="Qwen3 0.6B (8-bit)",
+ description="""Qwen3 0.6B is a large language model trained on the Qwen3 0.6B dataset.""",
+ tags=[],
+ metadata=ModelMetadata(
+ model_id=ModelId("mlx-community/Qwen3-0.6B-8bit"),
+ pretty_name="Qwen3 0.6B (8-bit)",
+ storage_size=Memory.from_mb(666),
n_layers=28,
),
),
"qwen3-30b": ModelCard(
short_id="qwen3-30b",
- model_id="mlx-community/Qwen3-30B-A3B-4bit",
- name="Qwen3 30B (Active 3B)",
+ model_id=ModelId("mlx-community/Qwen3-30B-A3B-4bit"),
+ name="Qwen3 30B A3B (4-bit)",
description="""Qwen3 30B is a large language model trained on the Qwen3 30B dataset.""",
tags=[],
metadata=ModelMetadata(
model_id=ModelId("mlx-community/Qwen3-30B-A3B-4bit"),
- pretty_name="Qwen3 30B (Active 3B)",
- storage_size=Memory.from_kb(16772092),
+ pretty_name="Qwen3 30B A3B (4-bit)",
+ storage_size=Memory.from_mb(16797),
n_layers=48,
),
),
- # "qwen3-235b-a22b": ModelCard(
- # short_id="qwen3-235b-a22b",
- # model_id="mlx-community/Qwen3-235B-A22B-4bit",
- # name="Qwen3 235B, Active 22B (4-bit)",
- # description="""Qwen3 235B (Active 22B) is a large language model trained on the Qwen3 235B dataset.""",
- # tags=[],
- # metadata=ModelMetadata(
- # model_id=ModelId("mlx-community/Qwen3-235B-A22B-4bit"),
- # pretty_name="Qwen3 235B, Active 22B (4-bit)",
- # storage_size=Memory.from_kb(123207680),
- # n_layers=94,
- # ),
- # ),
+ "qwen3-30b-8bit": ModelCard(
+ short_id="qwen3-30b-8bit",
+ model_id=ModelId("mlx-community/Qwen3-30B-A3B-8bit"),
+ name="Qwen3 30B A3B (8-bit)",
+ description="""Qwen3 30B is a large language model trained on the Qwen3 30B dataset.""",
+ tags=[],
+ metadata=ModelMetadata(
+ model_id=ModelId("mlx-community/Qwen3-30B-A3B-8bit"),
+ pretty_name="Qwen3 30B A3B (8-bit)",
+ storage_size=Memory.from_mb(31738),
+ n_layers=48,
+ ),
+ ),
+ "qwen3-235b-a22b-4bit": ModelCard(
+ short_id="qwen3-235b-a22b-4bit",
+ model_id=ModelId("mlx-community/Qwen3-235B-A22B-Instruct-2507-4bit"),
+ name="Qwen3 235B A22B (4-bit)",
+ description="""Qwen3 235B (Active 22B) is a large language model trained on the Qwen3 235B dataset.""",
+ tags=[],
+ metadata=ModelMetadata(
+ model_id=ModelId("mlx-community/Qwen3-235B-A22B-Instruct-2507-4bit"),
+ pretty_name="Qwen3 235B A22B (4-bit)",
+ storage_size=Memory.from_gb(132),
+ n_layers=94,
+ ),
+ ),
"qwen3-235b-a22b-8bit": ModelCard(
short_id="qwen3-235b-a22b-8bit",
- model_id="mlx-community/Qwen3-235B-A22B-Instruct-2507-8bit",
- name="Qwen3 235B, Active 22B (8-bit)",
+ model_id=ModelId("mlx-community/Qwen3-235B-A22B-Instruct-2507-8bit"),
+ name="Qwen3 235B A22B (8-bit)",
description="""Qwen3 235B (Active 22B) is a large language model trained on the Qwen3 235B dataset.""",
tags=[],
metadata=ModelMetadata(
model_id=ModelId("mlx-community/Qwen3-235B-A22B-Instruct-2507-8bit"),
- pretty_name="Qwen3 235B, Active 22B (8-bit)",
- storage_size=Memory.from_kb(246415360),
+ pretty_name="Qwen3 235B A22B (8-bit)",
+ storage_size=Memory.from_gb(250),
n_layers=94,
),
),
+ "qwen3-coder-480b-a35b-4bit": ModelCard(
+ short_id="qwen3-coder-480b-a35b-4bit",
+ model_id=ModelId("mlx-community/Qwen3-Coder-480B-A35B-Instruct-4bit"),
+ name="Qwen3 Coder 480B A35B (4-bit)",
+ description="""Qwen3 Coder 480B (Active 35B) is a large language model trained on the Qwen3 Coder 480B dataset.""",
+ tags=[],
+ metadata=ModelMetadata(
+ model_id=ModelId("mlx-community/Qwen3-Coder-480B-A35B-Instruct-4bit"),
+ pretty_name="Qwen3 Coder 480B A35B (4-bit)",
+ storage_size=Memory.from_gb(270),
+ n_layers=62,
+ ),
+ ),
+ "qwen3-coder-480b-a35b-8bit": ModelCard(
+ short_id="qwen3-coder-480b-a35b-8bit",
+ model_id=ModelId("mlx-community/Qwen3-Coder-480B-A35B-Instruct-8bit"),
+ name="Qwen3 Coder 480B A35B (8-bit)",
+ description="""Qwen3 Coder 480B (Active 35B) is a large language model trained on the Qwen3 Coder 480B dataset.""",
+ tags=[],
+ metadata=ModelMetadata(
+ model_id=ModelId("mlx-community/Qwen3-Coder-480B-A35B-Instruct-8bit"),
+ pretty_name="Qwen3 Coder 480B A35B (8-bit)",
+ storage_size=Memory.from_gb(540),
+ n_layers=62,
+ ),
+ ),
# granite
"granite-3.3-2b": ModelCard(
short_id="granite-3.3-2b",
- model_id="mlx-community/granite-3.3-2b-instruct-fp16",
- name="Granite 3.3 2B",
+ model_id=ModelId("mlx-community/granite-3.3-2b-instruct-fp16"),
+ name="Granite 3.3 2B (FP16)",
description="""Granite-3.3-2B-Instruct is a 2-billion parameter 128K context length language model fine-tuned for improved reasoning and instruction-following capabilities.""",
tags=[],
metadata=ModelMetadata(
model_id=ModelId("mlx-community/granite-3.3-2b-instruct-fp16"),
- pretty_name="Granite 3.3 2B",
- storage_size=Memory.from_kb(4948320),
+ pretty_name="Granite 3.3 2B (FP16)",
+ storage_size=Memory.from_mb(4951),
n_layers=40,
),
),
# "granite-3.3-8b": ModelCard(
# short_id="granite-3.3-8b",
- # model_id="mlx-community/granite-3.3-8b-instruct-fp16",
+ # model_id=ModelId("mlx-community/granite-3.3-8b-instruct-fp16"),
# name="Granite 3.3 8B",
# description="""Granite-3.3-8B-Instruct is a 8-billion parameter 128K context length language model fine-tuned for improved reasoning and instruction-following capabilities.""",
# tags=[],
@@ -335,4 +417,35 @@ MODEL_CARDS: dict[str, ModelCard] = {
# n_layers=30,
# ),
# ),
+ # gpt-oss
+ # "gpt-oss-120b-MXFP4-Q8": ModelCard(
+ # short_id="gpt-oss-120b-MXFP4-Q8",
+ # model_id=ModelId("mlx-community/gpt-oss-120b-MXFP4-Q8"),
+ # name="GPT-OSS 120B (MXFP4-Q8, MLX)",
+ # description="""OpenAI's GPT-OSS 120B is a 117B-parameter Mixture-of-Experts model designed for high-reasoning and general-purpose use; this variant is a 4-bit MLX conversion for Apple Silicon.""",
+ # tags=[],
+ # metadata=ModelMetadata(
+ # model_id=ModelId("mlx-community/gpt-oss-120b-MXFP4-Q8"),
+ # pretty_name="GPT-OSS 120B (MXFP4-Q8, MLX)",
+ # storage_size=Memory.from_kb(68_996_301),
+ # n_layers=36,
+ # hidden_size=2880,
+ # supports_tensor=True,
+ # ),
+ # ),
+ # "gpt-oss-20b-4bit": ModelCard(
+ # short_id="gpt-oss-20b-4bit",
+ # model_id=ModelId("mlx-community/gpt-oss-20b-MXFP4-Q4"),
+ # name="GPT-OSS 20B (MXFP4-Q4, MLX)",
+ # description="""OpenAI's GPT-OSS 20B is a medium-sized MoE model for lower-latency and local or specialized use cases; this MLX variant uses MXFP4 4-bit quantization.""",
+ # tags=[],
+ # metadata=ModelMetadata(
+ # model_id=ModelId("mlx-community/gpt-oss-20b-MXFP4-Q4"),
+ # pretty_name="GPT-OSS 20B (MXFP4-Q4, MLX)",
+ # storage_size=Memory.from_kb(11_744_051),
+ # n_layers=24,
+ # hidden_size=2880,
+ # supports_tensor=True,
+ # ),
+ # ),
}
diff --git a/src/exo/shared/types/api.py b/src/exo/shared/types/api.py
index 56def4dc..30b01e3e 100644
--- a/src/exo/shared/types/api.py
+++ b/src/exo/shared/types/api.py
@@ -1,11 +1,12 @@
import time
from typing import Any, Literal
-from pydantic import BaseModel, Field
+from pydantic import BaseModel, Field, field_validator
+from pydantic_core import PydanticUseDefault
from exo.shared.types.common import CommandId
-from exo.shared.types.models import ModelMetadata
-from exo.shared.types.worker.instances import InstanceId, InstanceMeta
+from exo.shared.types.models import ModelId
+from exo.shared.types.worker.instances import Instance, InstanceId, InstanceMeta
from exo.shared.types.worker.shards import Sharding
FinishReason = Literal[
@@ -24,6 +25,8 @@ class ModelListModel(BaseModel):
description: str = Field(default="")
context_length: int = Field(default=0)
tags: list[str] = Field(default=[])
+ storage_size_megabytes: int = Field(default=0)
+ supports_tensor: bool = Field(default=False)
class ModelList(BaseModel):
@@ -132,13 +135,37 @@ class ChatCompletionTaskParams(BaseModel):
user: str | None = None
-class CreateInstanceTaskParams(BaseModel):
- # TODO: in future the user could specify a specific Instance, not just a model_id
+class PlaceInstanceParams(BaseModel):
model_id: str
sharding: Sharding = Sharding.Pipeline
instance_meta: InstanceMeta = InstanceMeta.MlxRing
min_nodes: int = 1
+ @field_validator("sharding", "instance_meta", mode="plain")
+ @classmethod
+ def use_default(cls, v: object):
+ if not v or not isinstance(v, (Sharding, InstanceMeta)):
+ raise PydanticUseDefault()
+ return v
+
+
+class CreateInstanceParams(BaseModel):
+ instance: Instance
+
+
+class PlacementPreview(BaseModel):
+ model_id: ModelId
+ sharding: Sharding
+ instance_meta: InstanceMeta
+ instance: Instance | None = None
+ # Keys are NodeId strings, values are additional bytes that would be used on that node
+ memory_delta_by_node: dict[str, int] | None = None
+ error: str | None = None
+
+
+class PlacementPreviewResponse(BaseModel):
+ previews: list[PlacementPreview]
+
class DeleteInstanceTaskParams(BaseModel):
instance_id: str
@@ -147,7 +174,6 @@ class DeleteInstanceTaskParams(BaseModel):
class CreateInstanceResponse(BaseModel):
message: str
command_id: CommandId
- model_meta: ModelMetadata
class DeleteInstanceResponse(BaseModel):
diff --git a/src/exo/shared/types/commands.py b/src/exo/shared/types/commands.py
index 0a584ff5..5d8a5026 100644
--- a/src/exo/shared/types/commands.py
+++ b/src/exo/shared/types/commands.py
@@ -3,7 +3,7 @@ from pydantic import Field
from exo.shared.types.api import ChatCompletionTaskParams
from exo.shared.types.common import CommandId, NodeId
from exo.shared.types.models import ModelMetadata
-from exo.shared.types.worker.instances import InstanceId, InstanceMeta
+from exo.shared.types.worker.instances import Instance, InstanceId, InstanceMeta
from exo.shared.types.worker.shards import Sharding
from exo.utils.pydantic_ext import CamelCaseModel, TaggedModel
@@ -20,13 +20,17 @@ class ChatCompletion(BaseCommand):
request_params: ChatCompletionTaskParams
-class CreateInstance(BaseCommand):
+class PlaceInstance(BaseCommand):
model_meta: ModelMetadata
sharding: Sharding
instance_meta: InstanceMeta
min_nodes: int
+class CreateInstance(BaseCommand):
+ instance: Instance
+
+
class DeleteInstance(BaseCommand):
instance_id: InstanceId
@@ -43,6 +47,7 @@ Command = (
TestCommand
| RequestEventLog
| ChatCompletion
+ | PlaceInstance
| CreateInstance
| DeleteInstance
| TaskFinished
diff --git a/src/exo/shared/types/memory.py b/src/exo/shared/types/memory.py
index 562c3c87..b97fb345 100644
--- a/src/exo/shared/types/memory.py
+++ b/src/exo/shared/types/memory.py
@@ -47,6 +47,11 @@ class Memory(CamelCaseModel):
"""Construct a new Memory object from a number of megabytes"""
return cls(in_bytes=round(val * (1024**2)))
+ @classmethod
+ def from_gb(cls, val: float) -> Self:
+ """Construct a new Memory object from a number of megabytes"""
+ return cls(in_bytes=round(val * (1024**3)))
+
@property
def in_gb(self) -> float:
"""The approximate gigabytes this memory represents."""
diff --git a/src/exo/utils/dashboard_path.py b/src/exo/utils/dashboard_path.py
new file mode 100644
index 00000000..b9e6990c
--- /dev/null
+++ b/src/exo/utils/dashboard_path.py
@@ -0,0 +1,45 @@
+import os
+import sys
+from pathlib import Path
+from typing import cast
+
+
+def find_dashboard() -> Path:
+ dashboard = (
+ _find_dashboard_in_env()
+ or _find_dashboard_in_repo()
+ or _find_dashboard_in_bundle()
+ )
+ if not dashboard:
+ raise FileNotFoundError(
+ "Unable to locate dashboard assets. Export DASHBOARD_DIR or rebuild the binary."
+ )
+ return dashboard
+
+
+def _find_dashboard_in_env() -> Path | None:
+ env = os.environ.get("DASHBOARD_DIR")
+ if not env:
+ return None
+ resolved_env = Path(env).expanduser().resolve()
+
+ return resolved_env
+
+
+def _find_dashboard_in_repo() -> Path | None:
+ current_module = Path(__file__).resolve()
+ for parent in current_module.parents:
+ build = parent / "dashboard" / "build"
+ if build.is_dir() and (build / "index.html").exists():
+ return build
+ return None
+
+
+def _find_dashboard_in_bundle() -> Path | None:
+ frozen_root = cast(str | None, getattr(sys, "_MEIPASS", None))
+ if frozen_root is None:
+ return None
+ candidate = Path(frozen_root) / "dashboard"
+ if candidate.is_dir():
+ return candidate
+ return None
← 880a18d2 fix disconnects
·
back to Exo
·
prep repo for v1 0fcee708 →