feat: DrawerComonent

This commit is contained in:
2025-09-16 14:35:09 +02:00
parent f9dc811239
commit 27f93959ff
5 changed files with 177 additions and 157 deletions

View File

@@ -146,6 +146,7 @@
<script src="./scripts/api-client.js"></script> <script src="./scripts/api-client.js"></script>
<script src="./scripts/view-models.js"></script> <script src="./scripts/view-models.js"></script>
<!-- Base/leaf components first --> <!-- Base/leaf components first -->
<script src="./scripts/components/DrawerComponent.js"></script>
<script src="./scripts/components/PrimaryNodeComponent.js"></script> <script src="./scripts/components/PrimaryNodeComponent.js"></script>
<script src="./scripts/components/NodeDetailsComponent.js"></script> <script src="./scripts/components/NodeDetailsComponent.js"></script>
<script src="./scripts/components/ClusterMembersComponent.js"></script> <script src="./scripts/components/ClusterMembersComponent.js"></script>

View File

@@ -21,71 +21,23 @@ class ClusterMembersComponent extends Component {
}, 200); }, 200);
// Drawer state for desktop // Drawer state for desktop
this.detailsDrawer = null; this.drawer = new DrawerComponent();
this.detailsDrawerContent = null;
this.detailsDrawerBackdrop = null;
this.activeDrawerComponent = null;
} }
// Determine if we should use desktop drawer behavior // Determine if we should use desktop drawer behavior
isDesktop() { isDesktop() {
try { return this.drawer.isDesktop();
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 = `
<div class="drawer-title">Node Details</div>
<button class="drawer-close" aria-label="Close">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 6L6 18M6 6l12 12"/>
</svg>
</button>
`;
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) { openDrawerForMember(memberIp) {
this.ensureDrawer(); // Get display name for drawer title
let displayName = memberIp;
// Set drawer title to member name (hostname) and IP
try { try {
const members = (this.viewModel && typeof this.viewModel.get === 'function') ? this.viewModel.get('members') : []; 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 member = Array.isArray(members) ? members.find(m => m && m.ip === memberIp) : null;
const hostname = (member && member.hostname) ? member.hostname : ''; const hostname = (member && member.hostname) ? member.hostname : '';
const ip = (member && member.ip) ? member.ip : memberIp; const ip = (member && member.ip) ? member.ip : memberIp;
let displayName = memberIp;
if (hostname && ip) { if (hostname && ip) {
displayName = `${hostname} - ${ip}`; displayName = `${hostname} - ${ip}`;
@@ -94,46 +46,33 @@ class ClusterMembersComponent extends Component {
} else if (ip) { } else if (ip) {
displayName = ip; displayName = ip;
} }
const titleEl = this.detailsDrawer.querySelector('.drawer-title');
if (titleEl) {
titleEl.textContent = displayName;
}
} catch (_) { } catch (_) {
// no-op if anything goes wrong, default title remains // no-op if anything goes wrong, default title remains
} }
// Clear previous component if any // Open drawer with content callback
if (this.activeDrawerComponent && typeof this.activeDrawerComponent.unmount === 'function') { this.drawer.openDrawer(displayName, (contentContainer, setActiveComponent) => {
try { this.activeDrawerComponent.unmount(); } catch (_) {} // Load and mount NodeDetails into drawer
} const nodeDetailsVM = new NodeDetailsViewModel();
this.detailsDrawerContent.innerHTML = '<div class="loading-details">Loading detailed information...</div>'; const nodeDetailsComponent = new NodeDetailsComponent(contentContainer, nodeDetailsVM, this.eventBus);
setActiveComponent(nodeDetailsComponent);
// Load and mount NodeDetails into drawer nodeDetailsVM.loadNodeDetails(memberIp).then(() => {
const nodeDetailsVM = new NodeDetailsViewModel(); nodeDetailsComponent.mount();
const nodeDetailsComponent = new NodeDetailsComponent(this.detailsDrawerContent, nodeDetailsVM, this.eventBus); }).catch((error) => {
this.activeDrawerComponent = nodeDetailsComponent; logger.error('Failed to load node details for drawer:', error);
contentContainer.innerHTML = `
nodeDetailsVM.loadNodeDetails(memberIp).then(() => { <div class="error">
nodeDetailsComponent.mount(); <strong>Error loading node details:</strong><br>
}).catch((error) => { ${this.escapeHtml(error.message)}
logger.error('Failed to load node details for drawer:', error); </div>
this.detailsDrawerContent.innerHTML = ` `;
<div class="error"> });
<strong>Error loading node details:</strong><br>
${this.escapeHtml(error.message)}
</div>
`;
}); });
// Open drawer
this.detailsDrawer.classList.add('open');
this.detailsDrawerBackdrop.classList.add('visible');
} }
closeDrawer() { closeDrawer() {
if (this.detailsDrawer) this.detailsDrawer.classList.remove('open'); this.drawer.closeDrawer();
if (this.detailsDrawerBackdrop) this.detailsDrawerBackdrop.classList.remove('visible');
} }
mount() { mount() {

View File

@@ -1,7 +1,7 @@
(function(){ (function(){
// Simple readiness flag once all component constructors are present // Simple readiness flag once all component constructors are present
function allReady(){ 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){ window.waitForComponentsReady = function(timeoutMs = 5000){
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {

View File

@@ -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 = `
<div class="drawer-title">Node Details</div>
<button class="drawer-close" aria-label="Close">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 6L6 18M6 6l12 12"/>
</svg>
</button>
`;
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 = '<div class="loading-details">Loading detailed information...</div>';
// 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 = `
<div class="error">
<strong>Error loading content:</strong><br>
${this.escapeHtml ? this.escapeHtml(error.message) : error.message}
</div>
`;
}
}
// 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;

View File

@@ -11,10 +11,7 @@ class TopologyGraphComponent extends Component {
this.isInitialized = false; this.isInitialized = false;
// Drawer state for desktop reuse (same pattern as ClusterMembersComponent) // Drawer state for desktop reuse (same pattern as ClusterMembersComponent)
this.detailsDrawer = null; this.drawer = new DrawerComponent();
this.detailsDrawerContent = null;
this.detailsDrawerBackdrop = null;
this.activeDrawerComponent = null;
// Tooltip for labels on hover // Tooltip for labels on hover
this.tooltipEl = null; this.tooltipEl = null;
@@ -22,52 +19,16 @@ class TopologyGraphComponent extends Component {
// Determine desktop threshold // Determine desktop threshold
isDesktop() { 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 = `
<div class="drawer-title">Node Details</div>
<button class="drawer-close" aria-label="Close">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 6L6 18M6 6l12 12"/>
</svg>
</button>
`;
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) { openDrawerForNode(nodeData) {
this.ensureDrawer(); // Get display name for drawer title
let displayName = 'Node Details';
// Title from hostname and IP
try { try {
const hostname = nodeData.hostname || ''; const hostname = nodeData.hostname || '';
const ip = nodeData.ip || ''; const ip = nodeData.ip || '';
let displayName = 'Node Details';
if (hostname && ip) { if (hostname && ip) {
displayName = `${hostname} - ${ip}`; displayName = `${hostname} - ${ip}`;
@@ -76,43 +37,32 @@ class TopologyGraphComponent extends Component {
} else if (ip) { } else if (ip) {
displayName = ip; displayName = ip;
} }
const titleEl = this.detailsDrawer.querySelector('.drawer-title');
if (titleEl) titleEl.textContent = displayName;
} catch (_) {} } catch (_) {}
// Clear previous component // Open drawer with content callback
if (this.activeDrawerComponent && typeof this.activeDrawerComponent.unmount === 'function') { this.drawer.openDrawer(displayName, (contentContainer, setActiveComponent) => {
try { this.activeDrawerComponent.unmount(); } catch (_) {} // Mount NodeDetailsComponent
} const nodeDetailsVM = new NodeDetailsViewModel();
this.detailsDrawerContent.innerHTML = '<div class="loading-details">Loading detailed information...</div>'; const nodeDetailsComponent = new NodeDetailsComponent(contentContainer, nodeDetailsVM, this.eventBus);
setActiveComponent(nodeDetailsComponent);
// Mount NodeDetailsComponent const ip = nodeData.ip || nodeData.id;
const nodeDetailsVM = new NodeDetailsViewModel(); nodeDetailsVM.loadNodeDetails(ip).then(() => {
const nodeDetailsComponent = new NodeDetailsComponent(this.detailsDrawerContent, nodeDetailsVM, this.eventBus); nodeDetailsComponent.mount();
this.activeDrawerComponent = nodeDetailsComponent; }).catch((error) => {
logger.error('Failed to load node details (topology drawer):', error);
const ip = nodeData.ip || nodeData.id; contentContainer.innerHTML = `
nodeDetailsVM.loadNodeDetails(ip).then(() => { <div class="error">
nodeDetailsComponent.mount(); <strong>Error loading node details:</strong><br>
}).catch((error) => { ${this.escapeHtml(error.message)}
logger.error('Failed to load node details (topology drawer):', error); </div>
this.detailsDrawerContent.innerHTML = ` `;
<div class="error"> });
<strong>Error loading node details:</strong><br>
${this.escapeHtml(error.message)}
</div>
`;
}); });
// Open
this.detailsDrawer.classList.add('open');
this.detailsDrawerBackdrop.classList.add('visible');
} }
closeDrawer() { closeDrawer() {
if (this.detailsDrawer) this.detailsDrawer.classList.remove('open'); this.drawer.closeDrawer();
if (this.detailsDrawerBackdrop) this.detailsDrawerBackdrop.classList.remove('visible');
} }
// Tooltip helpers // Tooltip helpers