feat: DrawerComonent
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
130
public/scripts/components/DrawerComponent.js
Normal file
130
public/scripts/components/DrawerComponent.js
Normal 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;
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user