← back to Exo
Reinstate dashboard
3f192f20cc9cb6d93ab0457369a361919dabad0a · 2025-07-28 15:18:23 -0700 · Seth Howes
Files touched
A dashboard/index.htmlM master/api.py
Diff
commit 3f192f20cc9cb6d93ab0457369a361919dabad0a
Author: Seth Howes <71157822+sethhowes@users.noreply.github.com>
Date: Mon Jul 28 15:18:23 2025 -0700
Reinstate dashboard
---
dashboard/index.html | 1411 ++++++++++++++++++++++++++++++++++++++++++++++++++
master/api.py | 33 +-
2 files changed, 1422 insertions(+), 22 deletions(-)
diff --git a/dashboard/index.html b/dashboard/index.html
new file mode 100644
index 00000000..9d8c9e9a
--- /dev/null
+++ b/dashboard/index.html
@@ -0,0 +1,1411 @@
+<!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: 20px;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ }
+
+ .dashboard-header {
+ width: 100%;
+ max-width: 1200px;
+ margin-bottom: 30px;
+ text-align: left;
+ }
+
+ .dashboard-header h1 {
+ font-size: 2.5em;
+ color: var(--exo-yellow);
+ margin: 0;
+ font-weight: 600;
+ }
+ .dashboard-header h1 .logo-text {
+ font-weight: bold;
+ }
+ .dashboard-header p {
+ font-size: 1em;
+ color: var(--exo-light-gray);
+ margin-top: 5px;
+ }
+ .dashboard-header .last-updated {
+ font-size: 0.8em;
+ color: var(--exo-medium-gray);
+ margin-top: 10px;
+ }
+
+ /* 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: 20px;
+ position: relative; /* For potential absolute positioning of elements within */
+ }
+
+ .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);
+ }
+ #nodeDetailPanel.visible {
+ right: 0;
+ }
+ #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);
+ }
+
+ </style>
+</head>
+<body>
+
+ <div class="dashboard-header">
+ <h1><span class="logo-text">EXO</span></h1>
+ <p class="last-updated" id="lastUpdated">Fetching data...</p>
+ </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');
+
+ const USE_MOCK_DATA = false; // <<< FLAG TO TOGGLE MOCK DATA
+ let currentlySelectedNodeId = null; // To store the ID of the currently selected node
+
+ 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');
+ }
+
+ function renderNodes(nodesData) {
+ if (!topologyGraphContainer) return;
+ topologyGraphContainer.innerHTML = ''; // Clear previous SVG content
+
+ 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)
+ };
+ });
+
+ // Create group for links (drawn first, so they are behind nodes)
+ const linksGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g');
+ linksGroup.setAttribute('class', 'links-group');
+
+ for (let i = 0; i < numNodes; i++) {
+ for (let j = i + 1; j < numNodes; j++) {
+ const link = document.createElementNS('http://www.w3.org/2000/svg', 'line');
+ link.setAttribute('x1', nodesWithPositions[i].x);
+ link.setAttribute('y1', nodesWithPositions[i].y);
+ link.setAttribute('x2', nodesWithPositions[j].x);
+ link.setAttribute('y2', nodesWithPositions[j].y);
+ link.setAttribute('class', 'graph-link');
+ linksGroup.appendChild(link);
+ }
+ }
+ topologyGraphContainer.appendChild(linksGroup);
+
+ // 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);
+ });
+ topologyGraphContainer.appendChild(nodesGroup);
+ }
+
+ 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
+ });
+ }
+
+ 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 nodesData = transformClusterStateToTopology(clusterState);
+ renderNodes(nodesData);
+
+ // If a node was selected, and it still exists, refresh its details
+ if (currentlySelectedNodeId && nodesData[currentlySelectedNodeId]) {
+ showNodeDetails(currentlySelectedNodeId, nodesData);
+ } else if (currentlySelectedNodeId && !nodesData[currentlySelectedNodeId]) {
+ // If 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 result = {};
+ if (!clusterState) return result;
+
+ // Process nodes from topology or fallback to node_profiles directly
+ let nodesToProcess = {};
+
+ if (clusterState.topology && clusterState.topology.nodes) {
+ // Use topology.nodes array
+ clusterState.topology.nodes.forEach(node => {
+ if (node.node_id && node.node_profile) {
+ nodesToProcess[node.node_id] = node.node_profile;
+ }
+ });
+ } else if (clusterState.node_profiles) {
+ // Fallback to node_profiles directly
+ nodesToProcess = clusterState.node_profiles;
+ }
+
+ // Transform each node
+ for (const nodeId in nodesToProcess) {
+ const nodeProfile = nodesToProcess[nodeId];
+ if (!nodeProfile) continue;
+
+ // Extract memory information
+ let memBytesTotal = 0;
+ let memBytesUsed = 0;
+
+ if (nodeProfile.memory) {
+ memBytesTotal = nodeProfile.memory.ram_total || 0;
+ const ramAvailable = nodeProfile.memory.ram_available || 0;
+ memBytesUsed = Math.max(memBytesTotal - ramAvailable, 0);
+ }
+
+ // Extract model information
+ const modelId = nodeProfile.model_id || 'Unknown';
+ const chipId = nodeProfile.chip_id || '';
+ const friendlyName = nodeProfile.friendly_name || `${nodeId.substring(0, 8)}...`;
+
+ // Extract network addresses
+ const addrList = [];
+ if (nodeProfile.network_interfaces) {
+ nodeProfile.network_interfaces.forEach(intf => {
+ if (intf.ip_address && !intf.ip_address.startsWith('fe80::')) {
+ // Filter out link-local IPv6 addresses
+ addrList.push(intf.ip_address);
+ }
+ });
+ }
+
+ // Transform system metrics to macmon_info format for compatibility
+ const systemInfo = nodeProfile.system || {};
+ const macmonInfo = {
+ memory: {
+ ram_total: memBytesTotal,
+ ram_usage: memBytesUsed,
+ ram_available: nodeProfile.memory?.ram_available || 0,
+ swap_total: nodeProfile.memory?.swap_total || 0,
+ swap_usage: (nodeProfile.memory?.swap_total || 0) - (nodeProfile.memory?.swap_available || 0)
+ },
+ // Convert new format to old format
+ gpu_usage: systemInfo.gpu_usage ? [0, systemInfo.gpu_usage] : [0, 0],
+ temp: {
+ cpu_temp_avg: systemInfo.temp || null,
+ gpu_temp_avg: systemInfo.temp || null // Using same temp for both in new format
+ },
+ sys_power: systemInfo.sys_power || null,
+ pcpu_usage: systemInfo.pcpu_usage ? [0, systemInfo.pcpu_usage] : [0, 0],
+ ecpu_usage: systemInfo.ecpu_usage ? [0, systemInfo.ecpu_usage] : [0, 0],
+ ane_power: systemInfo.ane_power || 0,
+ flops_fp16: systemInfo.flops_fp16 || 0,
+ timestamp: new Date().toISOString()
+ };
+
+ result[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
+ };
+ }
+
+ return result;
+ }
+
+ // --- Conditional Data Handling ---
+ if (!USE_MOCK_DATA) {
+ // Initial fetch for live data
+ fetchDataAndRender();
+ // Periodic refresh for live data
+ fetchIntervalId = setInterval(fetchDataAndRender, 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();
+ }
+ }
+ renderNodes(mockData);
+ lastUpdatedElement.textContent = `Last updated: ${new Date().toLocaleTimeString()} (Mock Data)`;
+
+ if (currentlySelectedNodeId && mockData[currentlySelectedNodeId]) {
+ showNodeDetails(currentlySelectedNodeId, mockData);
+ } else if (currentlySelectedNodeId && !mockData[currentlySelectedNodeId]) {
+ nodeDetailPanel.classList.remove('visible');
+ currentlySelectedNodeId = null;
+ }
+ }
+ updateMockData(); // Initial render with mock
+ setInterval(updateMockData, REFRESH_INTERVAL);
+ }
+
+
+ // Mock data for local testing if the API is not available
+ // Comment out fetchDataAndRender() and setInterval() above
+ // and uncomment the block below to use mock data.
+ /* <<<< This comment and the one below should be removed or adjusted
+ const mockData = {
+ "12D3KooWEbiTv9MkyNu5aVi4A7A2xHhwyrFxPEqso2ciJtPDjKcn": {
+// ... existing old mock data ...
+ setInterval(updateMockData, REFRESH_INTERVAL); // Update mock data every second
+ */
+
+ </script>
+</body>
+</html>
\ No newline at end of file
diff --git a/master/api.py b/master/api.py
index 69efb21b..ba74077f 100644
--- a/master/api.py
+++ b/master/api.py
@@ -1,4 +1,5 @@
import asyncio
+from pathlib import Path
import time
from collections.abc import AsyncGenerator
from typing import Callable, List, Sequence, final
@@ -7,6 +8,7 @@ import uvicorn
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse
+from fastapi.staticfiles import StaticFiles
from shared.db.sqlite.connector import AsyncSQLiteEventStorage
from shared.models.model_cards import MODEL_CARDS
@@ -38,6 +40,10 @@ from shared.types.tasks import ChatCompletionTaskParams
from shared.types.worker.common import InstanceId
from shared.types.worker.instances import Instance
+# TODO: Make sure that when we package the app the dashboard is in the right place.
+_ROOT_DIR = Path(__file__).resolve().parents[1]
+_DASHBOARD_DIR = _ROOT_DIR / "dashboard"
+
def chunk_to_response(chunk: TokenChunk) -> ChatCompletionResponse:
return ChatCompletionResponse(
@@ -74,6 +80,8 @@ class API:
self.global_events = global_events
self.get_state = get_state
+ self._app.mount("/", StaticFiles(directory=_DASHBOARD_DIR, html=True), name="dashboard")
+
def _setup_cors(self) -> None:
self._app.add_middleware(
CORSMiddleware,
@@ -84,35 +92,18 @@ class API:
)
def _setup_routes(self) -> None:
- # self._app.get("/topology/control_plane")(self.get_control_plane_topology)
- # self._app.get("/topology/data_plane")(self.get_data_plane_topology)
- # self._app.get("/instances/list")(self.list_instances)
self._app.post("/instance")(self.create_instance)
self._app.get("/instance/{instance_id}")(self.get_instance)
self._app.delete("/instance/{instance_id}")(self.delete_instance)
self._app.get("/models")(self.get_models)
self._app.get("/v1/models")(self.get_models)
- # self._app.get("/model/{model_id}/metadata")(self.get_model_data)
- # self._app.post("/model/{model_id}/instances")(self.get_instances_by_model)
self._app.post("/v1/chat/completions")(self.chat_completions)
+ self._app.get("/state")(self.get_state)
@property
def app(self) -> FastAPI:
return self._app
- # def get_control_plane_topology(self):
- # return {"message": "Hello, World!"}
-
- # def get_data_plane_topology(self):
- # return {"message": "Hello, World!"}
-
- # def get_model_metadata(self, model_id: ModelId) -> ModelMetadata: ...
-
- # def download_model(self, model_id: ModelId) -> None: ...
-
- # def list_instances(self):
- # return {"message": "Hello, World!"}
-
async def create_instance(self, payload: CreateInstanceTaskParams) -> CreateInstanceResponse:
model_meta = await resolve_model_meta(payload.model_id)
@@ -153,10 +144,6 @@ class API:
instance_id=instance_id,
)
- # def get_model_data(self, model_id: ModelId) -> ModelInfo: ...
-
- # def get_instances_by_model(self, model_id: ModelId) -> list[Instance]: ...
-
async def _generate_chat_stream(self, command_id: CommandId) -> AsyncGenerator[str, None]:
"""Generate chat completion stream as JSON strings."""
@@ -221,6 +208,8 @@ class API:
description=card.description,
tags=card.tags) for card in MODEL_CARDS.values()])
+ async def get_state(self) -> State:
+ return self.get_state()
def start_fastapi_server(
← a2b4093d add metrics: gpu_usage, temp, sys_power, pcpu_usage, ecpu_us
·
back to Exo
·
Update CODEOWNERS 25fa46c6 →