feat: add member details overlay to topology

This commit is contained in:
2025-08-30 15:18:50 +02:00
parent f28b4f8797
commit 7bac42c58e
4 changed files with 537 additions and 1 deletions

View File

@@ -2579,10 +2579,13 @@ class TopologyGraphComponent extends Component {
this.simulation.alpha(0.3).restart(); this.simulation.alpha(0.3).restart();
} }
// Add click handlers for node selection // Add click handlers for node selection and member card overlay
node.on('click', (event, d) => { node.on('click', (event, d) => {
this.viewModel.selectNode(d.id); this.viewModel.selectNode(d.id);
this.updateSelection(d.id); this.updateSelection(d.id);
// Show member card overlay
this.showMemberCardOverlay(d);
}); });
// Add hover effects // Add hover effects
@@ -2820,6 +2823,52 @@ class TopologyGraphComponent extends Component {
container.innerHTML = '<div class="no-data"><div>No cluster members found</div></div>'; container.innerHTML = '<div class="no-data"><div>No cluster members found</div></div>';
} }
showMemberCardOverlay(nodeData) {
// Create overlay container if it doesn't exist
let overlayContainer = document.getElementById('member-card-overlay');
if (!overlayContainer) {
overlayContainer = document.createElement('div');
overlayContainer.id = 'member-card-overlay';
overlayContainer.className = 'member-card-overlay';
document.body.appendChild(overlayContainer);
}
// Create and show the overlay component
if (!this.memberOverlayComponent) {
const overlayVM = new ViewModel();
this.memberOverlayComponent = new MemberCardOverlayComponent(overlayContainer, overlayVM, this.eventBus);
this.memberOverlayComponent.mount();
}
// Convert node data to member data format
const memberData = {
ip: nodeData.ip,
hostname: nodeData.hostname,
status: this.normalizeStatus(nodeData.status),
latency: nodeData.latency,
labels: nodeData.resources || {}
};
this.memberOverlayComponent.show(memberData);
}
// Normalize status from topology format to member card format
normalizeStatus(status) {
if (!status) return 'unknown';
const normalized = status.toLowerCase();
switch (normalized) {
case 'active':
return 'active';
case 'inactive':
return 'inactive';
case 'dead':
return 'offline';
default:
return 'unknown';
}
}
// Override render method to display the graph // Override render method to display the graph
render() { render() {
console.log('TopologyGraphComponent: render called'); console.log('TopologyGraphComponent: render called');
@@ -2840,3 +2889,174 @@ class TopologyGraphComponent extends Component {
} }
// Member Card Overlay Component for displaying member details in topology view
class MemberCardOverlayComponent extends Component {
constructor(container, viewModel, eventBus) {
super(container, viewModel, eventBus);
this.isVisible = false;
this.currentMember = null;
}
mount() {
super.mount();
this.setupEventListeners();
}
setupEventListeners() {
// Close overlay when clicking outside or pressing escape
this.addEventListener(document, 'click', (e) => {
if (this.isVisible && !this.container.contains(e.target)) {
this.hide();
}
});
this.addEventListener(document, 'keydown', (e) => {
if (e.key === 'Escape' && this.isVisible) {
this.hide();
}
});
}
show(memberData) {
this.currentMember = memberData;
this.isVisible = true;
const memberCardHTML = this.renderMemberCard(memberData);
this.setHTML('', memberCardHTML);
// Add visible class for animation
setTimeout(() => {
this.container.classList.add('visible');
}, 10);
// Setup member card interactions
this.setupMemberCardInteractions();
}
hide() {
this.isVisible = false;
this.container.classList.remove('visible');
this.currentMember = null;
}
renderMemberCard(member) {
const statusClass = member.status === 'active' ? 'status-online' :
member.status === 'inactive' ? 'status-inactive' : 'status-offline';
const statusText = member.status === 'active' ? 'Online' :
member.status === 'inactive' ? 'Inactive' :
member.status === 'offline' ? 'Offline' : 'Unknown';
const statusIcon = member.status === 'active' ? '🟢' :
member.status === 'inactive' ? '🟠' : '🔴';
return `
<div class="member-overlay-content">
<div class="member-overlay-header">
<div class="member-overlay-title">
<h3>Member Details</h3>
</div>
<button class="member-overlay-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>
</div>
<div class="member-overlay-body">
<div class="member-card expanded" data-member-ip="${member.ip}">
<div class="member-header">
<div class="member-info">
<div class="member-name">${member.hostname || 'Unknown Device'}</div>
<div class="member-ip">${member.ip || 'No IP'}</div>
<div class="member-status ${statusClass}">
${statusIcon} ${statusText}
</div>
<div class="member-latency">
<span class="latency-label">Latency:</span>
<span class="latency-value">${member.latency ? member.latency + 'ms' : 'N/A'}</span>
</div>
${member.labels && Object.keys(member.labels).length ? `
<div class="member-labels">
${Object.entries(member.labels).map(([key, value]) => `<span class="label-chip">${key}: ${value}</span>`).join('')}
</div>
` : ''}
</div>
</div>
<div class="member-details">
<div class="loading-details">Loading detailed information...</div>
</div>
</div>
</div>
</div>
`;
}
setupMemberCardInteractions() {
// Close button
const closeBtn = this.findElement('.member-overlay-close');
if (closeBtn) {
this.addEventListener(closeBtn, 'click', () => {
this.hide();
});
}
// Setup member card expansion - automatically expand when shown
setTimeout(async () => {
const memberCard = this.findElement('.member-card');
if (memberCard) {
const memberDetails = memberCard.querySelector('.member-details');
const memberIp = memberCard.dataset.memberIp;
// Automatically expand the card to show details
await this.expandCard(memberCard, memberIp, memberDetails);
}
}, 100);
}
async expandCard(card, memberIp, memberDetails) {
try {
// Create node details view model and component
const nodeDetailsVM = new NodeDetailsViewModel();
const nodeDetailsComponent = new NodeDetailsComponent(memberDetails, nodeDetailsVM, this.eventBus);
// Load node details
await nodeDetailsVM.loadNodeDetails(memberIp);
// Mount the component
nodeDetailsComponent.mount();
// Update UI
card.classList.add('expanded');
const expandIcon = card.querySelector('.expand-icon');
if (expandIcon) {
expandIcon.classList.add('expanded');
}
} catch (error) {
console.error('Failed to expand card:', error);
memberDetails.innerHTML = `
<div class="error">
<strong>Error loading node details:</strong><br>
${error.message}
</div>
`;
}
}
collapseCard(card, expandIcon) {
card.classList.remove('expanded');
if (expandIcon) {
expandIcon.classList.remove('expanded');
}
// Reset member details to loading state
const memberDetails = card.querySelector('.member-details');
if (memberDetails) {
memberDetails.innerHTML = '<div class="loading-details">Loading detailed information...</div>';
}
}
}

View File

@@ -2383,6 +2383,201 @@ p {
height: 100%; height: 100%;
} }
/* Member Card Overlay Styles */
.member-card-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
opacity: 0;
visibility: hidden;
transition: all 0.3s ease;
}
.member-card-overlay.visible {
opacity: 1;
visibility: visible;
}
.member-overlay-content {
background: linear-gradient(135deg, #1c2a38 0%, #283746 50%, #1a252f 100%);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 16px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.5);
max-width: 800px;
width: 90%;
max-height: 90vh;
overflow: hidden;
transform: scale(0.9) translateY(20px);
transition: all 0.3s ease;
}
.member-card-overlay.visible .member-overlay-content {
transform: scale(1) translateY(0);
}
.member-overlay-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 24px 24px 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.member-overlay-title h3 {
margin: 0 0 8px 0;
font-size: 1.5rem;
font-weight: 600;
color: #ecf0f1;
}
.member-overlay-subtitle {
font-size: 1rem;
color: rgba(255, 255, 255, 0.7);
font-family: 'Courier New', monospace;
}
.member-overlay-close {
background: none;
border: none;
color: rgba(255, 255, 255, 0.6);
cursor: pointer;
padding: 8px;
border-radius: 8px;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
}
.member-overlay-close:hover {
background: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.9);
}
.member-overlay-close svg {
width: 20px;
height: 20px;
}
.member-overlay-body {
padding: 24px;
}
.member-overlay-section {
margin-bottom: 24px;
}
/* Member card container within overlay */
.member-overlay-body {
padding: 0;
overflow: auto;
max-height: calc(90vh - 120px); /* Account for header */
}
/* Ensure member cards render properly in overlay */
.member-overlay-body .member-card {
margin: 0;
border-radius: 0;
border: none;
box-shadow: none;
background: transparent;
}
.member-overlay-body .member-card .member-header {
padding: 20px 24px 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.member-overlay-body .member-card .member-details {
padding: 20px 24px;
}
/* Hide expand icon in overlay since card is always expanded */
.member-overlay-body .member-card .expand-icon {
display: none;
}
/* Ensure expanded state is visually clear */
.member-overlay-body .member-card.expanded .member-details {
display: block;
}
/* Highlight animation for member cards */
.member-card.highlighted {
animation: highlight-pulse 2s ease-in-out;
}
@keyframes highlight-pulse {
0%, 100% {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
}
50% {
box-shadow: 0 8px 32px rgba(59, 130, 246, 0.4), 0 4px 16px rgba(0, 0, 0, 0.3);
}
}
/* Responsive design for overlay */
@media (max-width: 768px) {
.member-overlay-content {
width: 95%;
max-width: none;
margin: 20px;
}
.member-overlay-header {
padding: 20px 20px 12px;
}
.member-overlay-body {
padding: 20px;
}
.member-overlay-actions {
flex-direction: column;
}
.member-overlay-title h3 {
font-size: 1.3rem;
}
}
/* Test page styles */
.test-section {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
padding: 24px;
margin: 24px 0;
}
.test-section h2 {
color: #ecf0f1;
margin-top: 0;
}
#test-overlay-btn {
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
color: white;
border: none;
padding: 12px 24px;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
#test-overlay-btn:hover {
background: linear-gradient(135deg, #2563eb 0%, #1e40af 100%);
transform: translateY(-1px);
}
/* Loading and error states */ /* Loading and error states */
.loading, .error, .no-data { .loading, .error, .no-data {
display: flex; display: flex;

View File

@@ -0,0 +1,58 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Test Member Card Overlay</title>
<link rel="stylesheet" href="styles.css">
<script src="./d3.v7.min.js"></script>
</head>
<body>
<div class="container">
<h1>Test Member Card Overlay</h1>
<div class="test-section">
<h2>Test Data</h2>
<button id="test-overlay-btn">Show Test Member Overlay</button>
</div>
<div id="member-card-overlay" class="member-card-overlay"></div>
</div>
<script src="./framework.js"></script>
<script src="./components.js"></script>
<script>
// Test the member card overlay
document.addEventListener('DOMContentLoaded', function() {
const testBtn = document.getElementById('test-overlay-btn');
const overlayContainer = document.getElementById('member-card-overlay');
// Create test view model and event bus
const testVM = new ViewModel();
const testEventBus = new EventBus();
// Create the overlay component
const overlayComponent = new MemberCardOverlayComponent(overlayContainer, testVM, testEventBus);
overlayComponent.mount();
// Test data
const testMember = {
ip: '192.168.1.100',
hostname: 'test-node-01',
status: 'active',
latency: 15,
labels: {
'environment': 'production',
'region': 'us-west',
'role': 'worker'
}
};
// Show overlay on button click
testBtn.addEventListener('click', function() {
overlayComponent.show(testMember);
});
});
</script>
</body>
</html>

View File

@@ -0,0 +1,63 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Test Topology with Member Card Overlay</title>
<link rel="stylesheet" href="styles.css">
<script src="./d3.v7.min.js"></script>
</head>
<body>
<div class="container">
<h1>Test Topology with Member Card Overlay</h1>
<div class="test-section">
<h2>Topology View with Clickable Nodes</h2>
<p>Click on any node in the topology to see the member card overlay.</p>
<button id="refresh-topology-btn">Refresh Topology</button>
</div>
<div id="topology-graph-container">
<div class="loading">
<div>Loading network topology...</div>
</div>
</div>
<div id="member-card-overlay" class="member-card-overlay"></div>
</div>
<script src="./demo-topology-data.js"></script>
<script src="./framework.js"></script>
<script src="./components.js"></script>
<script>
// Test the topology view with member card overlay
document.addEventListener('DOMContentLoaded', function() {
console.log('Setting up test topology with member card overlay...');
// Override the API client with demo data
window.apiClient = window.demoApiClient;
// Create topology view model
const topologyVM = new TopologyViewModel();
// Create the topology component
const topologyComponent = new TopologyGraphComponent(
document.getElementById('topology-graph-container'),
topologyVM,
new EventBus()
);
// Mount the component
topologyComponent.mount();
// Refresh button
const refreshBtn = document.getElementById('refresh-topology-btn');
refreshBtn.addEventListener('click', function() {
topologyVM.updateNetworkTopology();
});
console.log('Test topology setup complete');
});
</script>
</body>
</html>