Files
exo/dashboard/index.html
2025-11-26 18:16:32 +00:00

3343 lines
154 KiB
HTML

<!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">&times;</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>