diff --git a/public/index.html b/public/index.html
index d77de05..32651e1 100644
--- a/public/index.html
+++ b/public/index.html
@@ -146,6 +146,7 @@
+
diff --git a/public/scripts/components/ClusterMembersComponent.js b/public/scripts/components/ClusterMembersComponent.js
index fda4a19..7b89fb4 100644
--- a/public/scripts/components/ClusterMembersComponent.js
+++ b/public/scripts/components/ClusterMembersComponent.js
@@ -21,71 +21,23 @@ class ClusterMembersComponent extends Component {
}, 200);
// Drawer state for desktop
- this.detailsDrawer = null;
- this.detailsDrawerContent = null;
- this.detailsDrawerBackdrop = null;
- this.activeDrawerComponent = null;
+ this.drawer = new DrawerComponent();
}
// Determine if we should use desktop drawer behavior
isDesktop() {
- try {
- return window && window.innerWidth >= 1024; // desktop threshold
- } catch (_) {
- return false;
- }
+ return this.drawer.isDesktop();
}
- ensureDrawer() {
- if (this.detailsDrawer) return;
- // Create backdrop
- this.detailsDrawerBackdrop = document.createElement('div');
- this.detailsDrawerBackdrop.className = 'details-drawer-backdrop';
- document.body.appendChild(this.detailsDrawerBackdrop);
-
- // Create drawer
- this.detailsDrawer = document.createElement('div');
- this.detailsDrawer.className = 'details-drawer';
-
- // Header with close button
- const header = document.createElement('div');
- header.className = 'details-drawer-header';
- header.innerHTML = `
-
Node Details
-
- `;
- this.detailsDrawer.appendChild(header);
-
- // Content container
- this.detailsDrawerContent = document.createElement('div');
- this.detailsDrawerContent.className = 'details-drawer-content';
- this.detailsDrawer.appendChild(this.detailsDrawerContent);
-
- document.body.appendChild(this.detailsDrawer);
-
- // Close handlers
- const close = () => this.closeDrawer();
- header.querySelector('.drawer-close').addEventListener('click', close);
- this.detailsDrawerBackdrop.addEventListener('click', close);
- document.addEventListener('keydown', (e) => {
- if (e.key === 'Escape') close();
- });
- }
openDrawerForMember(memberIp) {
- this.ensureDrawer();
-
- // Set drawer title to member name (hostname) and IP
+ // Get display name for drawer title
+ let displayName = memberIp;
try {
const members = (this.viewModel && typeof this.viewModel.get === 'function') ? this.viewModel.get('members') : [];
const member = Array.isArray(members) ? members.find(m => m && m.ip === memberIp) : null;
const hostname = (member && member.hostname) ? member.hostname : '';
const ip = (member && member.ip) ? member.ip : memberIp;
- let displayName = memberIp;
if (hostname && ip) {
displayName = `${hostname} - ${ip}`;
@@ -94,46 +46,33 @@ class ClusterMembersComponent extends Component {
} else if (ip) {
displayName = ip;
}
-
- const titleEl = this.detailsDrawer.querySelector('.drawer-title');
- if (titleEl) {
- titleEl.textContent = displayName;
- }
} catch (_) {
// no-op if anything goes wrong, default title remains
}
- // Clear previous component if any
- if (this.activeDrawerComponent && typeof this.activeDrawerComponent.unmount === 'function') {
- try { this.activeDrawerComponent.unmount(); } catch (_) {}
- }
- this.detailsDrawerContent.innerHTML = 'Loading detailed information...
';
+ // Open drawer with content callback
+ this.drawer.openDrawer(displayName, (contentContainer, setActiveComponent) => {
+ // Load and mount NodeDetails into drawer
+ const nodeDetailsVM = new NodeDetailsViewModel();
+ const nodeDetailsComponent = new NodeDetailsComponent(contentContainer, nodeDetailsVM, this.eventBus);
+ setActiveComponent(nodeDetailsComponent);
- // Load and mount NodeDetails into drawer
- const nodeDetailsVM = new NodeDetailsViewModel();
- const nodeDetailsComponent = new NodeDetailsComponent(this.detailsDrawerContent, nodeDetailsVM, this.eventBus);
- this.activeDrawerComponent = nodeDetailsComponent;
-
- nodeDetailsVM.loadNodeDetails(memberIp).then(() => {
- nodeDetailsComponent.mount();
- }).catch((error) => {
- logger.error('Failed to load node details for drawer:', error);
- this.detailsDrawerContent.innerHTML = `
-
- Error loading node details:
- ${this.escapeHtml(error.message)}
-
- `;
+ nodeDetailsVM.loadNodeDetails(memberIp).then(() => {
+ nodeDetailsComponent.mount();
+ }).catch((error) => {
+ logger.error('Failed to load node details for drawer:', error);
+ contentContainer.innerHTML = `
+
+ Error loading node details:
+ ${this.escapeHtml(error.message)}
+
+ `;
+ });
});
-
- // Open drawer
- this.detailsDrawer.classList.add('open');
- this.detailsDrawerBackdrop.classList.add('visible');
}
closeDrawer() {
- if (this.detailsDrawer) this.detailsDrawer.classList.remove('open');
- if (this.detailsDrawerBackdrop) this.detailsDrawerBackdrop.classList.remove('visible');
+ this.drawer.closeDrawer();
}
mount() {
diff --git a/public/scripts/components/ComponentsLoader.js b/public/scripts/components/ComponentsLoader.js
index 4e8b580..a9d7230 100644
--- a/public/scripts/components/ComponentsLoader.js
+++ b/public/scripts/components/ComponentsLoader.js
@@ -1,7 +1,7 @@
(function(){
// Simple readiness flag once all component constructors are present
function allReady(){
- return !!(window.PrimaryNodeComponent && window.ClusterMembersComponent && window.NodeDetailsComponent && window.FirmwareComponent && window.ClusterViewComponent && window.FirmwareViewComponent && window.TopologyGraphComponent && window.MemberCardOverlayComponent && window.ClusterStatusComponent);
+ return !!(window.PrimaryNodeComponent && window.ClusterMembersComponent && window.NodeDetailsComponent && window.FirmwareComponent && window.ClusterViewComponent && window.FirmwareViewComponent && window.TopologyGraphComponent && window.MemberCardOverlayComponent && window.ClusterStatusComponent && window.DrawerComponent);
}
window.waitForComponentsReady = function(timeoutMs = 5000){
return new Promise((resolve, reject) => {
diff --git a/public/scripts/components/DrawerComponent.js b/public/scripts/components/DrawerComponent.js
new file mode 100644
index 0000000..3f8930f
--- /dev/null
+++ b/public/scripts/components/DrawerComponent.js
@@ -0,0 +1,130 @@
+// Reusable Drawer Component for desktop slide-in panels
+class DrawerComponent {
+ constructor() {
+ this.detailsDrawer = null;
+ this.detailsDrawerContent = null;
+ this.detailsDrawerBackdrop = null;
+ this.activeDrawerComponent = null;
+ }
+
+ // Determine if we should use desktop drawer behavior
+ isDesktop() {
+ try {
+ return window && window.innerWidth >= 1024; // desktop threshold
+ } catch (_) {
+ return false;
+ }
+ }
+
+ ensureDrawer() {
+ if (this.detailsDrawer) return;
+
+ // Create backdrop
+ this.detailsDrawerBackdrop = document.createElement('div');
+ this.detailsDrawerBackdrop.className = 'details-drawer-backdrop';
+ document.body.appendChild(this.detailsDrawerBackdrop);
+
+ // Create drawer
+ this.detailsDrawer = document.createElement('div');
+ this.detailsDrawer.className = 'details-drawer';
+
+ // Header with close button
+ const header = document.createElement('div');
+ header.className = 'details-drawer-header';
+ header.innerHTML = `
+ Node Details
+
+ `;
+ this.detailsDrawer.appendChild(header);
+
+ // Content container
+ this.detailsDrawerContent = document.createElement('div');
+ this.detailsDrawerContent.className = 'details-drawer-content';
+ this.detailsDrawer.appendChild(this.detailsDrawerContent);
+
+ document.body.appendChild(this.detailsDrawer);
+
+ // Close handlers
+ const close = () => this.closeDrawer();
+ header.querySelector('.drawer-close').addEventListener('click', close);
+ this.detailsDrawerBackdrop.addEventListener('click', close);
+ document.addEventListener('keydown', (e) => {
+ if (e.key === 'Escape') close();
+ });
+ }
+
+ openDrawer(title, contentCallback, errorCallback) {
+ this.ensureDrawer();
+
+ // Set drawer title
+ const titleEl = this.detailsDrawer.querySelector('.drawer-title');
+ if (titleEl) {
+ titleEl.textContent = title;
+ }
+
+ // Clear previous component if any
+ if (this.activeDrawerComponent && typeof this.activeDrawerComponent.unmount === 'function') {
+ try {
+ this.activeDrawerComponent.unmount();
+ } catch (_) {}
+ }
+ this.detailsDrawerContent.innerHTML = 'Loading detailed information...
';
+
+ // Execute content callback
+ try {
+ contentCallback(this.detailsDrawerContent, (component) => {
+ this.activeDrawerComponent = component;
+ });
+ } catch (error) {
+ logger.error('Failed to load drawer content:', error);
+ if (errorCallback) {
+ errorCallback(error);
+ } else {
+ this.detailsDrawerContent.innerHTML = `
+
+ Error loading content:
+ ${this.escapeHtml ? this.escapeHtml(error.message) : error.message}
+
+ `;
+ }
+ }
+
+ // Open drawer
+ this.detailsDrawer.classList.add('open');
+ this.detailsDrawerBackdrop.classList.add('visible');
+ }
+
+ closeDrawer() {
+ if (this.detailsDrawer) this.detailsDrawer.classList.remove('open');
+ if (this.detailsDrawerBackdrop) this.detailsDrawerBackdrop.classList.remove('visible');
+ }
+
+ // Clean up drawer elements
+ destroy() {
+ if (this.detailsDrawer && this.detailsDrawer.parentNode) {
+ this.detailsDrawer.parentNode.removeChild(this.detailsDrawer);
+ }
+ if (this.detailsDrawerBackdrop && this.detailsDrawerBackdrop.parentNode) {
+ this.detailsDrawerBackdrop.parentNode.removeChild(this.detailsDrawerBackdrop);
+ }
+
+ this.detailsDrawer = null;
+ this.detailsDrawerContent = null;
+ this.detailsDrawerBackdrop = null;
+ this.activeDrawerComponent = null;
+ }
+
+ // Helper method for HTML escaping (can be overridden)
+ escapeHtml(text) {
+ if (typeof text !== 'string') return text;
+ const div = document.createElement('div');
+ div.textContent = text;
+ return div.innerHTML;
+ }
+}
+
+window.DrawerComponent = DrawerComponent;
diff --git a/public/scripts/components/TopologyGraphComponent.js b/public/scripts/components/TopologyGraphComponent.js
index 2c9a3b3..0f33c29 100644
--- a/public/scripts/components/TopologyGraphComponent.js
+++ b/public/scripts/components/TopologyGraphComponent.js
@@ -11,10 +11,7 @@ class TopologyGraphComponent extends Component {
this.isInitialized = false;
// Drawer state for desktop reuse (same pattern as ClusterMembersComponent)
- this.detailsDrawer = null;
- this.detailsDrawerContent = null;
- this.detailsDrawerBackdrop = null;
- this.activeDrawerComponent = null;
+ this.drawer = new DrawerComponent();
// Tooltip for labels on hover
this.tooltipEl = null;
@@ -22,52 +19,16 @@ class TopologyGraphComponent extends Component {
// Determine desktop threshold
isDesktop() {
- try { return window && window.innerWidth >= 1024; } catch (_) { return false; }
+ return this.drawer.isDesktop();
}
- ensureDrawer() {
- if (this.detailsDrawer) return;
- // Backdrop
- this.detailsDrawerBackdrop = document.createElement('div');
- this.detailsDrawerBackdrop.className = 'details-drawer-backdrop';
- document.body.appendChild(this.detailsDrawerBackdrop);
-
- // Drawer
- this.detailsDrawer = document.createElement('div');
- this.detailsDrawer.className = 'details-drawer';
-
- const header = document.createElement('div');
- header.className = 'details-drawer-header';
- header.innerHTML = `
- Node Details
-
- `;
- this.detailsDrawer.appendChild(header);
-
- this.detailsDrawerContent = document.createElement('div');
- this.detailsDrawerContent.className = 'details-drawer-content';
- this.detailsDrawer.appendChild(this.detailsDrawerContent);
-
- document.body.appendChild(this.detailsDrawer);
-
- const close = () => this.closeDrawer();
- header.querySelector('.drawer-close').addEventListener('click', close);
- this.detailsDrawerBackdrop.addEventListener('click', close);
- document.addEventListener('keydown', (e) => { if (e.key === 'Escape') close(); });
- }
openDrawerForNode(nodeData) {
- this.ensureDrawer();
-
- // Title from hostname and IP
+ // Get display name for drawer title
+ let displayName = 'Node Details';
try {
const hostname = nodeData.hostname || '';
const ip = nodeData.ip || '';
- let displayName = 'Node Details';
if (hostname && ip) {
displayName = `${hostname} - ${ip}`;
@@ -76,43 +37,32 @@ class TopologyGraphComponent extends Component {
} else if (ip) {
displayName = ip;
}
-
- const titleEl = this.detailsDrawer.querySelector('.drawer-title');
- if (titleEl) titleEl.textContent = displayName;
} catch (_) {}
- // Clear previous component
- if (this.activeDrawerComponent && typeof this.activeDrawerComponent.unmount === 'function') {
- try { this.activeDrawerComponent.unmount(); } catch (_) {}
- }
- this.detailsDrawerContent.innerHTML = 'Loading detailed information...
';
+ // Open drawer with content callback
+ this.drawer.openDrawer(displayName, (contentContainer, setActiveComponent) => {
+ // Mount NodeDetailsComponent
+ const nodeDetailsVM = new NodeDetailsViewModel();
+ const nodeDetailsComponent = new NodeDetailsComponent(contentContainer, nodeDetailsVM, this.eventBus);
+ setActiveComponent(nodeDetailsComponent);
- // Mount NodeDetailsComponent
- const nodeDetailsVM = new NodeDetailsViewModel();
- const nodeDetailsComponent = new NodeDetailsComponent(this.detailsDrawerContent, nodeDetailsVM, this.eventBus);
- this.activeDrawerComponent = nodeDetailsComponent;
-
- const ip = nodeData.ip || nodeData.id;
- nodeDetailsVM.loadNodeDetails(ip).then(() => {
- nodeDetailsComponent.mount();
- }).catch((error) => {
- logger.error('Failed to load node details (topology drawer):', error);
- this.detailsDrawerContent.innerHTML = `
-
- Error loading node details:
- ${this.escapeHtml(error.message)}
-
- `;
+ const ip = nodeData.ip || nodeData.id;
+ nodeDetailsVM.loadNodeDetails(ip).then(() => {
+ nodeDetailsComponent.mount();
+ }).catch((error) => {
+ logger.error('Failed to load node details (topology drawer):', error);
+ contentContainer.innerHTML = `
+
+ Error loading node details:
+ ${this.escapeHtml(error.message)}
+
+ `;
+ });
});
-
- // Open
- this.detailsDrawer.classList.add('open');
- this.detailsDrawerBackdrop.classList.add('visible');
}
closeDrawer() {
- if (this.detailsDrawer) this.detailsDrawer.classList.remove('open');
- if (this.detailsDrawerBackdrop) this.detailsDrawerBackdrop.classList.remove('visible');
+ this.drawer.closeDrawer();
}
// Tooltip helpers