mirror of
https://github.com/exo-explore/exo.git
synced 2025-12-23 22:27:50 -05:00
2676 lines
122 KiB
HTML
2676 lines
122 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;
|
|
text-align: left;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: flex-end;
|
|
}
|
|
|
|
.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-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 */
|
|
}
|
|
}
|
|
|
|
.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;
|
|
}
|
|
|
|
.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-details {
|
|
font-size: 12px;
|
|
color: var(--exo-light-gray);
|
|
}
|
|
|
|
|
|
|
|
.download-progress {
|
|
font-size: 11px;
|
|
color: var(--exo-light-gray);
|
|
margin-top: 4px;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
|
|
.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;
|
|
}
|
|
|
|
/* Detailed download info */
|
|
.download-details {
|
|
margin-top: 8px;
|
|
padding: 12px;
|
|
background-color: #1a1a1a;
|
|
border: 1px solid var(--exo-medium-gray);
|
|
border-radius: 6px;
|
|
box-sizing: border-box;
|
|
width: 100%;
|
|
max-width: 100%;
|
|
overflow: visible;
|
|
}
|
|
.download-runner-header {
|
|
font-size: 11px;
|
|
color: var(--exo-light-gray);
|
|
opacity: 0.85;
|
|
margin-bottom: 4px;
|
|
}
|
|
.download-overview-row {
|
|
display: flex;
|
|
gap: 12px;
|
|
flex-wrap: wrap;
|
|
font-size: 12px;
|
|
margin-bottom: 8px;
|
|
}
|
|
.download-overview-item strong {
|
|
color: #E0E0E0;
|
|
font-weight: 600;
|
|
margin-right: 4px;
|
|
}
|
|
.progress-with-label {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
margin-bottom: 10px;
|
|
}
|
|
.progress-with-label .progress-bar-container {
|
|
flex: 1 1 auto;
|
|
}
|
|
.progress-percent {
|
|
font-size: 12px;
|
|
color: var(--exo-light-gray);
|
|
font-variant-numeric: tabular-nums;
|
|
white-space: nowrap;
|
|
}
|
|
.download-overview-combined {
|
|
font-size: 12px;
|
|
color: var(--exo-light-gray);
|
|
opacity: 0.9;
|
|
}
|
|
.instance-download-summary {
|
|
font-size: 11px;
|
|
color: var(--exo-light-gray);
|
|
margin-top: 6px;
|
|
opacity: 0.95;
|
|
}
|
|
.download-files-list {
|
|
display: grid;
|
|
gap: 8px;
|
|
}
|
|
.download-file {
|
|
padding: 8px;
|
|
background-color: var(--exo-dark-gray);
|
|
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-stats {
|
|
color: var(--exo-light-gray);
|
|
text-align: right;
|
|
white-space: nowrap;
|
|
}
|
|
.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-details, .download-files-list {
|
|
box-sizing: border-box;
|
|
width: 100%;
|
|
max-width: 100%;
|
|
}
|
|
.download-files-list {
|
|
overflow: visible;
|
|
padding-right: 2px; /* avoid edge clipping */
|
|
}
|
|
.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 var(--exo-medium-gray);
|
|
}
|
|
.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;
|
|
}
|
|
|
|
.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>
|
|
<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">
|
|
<h1><img src="exo-logo.png" alt="EXO logo" height="48" /></h1>
|
|
<p class="last-updated" id="lastUpdated">Fetching data...</p>
|
|
</div>
|
|
<button class="header-instances-button" id="instancesMenuButton">Instances</button>
|
|
</div>
|
|
|
|
<!-- Replaced node-grid with SVG container for topology graph -->
|
|
<svg id="topologyGraphContainer"></svg>
|
|
|
|
<div id="nodeDetailPanel">
|
|
<span id="closeDetailPanel" title="Close">×</span>
|
|
<h2 id="detailFriendlyName">Node Details</h2>
|
|
<div id="detailNodeId" class="detail-node-id"></div>
|
|
<div id="detailContent">
|
|
<!-- Info items will be populated here -->
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
// Helper to convert RGB string for box-shadow
|
|
function setRgbVar(cssVarName, hexColor) {
|
|
const r = parseInt(hexColor.slice(1, 3), 16);
|
|
const g = parseInt(hexColor.slice(3, 5), 16);
|
|
const b = parseInt(hexColor.slice(5, 7), 16);
|
|
document.documentElement.style.setProperty(`--${cssVarName}-rgb`, `${r}, ${g}, ${b}`);
|
|
}
|
|
setRgbVar('exo-yellow', getComputedStyle(document.documentElement).getPropertyValue('--exo-yellow').trim());
|
|
|
|
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 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
|
|
|
|
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);
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
|
|
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 })
|
|
});
|
|
|
|
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();
|
|
renderInstances(data.instances || {}, data.runners || {});
|
|
} 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 its runners, with detailed per-file info
|
|
function calculateInstanceDownloadStatus(instance, runners) {
|
|
if (!instance.shardAssignments?.runnerToShard || !runners) {
|
|
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;
|
|
};
|
|
|
|
// Returns [tag, payload] for objects serialized as {Tag: {...}}, else [null, null]
|
|
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];
|
|
}
|
|
|
|
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 };
|
|
}
|
|
|
|
const runnerIds = Object.keys(instance.shardAssignments.runnerToShard);
|
|
const details = [];
|
|
let totalBytes = 0;
|
|
let downloadedBytes = 0;
|
|
|
|
for (const runnerId of runnerIds) {
|
|
const runner = runners[runnerId];
|
|
if (!runner) continue;
|
|
|
|
// New tagged format: { "DownloadingRunnerStatus": { downloadProgress: { "DownloadOngoing": { ... } } } }
|
|
const [statusKind, statusPayload] = getTagged(runner);
|
|
let nodeId;
|
|
let rawProg;
|
|
|
|
if (statusKind === 'DownloadingRunnerStatus') {
|
|
const dpTagged = statusPayload && (statusPayload.downloadProgress || statusPayload.download_progress);
|
|
const [dpKind, dpPayload] = getTagged(dpTagged);
|
|
if (dpKind !== 'DownloadOngoing') continue;
|
|
nodeId = (dpPayload && (dpPayload.nodeId || dpPayload.node_id)) || undefined;
|
|
rawProg = pick(dpPayload, 'download_progress', 'downloadProgress', null);
|
|
} else {
|
|
// Backward compatibility with old flat shape
|
|
if (runner.runnerStatus !== 'Downloading' || !runner.downloadProgress) continue;
|
|
const dp = runner.downloadProgress;
|
|
const isDownloading = (dp.downloadStatus === 'Downloading') || (dp.download_status === 'Downloading');
|
|
if (!isDownloading) continue;
|
|
nodeId = (dp && (dp.nodeId || dp.node_id)) || undefined;
|
|
rawProg = pick(dp, 'download_progress', 'downloadProgress', null);
|
|
}
|
|
|
|
const normalized = normalizeProgress(rawProg);
|
|
if (!normalized) continue;
|
|
details.push({ runnerId, nodeId, progress: normalized });
|
|
totalBytes += normalized.totalBytes || 0;
|
|
downloadedBytes += normalized.downloadedBytes || 0;
|
|
}
|
|
|
|
const isDownloadingAny = details.length > 0;
|
|
const progress = totalBytes > 0 ? ((downloadedBytes / totalBytes) * 100) : 0;
|
|
return { isDownloading: isDownloadingAny, progress, details };
|
|
}
|
|
|
|
function buildDownloadDetailsHTML(details) {
|
|
if (!details || details.length === 0) return '';
|
|
function shortId(id) { return (id && id.length > 8) ? id.slice(0, 8) + '…' : (id || ''); }
|
|
return details.map(({ runnerId, nodeId, progress }) => {
|
|
const etaStr = formatDurationMs(progress.etaMs);
|
|
const pctStr = formatPercent(progress.percentage || 0, 2);
|
|
const bytesStr = `${formatBytes(progress.downloadedBytes)} / ${formatBytes(progress.totalBytes)}`;
|
|
const speedStr = formatBytesPerSecond(progress.speed);
|
|
const filesSummary = `${progress.completedFiles}/${progress.totalFiles}`;
|
|
|
|
const allFiles = progress.files || [];
|
|
const inProgressFiles = allFiles.filter(f => (f.percentage || 0) < 100);
|
|
const completedFiles = allFiles.filter(f => (f.percentage || 0) >= 100);
|
|
|
|
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 pctText = 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">${pctText}</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('');
|
|
|
|
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>
|
|
` : '';
|
|
|
|
const runnerName = (nodeId && nodeIdToFriendlyName[nodeId]) ? nodeIdToFriendlyName[nodeId] : '?';
|
|
const headerText = `${runnerName} (${shortId(nodeId || '')})`;
|
|
return `
|
|
<div class="download-details">
|
|
<div class="download-runner-header">${headerText}</div>
|
|
<div class="download-files-list">
|
|
${inProgressHTML}
|
|
</div>
|
|
${completedHTML}
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
}
|
|
|
|
// Derive a display status for an instance from its runners.
|
|
// Priority: FAILED > DOWNLOADING > STARTING > RUNNING > LOADED > INACTIVE
|
|
function deriveInstanceStatus(instance, runners = {}) {
|
|
const runnerIds = Object.keys(instance.shardAssignments?.runnerToShard || {});
|
|
|
|
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];
|
|
}
|
|
|
|
function canonicalStatusFromKind(kind) {
|
|
const map = {
|
|
DownloadingRunnerStatus: 'Downloading',
|
|
InactiveRunnerStatus: 'Inactive',
|
|
StartingRunnerStatus: 'Starting',
|
|
LoadedRunnerStatus: 'Loaded',
|
|
RunningRunnerStatus: 'Running',
|
|
FailedRunnerStatus: '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; // backward compatibility
|
|
})
|
|
.filter(s => typeof s === 'string');
|
|
|
|
const has = (s) => statuses.includes(s);
|
|
const every = (pred) => statuses.length > 0 && statuses.every(pred);
|
|
|
|
if (statuses.length === 0) {
|
|
const it = instance.instanceType;
|
|
const inactive = (it === 'Inactive' || it === 'INACTIVE');
|
|
return { statusText: inactive ? 'INACTIVE' : 'LOADED', statusClass: inactive ? 'inactive' : 'loaded' };
|
|
}
|
|
|
|
if (has('Failed')) return { statusText: 'FAILED', statusClass: 'failed' };
|
|
if (has('Downloading')) return { statusText: 'DOWNLOADING', statusClass: 'downloading' };
|
|
if (has('Starting')) return { statusText: 'LOADING', statusClass: 'starting' };
|
|
if (has('Running')) return { statusText: 'RUNNING', statusClass: 'running' };
|
|
|
|
const allInactive = every(s => s === 'Inactive');
|
|
const loadedOrInactiveOnly = every(s => s === 'Loaded' || s === 'Inactive');
|
|
const anyLoaded = statuses.some(s => s === 'Loaded');
|
|
if (loadedOrInactiveOnly && anyLoaded) {
|
|
return { statusText: 'LOADED', statusClass: 'loaded' };
|
|
}
|
|
if (allInactive) {
|
|
return { statusText: 'INACTIVE', statusClass: 'inactive' };
|
|
}
|
|
return { statusText: 'LOADED', statusClass: 'loaded' };
|
|
}
|
|
|
|
function renderInstances(instances, runners = {}) {
|
|
const instancesArray = Object.values(instances);
|
|
|
|
if (instancesArray.length === 0) {
|
|
instancesList.innerHTML = '<div class="no-instances">No instances running</div>';
|
|
return;
|
|
}
|
|
|
|
const instancesHTML = instancesArray.map(instance => {
|
|
const modelId = instance.shardAssignments?.modelId || 'Unknown Model';
|
|
const truncatedInstanceId = instance.instanceId.length > 8
|
|
? instance.instanceId.substring(0, 8) + '...'
|
|
: instance.instanceId;
|
|
|
|
const hostsHTML = instance.hosts?.map(host =>
|
|
`<span class="instance-host">${host.ip}:${host.port}</span>`
|
|
).join('') || '';
|
|
|
|
// Calculate download status for this instance
|
|
const downloadStatus = calculateInstanceDownloadStatus(instance, runners);
|
|
|
|
let statusText, statusClass;
|
|
if (downloadStatus.isDownloading) {
|
|
({ statusText, statusClass } = { statusText: 'DOWNLOADING', statusClass: 'downloading' });
|
|
} else {
|
|
({ statusText, statusClass } = deriveInstanceStatus(instance, runners));
|
|
}
|
|
|
|
// Generate download progress HTML
|
|
let downloadProgressHTML = '';
|
|
let instanceDownloadSummary = '';
|
|
if (downloadStatus.isDownloading) {
|
|
const detailsHTML = buildDownloadDetailsHTML(downloadStatus.details || []);
|
|
const pctText = (downloadStatus.progress || 0).toFixed(2);
|
|
// Aggregate a compact summary from the first runner (they should be consistent in aggregate)
|
|
const first = (downloadStatus.details || [])[0]?.progress;
|
|
const etaStr = first ? formatDurationMs(first.etaMs) : '—';
|
|
const bytesStr = first ? `${formatBytes(first.downloadedBytes)} / ${formatBytes(first.totalBytes)}` : '';
|
|
const speedStr = first ? formatBytesPerSecond(first.speed) : '';
|
|
const filesSummary = first ? `${first.completedFiles}/${first.totalFiles}` : '';
|
|
instanceDownloadSummary = `${etaStr} · ${bytesStr} · ${speedStr} · ${filesSummary} files`;
|
|
|
|
downloadProgressHTML = `
|
|
<div class="download-progress">
|
|
<span>${pctText}%</span>
|
|
<div class="progress-bar-container">
|
|
<div class="progress-bar" style="width: ${pctText}%;"></div>
|
|
</div>
|
|
</div>
|
|
${detailsHTML}
|
|
`;
|
|
}
|
|
|
|
const shardCount = Object.keys(instance.shardAssignments?.runnerToShard || {}).length;
|
|
return `
|
|
<div class="instance-item">
|
|
<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="${instance.instanceId}" title="Delete Instance">
|
|
Delete
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="instance-model">${modelId} <span style="color: var(--exo-light-gray); opacity: 0.8;">(${shardCount})</span></div>
|
|
${instanceDownloadSummary ? `<div class="instance-download-summary">${instanceDownloadSummary}</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}`);
|
|
}
|
|
}
|
|
|
|
function renderNodes(topologyData) {
|
|
if (!topologyGraphContainer) return;
|
|
topologyGraphContainer.innerHTML = ''; // Clear previous SVG content
|
|
|
|
const nodesData = (topologyData && topologyData.nodes) ? topologyData.nodes : {};
|
|
const edgesData = (topologyData && Array.isArray(topologyData.edges)) ? topologyData.edges : [];
|
|
const nodeIds = Object.keys(nodesData);
|
|
|
|
if (nodeIds.length === 0) {
|
|
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;');
|
|
|
|
// Build quick lookup for node positions
|
|
const positionById = {};
|
|
nodesWithPositions.forEach(n => { positionById[n.id] = { x: n.x, y: n.y }; });
|
|
|
|
// Group directed edges into undirected pairs to support single line with two arrows
|
|
const pairMap = new Map(); // key: "a|b" with a<b, value: { a, b, aToB, bToA }
|
|
edgesData.forEach(edge => {
|
|
if (!edge || !edge.source || !edge.target) return;
|
|
if (!positionById[edge.source] || !positionById[edge.target]) return;
|
|
if (edge.source === edge.target) return;
|
|
const a = edge.source < edge.target ? edge.source : edge.target;
|
|
const b = edge.source < edge.target ? edge.target : edge.source;
|
|
const key = `${a}|${b}`;
|
|
const entry = pairMap.get(key) || { a, b, aToB: false, bToA: false };
|
|
if (edge.source === a && edge.target === b) entry.aToB = true; else entry.bToA = true;
|
|
pairMap.set(key, entry);
|
|
});
|
|
|
|
// Draw one line per undirected pair with separate arrow carrier lines
|
|
pairMap.forEach(entry => {
|
|
const posA = positionById[entry.a];
|
|
const posB = positionById[entry.b];
|
|
if (!posA || !posB) return;
|
|
|
|
// 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);
|
|
}
|
|
|
|
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);
|
|
}
|
|
});
|
|
// 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 === "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 === "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 === "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 nodes, then mid-line arrows on top
|
|
topologyGraphContainer.appendChild(linksGroup);
|
|
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
|
|
resultEdges.push({ source: src, target: dst });
|
|
});
|
|
|
|
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() {
|
|
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> |