feat: add topology view

This commit is contained in:
2025-08-30 13:06:44 +02:00
parent c1b92b3fef
commit f28b4f8797
18 changed files with 2903 additions and 6 deletions

View File

@@ -45,6 +45,17 @@ class ApiClient {
}
}
async getClusterMembersFromNode(ip) {
try {
return await this.request(`/api/cluster/members`, {
method: 'GET',
query: { ip: ip }
});
} catch (error) {
throw new Error(`Request failed: ${error.message}`);
}
}
async getDiscoveryInfo() {
try {
return await this.request('/api/discovery/nodes', { method: 'GET' });

View File

@@ -12,7 +12,8 @@ document.addEventListener('DOMContentLoaded', function() {
console.log('App: Creating view models...');
const clusterViewModel = new ClusterViewModel();
const firmwareViewModel = new FirmwareViewModel();
console.log('App: View models created:', { clusterViewModel, firmwareViewModel });
const topologyViewModel = new TopologyViewModel();
console.log('App: View models created:', { clusterViewModel, firmwareViewModel, topologyViewModel });
// Connect firmware view model to cluster data
clusterViewModel.subscribe('members', (members) => {
@@ -35,6 +36,7 @@ document.addEventListener('DOMContentLoaded', function() {
// Register routes with their view models
console.log('App: Registering routes...');
app.registerRoute('cluster', ClusterViewComponent, 'cluster-view', clusterViewModel);
app.registerRoute('topology', TopologyGraphComponent, 'topology-view', topologyViewModel);
app.registerRoute('firmware', FirmwareViewComponent, 'firmware-view', firmwareViewModel);
console.log('App: Routes registered and components pre-initialized');

View File

@@ -2267,4 +2267,576 @@ class ClusterStatusComponent extends Component {
this.container.classList.add(statusClass);
}
}
}
}
// Topology Graph Component with D3.js force-directed visualization
class TopologyGraphComponent extends Component {
constructor(container, viewModel, eventBus) {
super(container, viewModel, eventBus);
console.log('TopologyGraphComponent: Constructor called');
this.svg = null;
this.simulation = null;
this.zoom = null;
this.width = 1400; // Increased from 1000 for more space
this.height = 1000; // Increased from 800 for more space
this.isInitialized = false;
}
// Override mount to ensure proper initialization
mount() {
if (this.isMounted) return;
console.log('TopologyGraphComponent: Starting mount...');
console.log('TopologyGraphComponent: isInitialized =', this.isInitialized);
// Call initialize if not already done
if (!this.isInitialized) {
console.log('TopologyGraphComponent: Initializing during mount...');
this.initialize().then(() => {
console.log('TopologyGraphComponent: Initialization completed, calling completeMount...');
// Complete mount after initialization
this.completeMount();
}).catch(error => {
console.error('TopologyGraphComponent: Initialization failed during mount:', error);
// Still complete mount to prevent blocking
this.completeMount();
});
} else {
console.log('TopologyGraphComponent: Already initialized, calling completeMount directly...');
this.completeMount();
}
}
completeMount() {
console.log('TopologyGraphComponent: completeMount called');
this.isMounted = true;
console.log('TopologyGraphComponent: Setting up event listeners...');
this.setupEventListeners();
console.log('TopologyGraphComponent: Setting up view model listeners...');
this.setupViewModelListeners();
console.log('TopologyGraphComponent: Calling render...');
this.render();
console.log('TopologyGraphComponent: Mounted successfully');
}
setupEventListeners() {
console.log('TopologyGraphComponent: setupEventListeners called');
console.log('TopologyGraphComponent: Container:', this.container);
console.log('TopologyGraphComponent: Container ID:', this.container?.id);
// Refresh button removed from HTML, so no need to set up event listeners
console.log('TopologyGraphComponent: No event listeners needed (refresh button removed)');
}
setupViewModelListeners() {
console.log('TopologyGraphComponent: setupViewModelListeners called');
console.log('TopologyGraphComponent: isInitialized =', this.isInitialized);
if (this.isInitialized) {
// Component is already initialized, set up subscriptions immediately
console.log('TopologyGraphComponent: Setting up property subscriptions immediately');
this.subscribeToProperty('nodes', this.renderGraph.bind(this));
this.subscribeToProperty('links', this.renderGraph.bind(this));
this.subscribeToProperty('isLoading', this.handleLoadingState.bind(this));
this.subscribeToProperty('error', this.handleError.bind(this));
this.subscribeToProperty('selectedNode', this.updateSelection.bind(this));
} else {
// Component not yet initialized, store for later
console.log('TopologyGraphComponent: Component not initialized, storing pending subscriptions');
this._pendingSubscriptions = [
['nodes', this.renderGraph.bind(this)],
['links', this.renderGraph.bind(this)],
['isLoading', this.handleLoadingState.bind(this)],
['error', this.handleError.bind(this)],
['selectedNode', this.updateSelection.bind(this)]
];
}
}
async initialize() {
console.log('TopologyGraphComponent: Initializing...');
// Wait for DOM to be ready
if (document.readyState === 'loading') {
await new Promise(resolve => {
document.addEventListener('DOMContentLoaded', resolve);
});
}
// Set up the SVG container
this.setupSVG();
// Mark as initialized
this.isInitialized = true;
// Now set up the actual property listeners after initialization
if (this._pendingSubscriptions) {
this._pendingSubscriptions.forEach(([property, callback]) => {
this.subscribeToProperty(property, callback);
});
this._pendingSubscriptions = null;
}
// Initial data load
await this.viewModel.updateNetworkTopology();
}
setupSVG() {
const container = this.findElement('#topology-graph-container');
if (!container) {
console.error('TopologyGraphComponent: Graph container not found');
return;
}
// Clear existing content
container.innerHTML = '';
// Create SVG element
this.svg = d3.select(container)
.append('svg')
.attr('width', this.width)
.attr('height', this.height)
.attr('viewBox', `0 0 ${this.width} ${this.height}`)
.style('border', '1px solid rgba(255, 255, 255, 0.1)')
.style('background', 'rgba(0, 0, 0, 0.2)')
.style('border-radius', '12px');
// Add zoom behavior
this.zoom = d3.zoom()
.scaleExtent([0.5, 5]) // Changed from [0.3, 4] to allow more zoom in and start more zoomed in
.on('zoom', (event) => {
this.svg.select('g').attr('transform', event.transform);
});
this.svg.call(this.zoom);
// Create main group for zoom and apply initial zoom
const mainGroup = this.svg.append('g');
// Apply initial zoom to show the graph more zoomed in
mainGroup.attr('transform', 'scale(1.4) translate(-200, -150)'); // Better centered positioning
console.log('TopologyGraphComponent: SVG setup completed');
}
// Ensure component is initialized
async ensureInitialized() {
if (!this.isInitialized) {
console.log('TopologyGraphComponent: Ensuring initialization...');
await this.initialize();
}
return this.isInitialized;
}
renderGraph() {
try {
// Check if component is initialized
if (!this.isInitialized) {
console.log('TopologyGraphComponent: Component not yet initialized, ensuring initialization...');
this.ensureInitialized().then(() => {
// Re-render after initialization
this.renderGraph();
}).catch(error => {
console.error('TopologyGraphComponent: Failed to initialize:', error);
});
return;
}
const nodes = this.viewModel.get('nodes');
const links = this.viewModel.get('links');
// Check if SVG is initialized
if (!this.svg) {
console.log('TopologyGraphComponent: SVG not initialized yet, setting up SVG first');
this.setupSVG();
}
if (!nodes || nodes.length === 0) {
this.showNoData();
return;
}
console.log('TopologyGraphComponent: Rendering graph with', nodes.length, 'nodes and', links.length, 'links');
// Get the main SVG group (the one created in setupSVG)
let svgGroup = this.svg.select('g');
if (!svgGroup || svgGroup.empty()) {
console.log('TopologyGraphComponent: Creating new SVG group');
svgGroup = this.svg.append('g');
// Apply initial zoom to show the graph more zoomed in
svgGroup.attr('transform', 'scale(1.4) translate(-200, -150)'); // Better centered positioning
}
// Clear existing graph elements but preserve the main group and its transform
svgGroup.selectAll('.graph-element').remove();
// Create links with better styling (no arrows needed)
const link = svgGroup.append('g')
.attr('class', 'graph-element')
.selectAll('line')
.data(links)
.enter().append('line')
.attr('stroke', d => this.getLinkColor(d.latency))
.attr('stroke-opacity', 0.7) // Reduced from 0.8 for subtlety
.attr('stroke-width', d => Math.max(1, Math.min(3, d.latency / 8))) // Much thinner: reduced from max 6 to max 3
.attr('marker-end', null); // Remove arrows
// Create nodes
const node = svgGroup.append('g')
.attr('class', 'graph-element')
.selectAll('g')
.data(nodes)
.enter().append('g')
.attr('class', 'node')
.call(this.drag(this.simulation));
// Add circles to nodes with size based on status
node.append('circle')
.attr('r', d => this.getNodeRadius(d.status))
.attr('fill', d => this.getNodeColor(d.status))
.attr('stroke', '#fff')
.attr('stroke-width', 2);
// Add status indicator
node.append('circle')
.attr('r', 3)
.attr('fill', d => this.getStatusIndicatorColor(d.status))
.attr('cx', -8)
.attr('cy', -8);
// Add labels to nodes
node.append('text')
.text(d => d.hostname.length > 15 ? d.hostname.substring(0, 15) + '...' : d.hostname)
.attr('x', 15)
.attr('y', 4)
.attr('font-size', '13px') // Increased from 12px for better readability
.attr('fill', '#ecf0f1') // Light text for dark theme
.attr('font-weight', '500');
// Add IP address labels
node.append('text')
.text(d => d.ip)
.attr('x', 15)
.attr('y', 20)
.attr('font-size', '11px') // Increased from 10px for better readability
.attr('fill', 'rgba(255, 255, 255, 0.7)'); // Semi-transparent white
// Add status labels
node.append('text')
.text(d => d.status)
.attr('x', 15)
.attr('y', 35)
.attr('font-size', '11px') // Increased from 10px for better readability
.attr('fill', d => this.getNodeColor(d.status))
.attr('font-weight', '600');
// Add latency labels on links with better positioning
const linkLabels = svgGroup.append('g')
.attr('class', 'graph-element')
.selectAll('text')
.data(links)
.enter().append('text')
.attr('font-size', '12px') // Increased from 11px for better readability
.attr('fill', '#ecf0f1') // Light text for dark theme
.attr('font-weight', '600') // Made slightly bolder for better readability
.attr('text-anchor', 'middle')
.style('text-shadow', '0 1px 2px rgba(0, 0, 0, 0.8)') // Add shadow for better contrast
.text(d => `${d.latency}ms`);
// Remove the background boxes for link labels - they look out of place
// Set up force simulation with better parameters (only if not already exists)
if (!this.simulation) {
this.simulation = d3.forceSimulation(nodes)
.force('link', d3.forceLink(links).id(d => d.id).distance(300)) // Increased from 200 for more spacing
.force('charge', d3.forceManyBody().strength(-800)) // Increased from -600 for stronger repulsion
.force('center', d3.forceCenter(this.width / 2, this.height / 2))
.force('collision', d3.forceCollide().radius(80)); // Increased from 60 for more separation
// Update positions on simulation tick
this.simulation.on('tick', () => {
link
.attr('x1', d => d.source.x)
.attr('y1', d => d.source.y)
.attr('x2', d => d.target.x)
.attr('y2', d => d.target.y);
// Update link labels
linkLabels
.attr('x', d => (d.source.x + d.target.x) / 2)
.attr('y', d => (d.source.y + d.target.y) / 2 - 5);
// Remove the background update code since we removed the backgrounds
node
.attr('transform', d => `translate(${d.x},${d.y})`);
});
} else {
// Update existing simulation with new data
this.simulation.nodes(nodes);
this.simulation.force('link').links(links);
this.simulation.alpha(0.3).restart();
}
// Add click handlers for node selection
node.on('click', (event, d) => {
this.viewModel.selectNode(d.id);
this.updateSelection(d.id);
});
// Add hover effects
node.on('mouseover', (event, d) => {
d3.select(event.currentTarget).select('circle')
.attr('r', d => this.getNodeRadius(d.status) + 4)
.attr('stroke-width', 3);
});
node.on('mouseout', (event, d) => {
d3.select(event.currentTarget).select('circle')
.attr('r', d => this.getNodeRadius(d.status))
.attr('stroke-width', 2);
});
// Add tooltip for links
link.on('mouseover', (event, d) => {
d3.select(event.currentTarget)
.attr('stroke-width', d => Math.max(2, Math.min(4, d.latency / 6))) // Reduced from max 10 to max 4
.attr('stroke-opacity', 0.9); // Reduced from 1 for subtlety
});
link.on('mouseout', (event, d) => {
d3.select(event.currentTarget)
.attr('stroke-width', d => Math.max(1, Math.min(3, d.latency / 8))) // Reduced from max 8 to max 3
.attr('stroke-opacity', 0.7); // Reduced from 0.7 for consistency
});
// Add legend
this.addLegend(svgGroup);
} catch (error) {
console.error('Failed to render graph:', error);
}
}
addLegend(svgGroup) {
const legend = svgGroup.append('g')
.attr('class', 'graph-element')
.attr('transform', `translate(120, 120)`) // Increased from (80, 80) for more space from edges
.style('opacity', '0'); // Hide the legend but keep it in the code
// Add background for better visibility
legend.append('rect')
.attr('width', 320) // Increased from 280 for more space
.attr('height', 120) // Increased from 100 for more space
.attr('fill', 'rgba(0, 0, 0, 0.7)')
.attr('rx', 8)
.attr('stroke', 'rgba(255, 255, 255, 0.2)')
.attr('stroke-width', 1);
// Node status legend
const nodeLegend = legend.append('g')
.attr('transform', 'translate(20, 20)'); // Increased from (15, 15) for more internal padding
nodeLegend.append('text')
.text('Node Status:')
.attr('x', 0)
.attr('y', 0)
.attr('font-size', '14px') // Increased from 13px for better readability
.attr('font-weight', '600')
.attr('fill', '#ecf0f1');
const statuses = [
{ status: 'ACTIVE', color: '#10b981', y: 20 },
{ status: 'INACTIVE', color: '#f59e0b', y: 40 },
{ status: 'DEAD', color: '#ef4444', y: 60 }
];
statuses.forEach(item => {
nodeLegend.append('circle')
.attr('r', 6)
.attr('cx', 0)
.attr('cy', item.y)
.attr('fill', item.color);
nodeLegend.append('text')
.text(item.status)
.attr('x', 15)
.attr('y', item.y + 4)
.attr('font-size', '12px') // Increased from 11px for better readability
.attr('fill', '#ecf0f1');
});
// Link latency legend
const linkLegend = legend.append('g')
.attr('transform', 'translate(150, 20)'); // Adjusted position for better spacing
linkLegend.append('text')
.text('Link Latency:')
.attr('x', 0)
.attr('y', 0)
.attr('font-size', '14px') // Increased from 13px for better readability
.attr('font-weight', '600')
.attr('fill', '#ecf0f1');
const latencies = [
{ range: '≤30ms', color: '#10b981', y: 20 },
{ range: '31-50ms', color: '#f59e0b', y: 40 },
{ range: '>50ms', color: '#ef4444', y: 60 }
];
latencies.forEach(item => {
linkLegend.append('line')
.attr('x1', 0)
.attr('y1', item.y)
.attr('x2', 20)
.attr('y2', item.y)
.attr('stroke', item.color)
.attr('stroke-width', 2); // Reduced from 3 to match the thinner graph lines
linkLegend.append('text')
.text(item.range)
.attr('x', 25)
.attr('y', item.y + 4)
.attr('font-size', '12px') // Increased from 11px for better readability
.attr('fill', '#ecf0f1');
});
}
getNodeRadius(status) {
switch (status?.toUpperCase()) {
case 'ACTIVE':
return 10;
case 'INACTIVE':
return 8;
case 'DEAD':
return 6;
default:
return 8;
}
}
getStatusIndicatorColor(status) {
switch (status?.toUpperCase()) {
case 'ACTIVE':
return '#10b981'; // Green
case 'INACTIVE':
return '#f59e0b'; // Orange
case 'DEAD':
return '#ef4444'; // Red
default:
return '#6b7280'; // Gray
}
}
getLinkColor(latency) {
if (latency <= 30) return '#10b981'; // Green for low latency (≤30ms)
if (latency <= 50) return '#f59e0b'; // Orange for medium latency (31-50ms)
return '#ef4444'; // Red for high latency (>50ms)
}
getNodeColor(status) {
switch (status?.toUpperCase()) {
case 'ACTIVE':
return '#10b981'; // Green
case 'INACTIVE':
return '#f59e0b'; // Orange
case 'DEAD':
return '#ef4444'; // Red
default:
return '#6b7280'; // Gray
}
}
drag(simulation) {
return d3.drag()
.on('start', function(event, d) {
if (!event.active && simulation && simulation.alphaTarget) {
simulation.alphaTarget(0.3).restart();
}
d.fx = d.x;
d.fy = d.y;
})
.on('drag', function(event, d) {
d.fx = event.x;
d.fy = event.y;
})
.on('end', function(event, d) {
if (!event.active && simulation && simulation.alphaTarget) {
simulation.alphaTarget(0);
}
d.fx = null;
d.fy = null;
});
}
updateSelection(selectedNodeId) {
// Update visual selection
if (!this.svg || !this.isInitialized) {
return;
}
this.svg.selectAll('.node').select('circle')
.attr('stroke-width', d => d.id === selectedNodeId ? 4 : 2)
.attr('stroke', d => d.id === selectedNodeId ? '#2196F3' : '#fff');
}
handleRefresh() {
console.log('TopologyGraphComponent: handleRefresh called');
if (!this.isInitialized) {
console.log('TopologyGraphComponent: Component not yet initialized, ensuring initialization...');
this.ensureInitialized().then(() => {
// Refresh after initialization
this.viewModel.updateNetworkTopology();
}).catch(error => {
console.error('TopologyGraphComponent: Failed to initialize for refresh:', error);
});
return;
}
console.log('TopologyGraphComponent: Calling updateNetworkTopology...');
this.viewModel.updateNetworkTopology();
}
handleLoadingState(isLoading) {
console.log('TopologyGraphComponent: handleLoadingState called with:', isLoading);
const container = this.findElement('#topology-graph-container');
if (isLoading) {
container.innerHTML = '<div class="loading"><div>Loading network topology...</div></div>';
}
}
handleError() {
const error = this.viewModel.get('error');
if (error) {
const container = this.findElement('#topology-graph-container');
container.innerHTML = `<div class="error"><div>Error: ${error}</div></div>`;
}
}
showNoData() {
const container = this.findElement('#topology-graph-container');
container.innerHTML = '<div class="no-data"><div>No cluster members found</div></div>';
}
// Override render method to display the graph
render() {
console.log('TopologyGraphComponent: render called');
if (!this.isInitialized) {
console.log('TopologyGraphComponent: Not initialized yet, skipping render');
return;
}
const nodes = this.viewModel.get('nodes');
const links = this.viewModel.get('links');
if (nodes && nodes.length > 0) {
console.log('TopologyGraphComponent: Rendering graph with data');
this.renderGraph();
} else {
console.log('TopologyGraphComponent: No data available, showing loading state');
this.handleLoadingState(true);
}
}
}

2
public/d3.v7.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,124 @@
// Demo data for testing the Members view without actual cluster data
window.demoMembersData = {
members: [
{
hostname: "spore-node-1",
ip: "192.168.1.100",
lastSeen: Date.now(),
latency: 3,
status: "ACTIVE",
resources: {
freeHeap: 48748,
chipId: 12345678,
sdkVersion: "3.1.2",
cpuFreqMHz: 80,
flashChipSize: 1048576
},
api: [
{ uri: "/api/node/status", method: "GET" },
{ uri: "/api/tasks/status", method: "GET" }
]
},
{
hostname: "spore-node-2",
ip: "192.168.1.101",
lastSeen: Date.now() - 5000,
latency: 8,
status: "ACTIVE",
resources: {
freeHeap: 52340,
chipId: 87654321,
sdkVersion: "3.1.2",
cpuFreqMHz: 80,
flashChipSize: 1048576
},
api: [
{ uri: "/api/node/status", method: "GET" },
{ uri: "/api/tasks/status", method: "GET" }
]
},
{
hostname: "spore-node-3",
ip: "192.168.1.102",
lastSeen: Date.now() - 15000,
latency: 12,
status: "INACTIVE",
resources: {
freeHeap: 38920,
chipId: 11223344,
sdkVersion: "3.1.1",
cpuFreqMHz: 80,
flashChipSize: 1048576
},
api: [
{ uri: "/api/node/status", method: "GET" }
]
},
{
hostname: "spore-node-4",
ip: "192.168.1.103",
lastSeen: Date.now() - 30000,
latency: 25,
status: "ACTIVE",
resources: {
freeHeap: 45678,
chipId: 55667788,
sdkVersion: "3.1.2",
cpuFreqMHz: 80,
flashChipSize: 1048576
},
api: [
{ uri: "/api/node/status", method: "GET" },
{ uri: "/api/tasks/status", method: "GET" },
{ uri: "/api/capabilities", method: "GET" }
]
},
{
hostname: "spore-node-5",
ip: "192.168.1.104",
lastSeen: Date.now() - 60000,
latency: 45,
status: "DEAD",
resources: {
freeHeap: 0,
chipId: 99887766,
sdkVersion: "3.1.0",
cpuFreqMHz: 0,
flashChipSize: 1048576
},
api: []
}
]
};
// Mock API client for demo purposes
window.demoApiClient = {
async getClusterMembers() {
// Simulate network delay
await new Promise(resolve => setTimeout(resolve, 500));
return window.demoMembersData;
},
async getClusterMembersFromNode(ip) {
// Simulate network delay
await new Promise(resolve => setTimeout(resolve, 200));
// Return a subset of members to simulate different node perspectives
const allMembers = window.demoMembersData.members;
const nodeIndex = allMembers.findIndex(m => m.ip === ip);
if (nodeIndex === -1) {
return { members: [] };
}
// Simulate each node seeing a different subset of the cluster
const startIndex = (nodeIndex * 2) % allMembers.length;
const members = [
allMembers[startIndex],
allMembers[(startIndex + 1) % allMembers.length],
allMembers[(startIndex + 2) % allMembers.length]
];
return { members: members.filter(m => m.ip !== ip) };
}
};

View File

@@ -0,0 +1,641 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Demo Members View - SPORE UI</title>
<script src="https://d3js.org/d3.v7.min.js"></script>
<script src="demo-members-data.js"></script>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div class="container">
<div class="main-navigation">
<div class="nav-left">
<button class="nav-tab" onclick="showCluster()">🌐 Cluster</button>
<button class="nav-tab active" onclick="showTopology()">🌐 Topology</button>
<button class="nav-tab" onclick="showFirmware()">📦 Firmware</button>
</div>
<div class="nav-right">
<div class="cluster-status">🚀 Demo Mode</div>
</div>
</div>
<div id="cluster-view" class="view-content">
<div class="cluster-section">
<h2>🌐 Cluster View (Demo)</h2>
<p>This is a demo of the cluster view. Switch to Members to see the network topology visualization.</p>
</div>
</div>
<div id="topology-view" class="view-content active">
<div class="topology-section">
<div class="topology-header">
<h2>🌐 Network Topology (Demo)</h2>
<button class="refresh-btn" onclick="refreshTopologyView()">
<svg class="refresh-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M1 4v6h6M23 20v-6h-6"/>
<path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15"/>
</svg>
Refresh
</button>
</div>
<div id="topology-graph-container">
<div class="loading">
<div>Loading network topology...</div>
</div>
</div>
</div>
</div>
<div id="firmware-view" class="view-content">
<div class="firmware-section">
<h2>📦 Firmware View (Demo)</h2>
<p>This is a demo of the firmware view. Switch to Members to see the network topology visualization.</p>
</div>
</div>
</div>
<script>
// Demo Topology View Implementation
class DemoTopologyViewModel {
constructor() {
this.nodes = [];
this.links = [];
this.isLoading = false;
this.error = null;
this.selectedNode = null;
}
async updateNetworkTopology() {
try {
this.isLoading = true;
this.error = null;
// Use demo API client
const response = await window.demoApiClient.getClusterMembers();
const members = response.members || [];
// Build enhanced graph data
const { nodes, links } = await this.buildEnhancedGraphData(members);
this.nodes = nodes;
this.links = links;
// Trigger render
if (window.topologyGraphComponent) {
window.topologyGraphComponent.renderGraph();
}
} catch (error) {
console.error('Failed to fetch network topology:', error);
this.error = error.message;
} finally {
this.isLoading = false;
}
}
async buildEnhancedGraphData(members) {
const nodes = [];
const links = [];
const nodeConnections = new Map();
// Create nodes from members
members.forEach((member, index) => {
if (member && member.ip) {
nodes.push({
id: member.ip,
hostname: member.hostname || member.ip,
ip: member.ip,
status: member.status || 'UNKNOWN',
latency: member.latency || 0,
resources: member.resources || {},
x: Math.random() * 800 + 100,
y: Math.random() * 600 + 100
});
}
});
// Try to get cluster members from each node to build actual connections
for (const node of nodes) {
try {
const nodeResponse = await window.demoApiClient.getClusterMembersFromNode(node.ip);
if (nodeResponse && nodeResponse.members) {
nodeConnections.set(node.ip, nodeResponse.members);
}
} catch (error) {
console.warn(`Failed to get cluster members from node ${node.ip}:`, error);
}
}
// Build links based on actual connections
for (const [sourceIp, sourceMembers] of nodeConnections) {
for (const targetMember of sourceMembers) {
if (targetMember.ip && targetMember.ip !== sourceIp) {
const existingLink = links.find(link =>
(link.source === sourceIp && link.target === targetMember.ip) ||
(link.source === targetMember.ip && link.target === sourceIp)
);
if (!existingLink) {
const sourceNode = nodes.find(n => n.id === sourceIp);
const targetNode = nodes.find(n => n.id === targetMember.ip);
if (sourceNode && targetNode) {
const latency = targetMember.latency || this.estimateLatency(sourceNode, targetNode);
links.push({
source: sourceIp,
target: targetMember.ip,
latency: latency,
sourceNode: sourceNode,
targetNode: targetNode,
bidirectional: true
});
}
}
}
}
}
// If no actual connections found, create a basic mesh
if (links.length === 0) {
console.log('No actual connections found, creating basic mesh');
for (let i = 0; i < nodes.length; i++) {
for (let j = i + 1; j < nodes.length; j++) {
const sourceNode = nodes[i];
const targetNode = nodes[j];
const estimatedLatency = this.estimateLatency(sourceNode, targetNode);
links.push({
source: sourceNode.id,
target: targetNode.id,
latency: estimatedLatency,
sourceNode: sourceNode,
targetNode: targetNode,
bidirectional: true
});
}
}
}
return { nodes, links };
}
estimateLatency(sourceNode, targetNode) {
const baseLatency = 5;
const randomVariation = Math.random() * 10;
return Math.round(baseLatency + randomVariation);
}
selectNode(nodeId) {
this.selectedNode = nodeId;
}
clearSelection() {
this.selectedNode = null;
}
}
// Demo Members Graph Component
class DemoTopologyGraphComponent {
constructor(container, viewModel) {
this.container = container;
this.viewModel = viewModel;
this.svg = null;
this.simulation = null;
this.zoom = null;
this.width = 800;
this.height = 600;
}
async initialize() {
console.log('DemoTopologyGraphComponent: Initializing...');
// Set up the SVG container
this.setupSVG();
// Initial data load
await this.viewModel.updateNetworkTopology();
}
setupSVG() {
const container = this.findElement('#members-graph-container');
if (!container) {
console.error('Graph container not found');
return;
}
// Clear existing content
container.innerHTML = '';
// Create SVG element
this.svg = d3.select(container)
.append('svg')
.attr('width', this.width)
.attr('height', this.height)
.attr('viewBox', `0 0 ${this.width} ${this.height}`)
.style('border', '1px solid #ddd')
.style('background', '#f9f9f9');
// Add zoom behavior
this.zoom = d3.zoom()
.scaleExtent([0.1, 4])
.on('zoom', (event) => {
this.svg.select('g').attr('transform', event.transform);
});
this.svg.call(this.zoom);
// Create main group for zoom
this.svg.append('g');
}
renderGraph() {
const nodes = this.viewModel.nodes;
const links = this.viewModel.links;
if (!nodes || nodes.length === 0) {
this.showNoData();
return;
}
console.log('Rendering graph with', nodes.length, 'nodes and', links.length, 'links');
const svgGroup = this.svg.select('g');
// Clear existing elements
svgGroup.selectAll('*').remove();
// Create arrow marker for links
svgGroup.append('defs').selectAll('marker')
.data(['end'])
.enter().append('marker')
.attr('id', 'arrowhead')
.attr('viewBox', '0 -5 10 10')
.attr('refX', 20)
.attr('refY', 0)
.attr('markerWidth', 6)
.attr('markerHeight', 6)
.attr('orient', 'auto')
.append('path')
.attr('d', 'M0,-5L10,0L0,5')
.attr('fill', '#999');
// Create links with better styling
const link = svgGroup.append('g')
.selectAll('line')
.data(links)
.enter().append('line')
.attr('stroke', d => this.getLinkColor(d.latency))
.attr('stroke-opacity', 0.7)
.attr('stroke-width', d => Math.max(2, Math.min(8, d.latency / 3)))
.attr('marker-end', 'url(#arrowhead)');
// Create nodes
const node = svgGroup.append('g')
.selectAll('g')
.data(nodes)
.enter().append('g')
.attr('class', 'node')
.call(this.drag(this.simulation));
// Add circles to nodes with size based on status
node.append('circle')
.attr('r', d => this.getNodeRadius(d.status))
.attr('fill', d => this.getNodeColor(d.status))
.attr('stroke', '#fff')
.attr('stroke-width', 2);
// Add status indicator
node.append('circle')
.attr('r', 3)
.attr('fill', d => this.getStatusIndicatorColor(d.status))
.attr('cx', -8)
.attr('cy', -8);
// Add labels to nodes
node.append('text')
.text(d => d.hostname.length > 15 ? d.hostname.substring(0, 15) + '...' : d.hostname)
.attr('x', 15)
.attr('y', 4)
.attr('font-size', '12px')
.attr('fill', '#333')
.attr('font-weight', '500');
// Add IP address labels
node.append('text')
.text(d => d.ip)
.attr('x', 15)
.attr('y', 20)
.attr('font-size', '10px')
.attr('fill', '#666');
// Add status labels
node.append('text')
.text(d => d.status)
.attr('x', 15)
.attr('y', 35)
.attr('font-size', '10px')
.attr('fill', d => this.getNodeColor(d.status))
.attr('font-weight', '600');
// Add latency labels on links with better positioning
const linkLabels = svgGroup.append('g')
.selectAll('text')
.data(links)
.enter().append('text')
.attr('font-size', '11px')
.attr('fill', '#333')
.attr('font-weight', '500')
.attr('text-anchor', 'middle')
.text(d => `${d.latency}ms`);
// Add background for link labels
const linkLabelBackgrounds = svgGroup.append('g')
.selectAll('rect')
.data(links)
.enter().append('rect')
.attr('width', d => `${d.latency}ms`.length * 6 + 4)
.attr('height', 16)
.attr('fill', '#fff')
.attr('opacity', 0.8)
.attr('rx', 3);
// Set up force simulation with better parameters
this.simulation = d3.forceSimulation(nodes)
.force('link', d3.forceLink(links).id(d => d.id).distance(120))
.force('charge', d3.forceManyBody().strength(-400))
.force('center', d3.forceCenter(this.width / 2, this.height / 2))
.force('collision', d3.forceCollide().radius(40));
// Update positions on simulation tick
this.simulation.on('tick', () => {
link
.attr('x1', d => d.source.x)
.attr('y1', d => d.source.y)
.attr('x2', d => d.target.x)
.attr('y2', d => d.target.y);
// Update link labels
linkLabels
.attr('x', d => (d.source.x + d.target.x) / 2)
.attr('y', d => (d.source.y + d.target.y) / 2 - 5);
// Update link label backgrounds
linkLabelBackgrounds
.attr('x', d => (d.source.x + d.target.x) / 2 - (d.latency.toString().length * 6 + 4) / 2)
.attr('y', d => (d.source.y + d.target.y) / 2 - 12);
node
.attr('transform', d => `translate(${d.x},${d.y})`);
});
// Add click handlers for node selection
node.on('click', (event, d) => {
this.viewModel.selectNode(d.id);
this.updateSelection(d.id);
});
// Add hover effects
node.on('mouseover', (event, d) => {
d3.select(event.currentTarget).select('circle')
.attr('r', d => this.getNodeRadius(d.status) + 4)
.attr('stroke-width', 3);
});
node.on('mouseout', (event, d) => {
d3.select(event.currentTarget).select('circle')
.attr('r', d => this.getNodeRadius(d.status))
.attr('stroke-width', 2);
});
// Add tooltip for links
link.on('mouseover', (event, d) => {
d3.select(event.currentTarget)
.attr('stroke-width', d => Math.max(3, Math.min(10, d.latency / 2)))
.attr('stroke-opacity', 1);
});
link.on('mouseout', (event, d) => {
d3.select(event.currentTarget)
.attr('stroke-width', d => Math.max(2, Math.min(8, d.latency / 3)))
.attr('stroke-opacity', 0.7);
});
// Add legend
this.addLegend(svgGroup);
}
getNodeRadius(status) {
switch (status?.toUpperCase()) {
case 'ACTIVE':
return 10;
case 'INACTIVE':
return 8;
case 'DEAD':
return 6;
default:
return 8;
}
}
getStatusIndicatorColor(status) {
switch (status?.toUpperCase()) {
case 'ACTIVE':
return '#4CAF50';
case 'INACTIVE':
return '#FF9800';
case 'DEAD':
return '#F44336';
default:
return '#9E9E9E';
}
}
getNodeColor(status) {
switch (status?.toUpperCase()) {
case 'ACTIVE':
return '#4CAF50';
case 'INACTIVE':
return '#FF9800';
case 'DEAD':
return '#F44336';
default:
return '#9E9E9E';
}
}
getLinkColor(latency) {
if (latency <= 5) return '#4CAF50'; // Green for low latency
if (latency <= 15) return '#FF9800'; // Orange for medium latency
if (latency <= 30) return '#FF5722'; // Red-orange for high latency
return '#F44336'; // Red for very high latency
}
drag(simulation) {
function dragstarted(event, d) {
if (!event.active) simulation.alphaTarget(0.3).restart();
d.fx = d.x;
d.fy = d.y;
}
function dragged(event, d) {
d.fx = event.x;
d.fy = event.y;
}
function dragended(event, d) {
if (!event.active) simulation.alphaTarget(0);
d.fx = null;
d.fy = null;
}
return d3.drag()
.on('start', dragstarted)
.on('drag', dragged)
.on('end', dragended);
}
updateSelection(selectedNodeId) {
// Update visual selection
this.svg.selectAll('.node').select('circle')
.attr('stroke-width', d => d.id === selectedNodeId ? 4 : 2)
.attr('stroke', d => d.id === selectedNodeId ? '#2196F3' : '#fff');
}
addLegend(svgGroup) {
const legend = svgGroup.append('g')
.attr('class', 'legend')
.attr('transform', `translate(20, 20)`);
// Node status legend
const nodeLegend = legend.append('g');
nodeLegend.append('text')
.text('Node Status:')
.attr('x', 0)
.attr('y', 0)
.attr('font-size', '12px')
.attr('font-weight', '600')
.attr('fill', '#333');
const statuses = [
{ status: 'ACTIVE', color: '#4CAF50', y: 20 },
{ status: 'INACTIVE', color: '#FF9800', y: 40 },
{ status: 'DEAD', color: '#F44336', y: 60 }
];
statuses.forEach(item => {
nodeLegend.append('circle')
.attr('r', 6)
.attr('cx', 0)
.attr('cy', item.y)
.attr('fill', item.color);
nodeLegend.append('text')
.text(item.status)
.attr('x', 15)
.attr('y', item.y + 4)
.attr('font-size', '10px')
.attr('fill', '#333');
});
// Link latency legend
const linkLegend = legend.append('g')
.attr('transform', 'translate(120, 0)');
linkLegend.append('text')
.text('Link Latency:')
.attr('x', 0)
.attr('y', 0)
.attr('font-size', '12px')
.attr('font-weight', '600')
.attr('fill', '#333');
const latencies = [
{ range: '≤5ms', color: '#4CAF50', y: 20 },
{ range: '6-15ms', color: '#FF9800', y: 40 },
{ range: '16-30ms', color: '#FF5722', y: 60 },
{ range: '>30ms', color: '#F44336', y: 80 }
];
latencies.forEach(item => {
linkLegend.append('line')
.attr('x1', 0)
.attr('y1', item.y)
.attr('x2', 20)
.attr('y2', item.y)
.attr('stroke', item.color)
.attr('stroke-width', 3);
linkLegend.append('text')
.text(item.range)
.attr('x', 25)
.attr('y', item.y + 4)
.attr('font-size', '10px')
.attr('fill', '#333');
});
}
findElement(selector) {
return document.querySelector(selector);
}
showNoData() {
const container = this.findElement('#members-graph-container');
container.innerHTML = '<div class="no-data"><div>No cluster members found</div></div>';
}
}
// Navigation functions
function showCluster() {
document.querySelectorAll('.view-content').forEach(v => v.classList.remove('active'));
document.querySelectorAll('.nav-tab').forEach(t => t.classList.remove('active'));
document.getElementById('cluster-view').classList.add('active');
document.querySelector('[onclick="showCluster()"]').classList.add('active');
}
function showTopology() {
document.querySelectorAll('.view-content').forEach(v => v.classList.remove('active'));
document.querySelectorAll('.nav-tab').forEach(t => t.classList.remove('active'));
document.getElementById('topology-view').classList.add('active');
document.querySelector('[onclick="showTopology()"]').classList.add('active');
}
function showFirmware() {
document.querySelectorAll('.view-content').forEach(v => v.classList.remove('active'));
document.querySelectorAll('.nav-tab').forEach(t => t.classList.remove('active'));
document.getElementById('firmware-view').classList.add('active');
document.querySelector('[onclick="showFirmware()"]').classList.add('active');
}
function refreshTopologyView() {
if (window.topologyGraphComponent) {
window.topologyGraphComponent.viewModel.updateNetworkTopology();
}
}
// Initialize the demo
window.addEventListener('DOMContentLoaded', async function() {
console.log('Demo Members View: Initializing...');
// Create view model and component
const topologyViewModel = new DemoTopologyViewModel();
const topologyGraphComponent = new DemoTopologyGraphComponent(
document.getElementById('topology-graph-container'),
topologyViewModel
);
// Store globally for refresh function
window.topologyGraphComponent = topologyGraphComponent;
// Initialize
await topologyGraphComponent.initialize();
console.log('Demo Topology View: Initialization completed');
});
</script>
</body>
</html>

View File

@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SPORE UI</title>
<link rel="stylesheet" href="styles.css">
<script src="./d3.v7.min.js"></script>
</head>
<body>
<div class="container">
@@ -16,6 +17,7 @@
</button>
<div class="nav-left">
<button class="nav-tab active" data-view="cluster">🌐 Cluster</button>
<button class="nav-tab" data-view="topology">🔗 Topology</button>
<button class="nav-tab" data-view="firmware">📦 Firmware</button>
</div>
<div class="nav-right">
@@ -55,7 +57,15 @@
</div>
</div>
<div id="firmware-view" class="view-content">
<div id="topology-view" class="view-content">
<div id="topology-graph-container">
<div class="loading">
<div>Loading network topology...</div>
</div>
</div>
</div>
<div id="firmware-view" class="view-content">
<div class="firmware-section">
<!--div class="firmware-header">
<div class="firmware-header-left"></div>

View File

@@ -2304,4 +2304,105 @@ p {
width: 100%;
text-align: left;
}
}
/* Topology View Styles */
#topology-view {
height: 100%;
padding: 0;
min-height: 100vh; /* Ensure full viewport height */
}
#topology-graph-container {
background: rgba(0, 0, 0, 0.2);
border-radius: 12px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
min-height: 100%;
height: 100vh; /* Use full viewport height */
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.1);
}
#topology-graph-container svg {
width: 100%;
height: 100%;
}
#topology-graph-container .loading,
#topology-graph-container .error,
#topology-graph-container .no-data {
text-align: center;
color: rgba(255, 255, 255, 0.7);
font-size: 1.1rem;
}
#topology-graph-container .error {
color: #f87171;
}
#topology-graph-container .no-data {
color: rgba(255, 255, 255, 0.5);
}
/* Node and link styles for the graph */
.node circle {
cursor: pointer;
transition: all 0.2s ease;
}
.node text {
pointer-events: none;
user-select: none;
}
.node:hover circle {
stroke-width: 3;
stroke: #60a5fa;
}
/* Legend styles */
.legend text {
pointer-events: none;
user-select: none;
}
.legend line {
pointer-events: none;
}
/* Graph container enhancements */
#members-graph-container {
position: relative;
}
#members-graph-container svg {
width: 100%;
height: 100%;
}
/* Loading and error states */
.loading, .error, .no-data {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
font-size: 1.1rem;
color: rgba(255, 255, 255, 0.7);
}
.loading div {
text-align: center;
}
.error div {
color: #f87171;
text-align: center;
}
.no-data div {
color: rgba(255, 255, 255, 0.5);
text-align: center;
}

View File

@@ -0,0 +1,296 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Debug Members Component</title>
<script src="https://d3js.org/d3.v7.min.js"></script>
<style>
body { font-family: Arial, sans-serif; margin: 20px; }
.container { max-width: 800px; margin: 0 auto; }
.test-section { margin: 20px 0; padding: 20px; border: 1px solid #ddd; }
button { padding: 10px 20px; margin: 5px; background: #007bff; color: white; border: none; cursor: pointer; }
button:hover { background: #0056b3; }
#graph-container { width: 600px; height: 400px; border: 1px solid #ccc; margin: 20px 0; }
.status { padding: 10px; margin: 10px 0; border-radius: 4px; }
.success { background: #d4edda; color: #155724; }
.error { background: #f8d7da; color: #721c24; }
.info { background: #d1ecf1; color: #0c5460; }
</style>
</head>
<body>
<div class="container">
<h1>🐛 Debug Members Component</h1>
<div class="test-section">
<h3>Component Test</h3>
<p>Testing if the TopologyGraphComponent can be created and initialized.</p>
<button onclick="testComponentCreation()">Test Component Creation</button>
<button onclick="testInitialization()">Test Initialization</button>
<button onclick="testMount()">Test Mount</button>
<button onclick="clearTest()">Clear Test</button>
<div id="status"></div>
</div>
<div class="test-section">
<h3>Graph Container</h3>
<div id="graph-container">
<div style="text-align: center; padding: 50px; color: #666;">
Graph will appear here after testing
</div>
</div>
</div>
</div>
<script>
let testComponent = null;
function showStatus(message, type = 'info') {
const statusDiv = document.getElementById('status');
statusDiv.innerHTML = `<div class="status ${type}">${message}</div>`;
}
// Mock base Component class
class MockComponent {
constructor(container, viewModel, eventBus) {
this.container = container;
this.viewModel = viewModel;
this.eventBus = eventBus;
this.isMounted = false;
this.isInitialized = false;
}
async initialize() {
console.log('MockComponent: initialize called');
this.isInitialized = true;
}
mount() {
console.log('MockComponent: mount called');
this.isMounted = true;
}
setupEventListeners() {
console.log('MockComponent: setupEventListeners called');
}
setupViewModelListeners() {
console.log('MockComponent: setupViewModelListeners called');
}
render() {
console.log('MockComponent: render called');
}
}
// Mock ViewModel
class MockViewModel {
constructor() {
this._data = {
nodes: [],
links: [],
isLoading: false,
error: null
};
}
get(property) {
return this._data[property];
}
set(property, value) {
this._data[property] = value;
}
subscribe(property, callback) {
console.log(`MockViewModel: Subscribing to ${property}`);
}
async updateNetworkTopology() {
console.log('MockViewModel: updateNetworkTopology called');
// Simulate some data
this.set('nodes', [
{ id: '1', hostname: 'Test Node 1', ip: '192.168.1.1', status: 'ACTIVE' },
{ id: '2', hostname: 'Test Node 2', ip: '192.168.1.2', status: 'ACTIVE' }
]);
this.set('links', [
{ source: '1', target: '2', latency: 5 }
]);
}
}
// Test TopologyGraphComponent (simplified version)
class TestTopologyGraphComponent extends MockComponent {
constructor(container, viewModel, eventBus) {
super(container, viewModel, eventBus);
console.log('TestTopologyGraphComponent: Constructor called');
this.svg = null;
this.simulation = null;
this.zoom = null;
this.width = 600;
this.height = 400;
this.isInitialized = false;
}
// Override mount to ensure proper initialization
mount() {
if (this.isMounted) return;
console.log('TestTopologyGraphComponent: Starting mount...');
// Call initialize if not already done
if (!this.isInitialized) {
console.log('TestTopologyGraphComponent: Initializing during mount...');
this.initialize().then(() => {
// Complete mount after initialization
this.completeMount();
}).catch(error => {
console.error('TestTopologyGraphComponent: Initialization failed during mount:', error);
// Still complete mount to prevent blocking
this.completeMount();
});
} else {
this.completeMount();
}
}
completeMount() {
this.isMounted = true;
this.setupEventListeners();
this.setupViewModelListeners();
this.render();
console.log('TestTopologyGraphComponent: Mounted successfully');
}
async initialize() {
console.log('TestTopologyGraphComponent: Initializing...');
await super.initialize();
// Set up the SVG container
this.setupSVG();
// Mark as initialized
this.isInitialized = true;
console.log('TestTopologyGraphComponent: Initialization completed');
}
setupSVG() {
const container = document.getElementById('graph-container');
if (!container) {
console.error('TestTopologyGraphComponent: Graph container not found');
return;
}
// Clear existing content
container.innerHTML = '';
// Create SVG element
this.svg = d3.select(container)
.append('svg')
.attr('width', this.width)
.attr('height', this.height)
.style('border', '1px solid #ddd')
.style('background', '#f9f9f9');
this.svg.append('g');
console.log('TestTopologyGraphComponent: SVG setup completed');
}
render() {
console.log('TestTopologyGraphComponent: render called');
// Simple render for testing
if (this.svg) {
const svgGroup = this.svg.select('g');
svgGroup.append('text')
.attr('x', 300)
.attr('y', 200)
.attr('text-anchor', 'middle')
.attr('font-size', '16px')
.text('Component Rendered Successfully!');
}
}
}
function testComponentCreation() {
try {
showStatus('🔄 Testing component creation...', 'info');
const mockViewModel = new MockViewModel();
const mockEventBus = {};
testComponent = new TestTopologyGraphComponent(
document.getElementById('graph-container'),
mockViewModel,
mockEventBus
);
showStatus('✅ Component created successfully', 'success');
console.log('Component created:', testComponent);
} catch (error) {
console.error('Component creation failed:', error);
showStatus(`❌ Component creation failed: ${error.message}`, 'error');
}
}
function testInitialization() {
if (!testComponent) {
showStatus('❌ No component created. Run component creation test first.', 'error');
return;
}
try {
showStatus('🔄 Testing initialization...', 'info');
testComponent.initialize().then(() => {
showStatus('✅ Component initialized successfully', 'success');
}).catch(error => {
console.error('Initialization failed:', error);
showStatus(`❌ Initialization failed: ${error.message}`, 'error');
});
} catch (error) {
console.error('Initialization test failed:', error);
showStatus(`❌ Initialization test failed: ${error.message}`, 'error');
}
}
function testMount() {
if (!testComponent) {
showStatus('❌ No component created. Run component creation test first.', 'error');
return;
}
try {
showStatus('🔄 Testing mount...', 'info');
testComponent.mount();
showStatus('✅ Component mounted successfully', 'success');
} catch (error) {
console.error('Mount test failed:', error);
showStatus(`❌ Mount test failed: ${error.message}`, 'error');
}
}
function clearTest() {
if (testComponent) {
testComponent = null;
}
document.getElementById('graph-container').innerHTML =
'<div style="text-align: center; padding: 50px; color: #666;">Graph will appear here after testing</div>';
document.getElementById('status').innerHTML = '';
showStatus('🧹 Test cleared', 'info');
}
// Auto-test on load
window.addEventListener('load', () => {
showStatus('🚀 Debug page loaded. Click "Test Component Creation" to begin.', 'info');
});
</script>
</body>
</html>

View File

@@ -0,0 +1,296 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Debug Members Component</title>
<script src="https://d3js.org/d3.v7.min.js"></script>
<style>
body { font-family: Arial, sans-serif; margin: 20px; }
.container { max-width: 800px; margin: 0 auto; }
.test-section { margin: 20px 0; padding: 20px; border: 1px solid #ddd; }
button { padding: 10px 20px; margin: 5px; background: #007bff; color: white; border: none; cursor: pointer; }
button:hover { background: #0056b3; }
#graph-container { width: 600px; height: 400px; border: 1px solid #ccc; margin: 20px 0; }
.status { padding: 10px; margin: 10px 0; border-radius: 4px; }
.success { background: #d4edda; color: #155724; }
.error { background: #f8d7da; color: #721c24; }
.info { background: #d1ecf1; color: #0c5460; }
</style>
</head>
<body>
<div class="container">
<h1>🐛 Debug Members Component</h1>
<div class="test-section">
<h3>Component Test</h3>
<p>Testing if the TopologyGraphComponent can be created and initialized.</p>
<button onclick="testComponentCreation()">Test Component Creation</button>
<button onclick="testInitialization()">Test Initialization</button>
<button onclick="testMount()">Test Mount</button>
<button onclick="clearTest()">Clear Test</button>
<div id="status"></div>
</div>
<div class="test-section">
<h3>Graph Container</h3>
<div id="graph-container">
<div style="text-align: center; padding: 50px; color: #666;">
Graph will appear here after testing
</div>
</div>
</div>
</div>
<script>
let testComponent = null;
function showStatus(message, type = 'info') {
const statusDiv = document.getElementById('status');
statusDiv.innerHTML = `<div class="status ${type}">${message}</div>`;
}
// Mock base Component class
class MockComponent {
constructor(container, viewModel, eventBus) {
this.container = container;
this.viewModel = viewModel;
this.eventBus = eventBus;
this.isMounted = false;
this.isInitialized = false;
}
async initialize() {
console.log('MockComponent: initialize called');
this.isInitialized = true;
}
mount() {
console.log('MockComponent: mount called');
this.isMounted = true;
}
setupEventListeners() {
console.log('MockComponent: setupEventListeners called');
}
setupViewModelListeners() {
console.log('MockComponent: setupViewModelListeners called');
}
render() {
console.log('MockComponent: render called');
}
}
// Mock ViewModel
class MockViewModel {
constructor() {
this._data = {
nodes: [],
links: [],
isLoading: false,
error: null
};
}
get(property) {
return this._data[property];
}
set(property, value) {
this._data[property] = value;
}
subscribe(property, callback) {
console.log(`MockViewModel: Subscribing to ${property}`);
}
async updateNetworkTopology() {
console.log('MockViewModel: updateNetworkTopology called');
// Simulate some data
this.set('nodes', [
{ id: '1', hostname: 'Test Node 1', ip: '192.168.1.1', status: 'ACTIVE' },
{ id: '2', hostname: 'Test Node 2', ip: '192.168.1.2', status: 'ACTIVE' }
]);
this.set('links', [
{ source: '1', target: '2', latency: 5 }
]);
}
}
// Test TopologyGraphComponent (simplified version)
class TestTopologyGraphComponent extends MockComponent {
constructor(container, viewModel, eventBus) {
super(container, viewModel, eventBus);
console.log('TestTopologyGraphComponent: Constructor called');
this.svg = null;
this.simulation = null;
this.zoom = null;
this.width = 600;
this.height = 400;
this.isInitialized = false;
}
// Override mount to ensure proper initialization
mount() {
if (this.isMounted) return;
console.log('TestTopologyGraphComponent: Starting mount...');
// Call initialize if not already done
if (!this.isInitialized) {
console.log('TestTopologyGraphComponent: Initializing during mount...');
this.initialize().then(() => {
// Complete mount after initialization
this.completeMount();
}).catch(error => {
console.error('TestTopologyGraphComponent: Initialization failed during mount:', error);
// Still complete mount to prevent blocking
this.completeMount();
});
} else {
this.completeMount();
}
}
completeMount() {
this.isMounted = true;
this.setupEventListeners();
this.setupViewModelListeners();
this.render();
console.log('TestTopologyGraphComponent: Mounted successfully');
}
async initialize() {
console.log('TestTopologyGraphComponent: Initializing...');
await super.initialize();
// Set up the SVG container
this.setupSVG();
// Mark as initialized
this.isInitialized = true;
console.log('TestTopologyGraphComponent: Initialization completed');
}
setupSVG() {
const container = document.getElementById('graph-container');
if (!container) {
console.error('TestTopologyGraphComponent: Graph container not found');
return;
}
// Clear existing content
container.innerHTML = '';
// Create SVG element
this.svg = d3.select(container)
.append('svg')
.attr('width', this.width)
.attr('height', this.height)
.style('border', '1px solid #ddd')
.style('background', '#f9f9f9');
this.svg.append('g');
console.log('TestTopologyGraphComponent: SVG setup completed');
}
render() {
console.log('TestTopologyGraphComponent: render called');
// Simple render for testing
if (this.svg) {
const svgGroup = this.svg.select('g');
svgGroup.append('text')
.attr('x', 300)
.attr('y', 200)
.attr('text-anchor', 'middle')
.attr('font-size', '16px')
.text('Component Rendered Successfully!');
}
}
}
function testComponentCreation() {
try {
showStatus('🔄 Testing component creation...', 'info');
const mockViewModel = new MockViewModel();
const mockEventBus = {};
testComponent = new TestTopologyGraphComponent(
document.getElementById('graph-container'),
mockViewModel,
mockEventBus
);
showStatus('✅ Component created successfully', 'success');
console.log('Component created:', testComponent);
} catch (error) {
console.error('Component creation failed:', error);
showStatus(`❌ Component creation failed: ${error.message}`, 'error');
}
}
function testInitialization() {
if (!testComponent) {
showStatus('❌ No component created. Run component creation test first.', 'error');
return;
}
try {
showStatus('🔄 Testing initialization...', 'info');
testComponent.initialize().then(() => {
showStatus('✅ Component initialized successfully', 'success');
}).catch(error => {
console.error('Initialization failed:', error);
showStatus(`❌ Initialization failed: ${error.message}`, 'error');
});
} catch (error) {
console.error('Initialization test failed:', error);
showStatus(`❌ Initialization test failed: ${error.message}`, 'error');
}
}
function testMount() {
if (!testComponent) {
showStatus('❌ No component created. Run component creation test first.', 'error');
return;
}
try {
showStatus('🔄 Testing mount...', 'info');
testComponent.mount();
showStatus('✅ Component mounted successfully', 'success');
} catch (error) {
console.error('Mount test failed:', error);
showStatus(`❌ Mount test failed: ${error.message}`, 'error');
}
}
function clearTest() {
if (testComponent) {
testComponent = null;
}
document.getElementById('graph-container').innerHTML =
'<div style="text-align: center; padding: 50px; color: #666;">Graph will appear here after testing</div>';
document.getElementById('status').innerHTML = '';
showStatus('🧹 Test cleared', 'info');
}
// Auto-test on load
window.addEventListener('load', () => {
showStatus('🚀 Debug page loaded. Click "Test Component Creation" to begin.', 'info');
});
</script>
</body>
</html>

View File

@@ -0,0 +1,286 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Test Members View Fix</title>
<script src="https://d3js.org/d3.v7.min.js"></script>
<style>
body { font-family: Arial, sans-serif; margin: 20px; }
.container { max-width: 800px; margin: 0 auto; }
.test-section { margin: 20px 0; padding: 20px; border: 1px solid #ddd; }
button { padding: 10px 20px; margin: 5px; background: #007bff; color: white; border: none; cursor: pointer; }
button:hover { background: #0056b3; }
#graph-container { width: 600px; height: 400px; border: 1px solid #ccc; margin: 20px 0; }
.status { padding: 10px; margin: 10px 0; border-radius: 4px; }
.success { background: #d4edda; color: #155724; }
.error { background: #f8d7da; color: #721c24; }
</style>
</head>
<body>
<div class="container">
<h1>🧪 Test Members View Fix</h1>
<div class="test-section">
<h3>Initialization Test</h3>
<p>Testing the fixed initialization order for the Members view component.</p>
<button onclick="testInitialization()">Test Initialization</button>
<button onclick="testDataUpdate()">Test Data Update</button>
<button onclick="clearTest()">Clear Test</button>
<div id="status"></div>
</div>
<div class="test-section">
<h3>Graph Container</h3>
<div id="graph-container">
<div style="text-align: center; padding: 50px; color: #666;">
Graph will appear here after initialization
</div>
</div>
</div>
</div>
<script>
let testComponent = null;
let testViewModel = null;
function showStatus(message, type = 'success') {
const statusDiv = document.getElementById('status');
statusDiv.innerHTML = `<div class="status ${type}">${message}</div>`;
}
// Mock ViewModel for testing
class TestViewModel {
constructor() {
this._data = {
nodes: [],
links: [],
isLoading: false,
error: null
};
this._listeners = new Map();
}
get(property) {
return this._data[property];
}
set(property, value) {
this._data[property] = value;
this._notifyListeners(property, value);
}
subscribe(property, callback) {
if (!this._listeners.has(property)) {
this._listeners.set(property, []);
}
this._listeners.get(property).push(callback);
}
_notifyListeners(property, value) {
if (this._listeners.has(property)) {
this._listeners.get(property).forEach(callback => {
try {
callback(value);
} catch (error) {
console.error(`Error in property listener for ${property}:`, error);
}
});
}
}
}
// Test Component for testing
class TestMembersComponent {
constructor(container, viewModel) {
this.container = container;
this.viewModel = viewModel;
this.svg = null;
this.isInitialized = false;
}
async initialize() {
console.log('TestMembersComponent: Initializing...');
// Simulate async initialization
await new Promise(resolve => setTimeout(resolve, 100));
// Set up SVG
this.setupSVG();
// Mark as initialized
this.isInitialized = true;
// Set up listeners AFTER initialization
this.viewModel.subscribe('nodes', this.renderGraph.bind(this));
this.viewModel.subscribe('links', this.renderGraph.bind(this));
console.log('TestMembersComponent: Initialization completed');
showStatus('✅ Component initialized successfully', 'success');
}
setupSVG() {
const container = document.getElementById('graph-container');
container.innerHTML = '';
this.svg = d3.select(container)
.append('svg')
.attr('width', 600)
.attr('height', 400)
.style('border', '1px solid #ddd')
.style('background', '#f9f9f9');
this.svg.append('g');
console.log('TestMembersComponent: SVG setup completed');
}
renderGraph() {
try {
// Check if component is initialized
if (!this.isInitialized) {
console.log('TestMembersComponent: Component not yet initialized, skipping render');
return;
}
const nodes = this.viewModel.get('nodes');
const links = this.viewModel.get('links');
// Check if SVG is initialized
if (!this.svg) {
console.log('TestMembersComponent: SVG not initialized yet, setting up SVG first');
this.setupSVG();
}
if (!nodes || nodes.length === 0) {
this.showNoData();
return;
}
console.log('TestMembersComponent: Rendering graph with', nodes.length, 'nodes and', links.length, 'links');
const svgGroup = this.svg.select('g');
if (!svgGroup || svgGroup.empty()) {
console.error('TestMembersComponent: SVG group not found, cannot render graph');
return;
}
// Clear existing elements
svgGroup.selectAll('*').remove();
// Create simple nodes
const node = svgGroup.append('g')
.selectAll('g')
.data(nodes)
.enter().append('g')
.attr('class', 'node')
.attr('transform', (d, i) => `translate(${100 + i * 150}, 200)`);
node.append('circle')
.attr('r', 20)
.attr('fill', '#4CAF50');
node.append('text')
.text(d => d.name)
.attr('x', 0)
.attr('y', 30)
.attr('text-anchor', 'middle')
.attr('font-size', '12px');
// Create simple links
if (links.length > 0) {
const link = svgGroup.append('g')
.selectAll('line')
.data(links)
.enter().append('line')
.attr('stroke', '#999')
.attr('stroke-width', 2)
.attr('x1', (d, i) => 100 + i * 150)
.attr('y1', 200)
.attr('x2', (d, i) => 100 + (i + 1) * 150)
.attr('y2', 200);
}
showStatus('✅ Graph rendered successfully', 'success');
} catch (error) {
console.error('Failed to render graph:', error);
showStatus(`❌ Graph rendering failed: ${error.message}`, 'error');
}
}
showNoData() {
const container = document.getElementById('graph-container');
container.innerHTML = '<div style="text-align: center; padding: 50px; color: #666;">No data available</div>';
}
}
async function testInitialization() {
try {
showStatus('🔄 Testing initialization...', 'success');
// Create test view model and component
testViewModel = new TestViewModel();
testComponent = new TestMembersComponent(
document.getElementById('graph-container'),
testViewModel
);
// Initialize component
await testComponent.initialize();
} catch (error) {
console.error('Initialization test failed:', error);
showStatus(`❌ Initialization test failed: ${error.message}`, 'error');
}
}
function testDataUpdate() {
if (!testComponent || !testComponent.isInitialized) {
showStatus('❌ Component not initialized. Run initialization test first.', 'error');
return;
}
try {
showStatus('🔄 Testing data update...', 'success');
// Update with test data
const testNodes = [
{ name: 'Node 1', id: '1' },
{ name: 'Node 2', id: '2' },
{ name: 'Node 3', id: '3' }
];
const testLinks = [
{ source: '1', target: '2' },
{ source: '2', target: '3' }
];
testViewModel.set('nodes', testNodes);
testViewModel.set('links', testLinks);
} catch (error) {
console.error('Data update test failed:', error);
showStatus(`❌ Data update test failed: ${error.message}`, 'error');
}
}
function clearTest() {
if (testComponent) {
testComponent = null;
}
if (testViewModel) {
testViewModel = null;
}
document.getElementById('graph-container').innerHTML =
'<div style="text-align: center; padding: 50px; color: #666;">Graph will appear here after initialization</div>';
document.getElementById('status').innerHTML = '';
showStatus('🧹 Test cleared', 'success');
}
// Auto-test on load
window.addEventListener('load', () => {
showStatus('🚀 Test page loaded. Click "Test Initialization" to begin.', 'success');
});
</script>
</body>
</html>

View File

@@ -0,0 +1,254 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Test Members View</title>
<script src="https://d3js.org/d3.v7.min.js"></script>
<style>
body {
font-family: Arial, sans-serif;
margin: 20px;
background: #f5f5f5;
}
.container {
max-width: 1200px;
margin: 0 auto;
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.test-section {
margin-bottom: 30px;
padding: 20px;
border: 1px solid #ddd;
border-radius: 6px;
}
.test-section h3 {
margin-top: 0;
color: #333;
}
button {
background: #2196F3;
color: white;
border: none;
padding: 10px 20px;
border-radius: 6px;
cursor: pointer;
margin: 5px;
}
button:hover {
background: #1976D2;
}
#graph-container {
width: 800px;
height: 600px;
border: 1px solid #ddd;
background: #f9f9f9;
margin: 20px 0;
}
.status {
padding: 10px;
margin: 10px 0;
border-radius: 4px;
}
.success { background: #d4edda; color: #155724; border: 1px solid #c3e6cb; }
.error { background: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; }
.info { background: #d1ecf1; color: #0c5460; border: 1px solid #bee5eb; }
</style>
</head>
<body>
<div class="container">
<h1>🧪 Test Members View</h1>
<div class="test-section">
<h3>D3.js Integration Test</h3>
<p>Testing D3.js library integration and basic force-directed graph functionality.</p>
<button onclick="testD3Integration()">Test D3.js Integration</button>
<button onclick="testForceGraph()">Test Force Graph</button>
<button onclick="clearGraph()">Clear Graph</button>
<div id="status"></div>
</div>
<div class="test-section">
<h3>Graph Visualization</h3>
<div id="graph-container"></div>
</div>
</div>
<script>
let svg = null;
let simulation = null;
function showStatus(message, type = 'info') {
const statusDiv = document.getElementById('status');
statusDiv.innerHTML = `<div class="status ${type}">${message}</div>`;
}
function testD3Integration() {
try {
if (typeof d3 === 'undefined') {
throw new Error('D3.js is not loaded');
}
const version = d3.version;
showStatus(`✅ D3.js v${version} loaded successfully!`, 'success');
// Test basic D3 functionality
const testData = [1, 2, 3, 4, 5];
const testSelection = d3.select('#graph-container');
testSelection.append('div')
.attr('class', 'test-div')
.text('D3 selection test');
showStatus('✅ D3.js basic functionality working!', 'success');
} catch (error) {
showStatus(`❌ D3.js test failed: ${error.message}`, 'error');
}
}
function testForceGraph() {
try {
if (!svg) {
setupSVG();
}
// Create sample data
const nodes = [
{ id: 'node1', name: 'Node 1', status: 'ACTIVE' },
{ id: 'node2', name: 'Node 2', status: 'ACTIVE' },
{ id: 'node3', name: 'Node 3', status: 'INACTIVE' },
{ id: 'node4', name: 'Node 4', status: 'ACTIVE' }
];
const links = [
{ source: 'node1', target: 'node2', latency: 5 },
{ source: 'node1', target: 'node3', latency: 15 },
{ source: 'node2', target: 'node4', latency: 8 },
{ source: 'node3', target: 'node4', latency: 25 }
];
renderGraph(nodes, links);
showStatus('✅ Force-directed graph created successfully!', 'success');
} catch (error) {
showStatus(`❌ Force graph test failed: ${error.message}`, 'error');
}
}
function setupSVG() {
const container = document.getElementById('graph-container');
container.innerHTML = '';
svg = d3.select(container)
.append('svg')
.attr('width', 800)
.attr('height', 600)
.style('border', '1px solid #ddd')
.style('background', '#f9f9f9');
}
function renderGraph(nodes, links) {
if (!svg) return;
const svgGroup = svg.append('g');
// Create links
const link = svgGroup.append('g')
.selectAll('line')
.data(links)
.enter().append('line')
.attr('stroke', '#999')
.attr('stroke-opacity', 0.6)
.attr('stroke-width', 2);
// Create nodes
const node = svgGroup.append('g')
.selectAll('g')
.data(nodes)
.enter().append('g')
.attr('class', 'node')
.call(d3.drag()
.on('start', dragstarted)
.on('drag', dragged)
.on('end', dragended));
// Add circles to nodes
node.append('circle')
.attr('r', 8)
.attr('fill', d => getNodeColor(d.status));
// Add labels
node.append('text')
.text(d => d.name)
.attr('x', 12)
.attr('y', 4)
.attr('font-size', '12px');
// Set up force simulation
simulation = d3.forceSimulation(nodes)
.force('link', d3.forceLink(links).id(d => d.id).distance(100))
.force('charge', d3.forceManyBody().strength(-300))
.force('center', d3.forceCenter(400, 300));
// Update positions on simulation tick
simulation.on('tick', () => {
link
.attr('x1', d => d.source.x)
.attr('y1', d => d.source.y)
.attr('x2', d => d.target.x)
.attr('y2', d => d.target.y);
node
.attr('transform', d => `translate(${d.x},${d.y})`);
});
}
function getNodeColor(status) {
switch (status) {
case 'ACTIVE': return '#4CAF50';
case 'INACTIVE': return '#FF9800';
default: return '#9E9E9E';
}
}
function dragstarted(event, d) {
if (!event.active) simulation.alphaTarget(0.3).restart();
d.fx = d.x;
d.fy = d.y;
}
function dragged(event, d) {
d.fx = event.x;
d.fy = event.y;
}
function dragended(event, d) {
if (!event.active) simulation.alphaTarget(0);
d.fx = null;
d.fy = null;
}
function clearGraph() {
if (svg) {
svg.selectAll('*').remove();
svg = null;
}
if (simulation) {
simulation.stop();
simulation = null;
}
showStatus('Graph cleared', 'info');
}
// Auto-test on load
window.addEventListener('load', () => {
setTimeout(() => {
testD3Integration();
}, 500);
});
</script>
</body>
</html>

View File

@@ -473,4 +473,160 @@ class NavigationViewModel extends ViewModel {
getActiveView() {
return this.get('activeView');
}
}
// Topology View Model for network topology visualization
class TopologyViewModel extends ViewModel {
constructor() {
super();
this.setMultiple({
nodes: [],
links: [],
isLoading: false,
error: null,
lastUpdateTime: null,
selectedNode: null
});
}
// Update network topology data
async updateNetworkTopology() {
try {
console.log('TopologyViewModel: updateNetworkTopology called');
this.set('isLoading', true);
this.set('error', null);
// Get cluster members from the primary node
const response = await window.apiClient.getClusterMembers();
console.log('TopologyViewModel: Got cluster members response:', response);
const members = response.members || [];
// Build enhanced graph data with actual node connections
const { nodes, links } = await this.buildEnhancedGraphData(members);
this.batchUpdate({
nodes: nodes,
links: links,
lastUpdateTime: new Date().toISOString()
});
} catch (error) {
console.error('TopologyViewModel: Failed to fetch network topology:', error);
this.set('error', error.message);
} finally {
this.set('isLoading', false);
console.log('TopologyViewModel: updateNetworkTopology completed');
}
}
// Build enhanced graph data with actual node connections
async buildEnhancedGraphData(members) {
const nodes = [];
const links = [];
const nodeConnections = new Map();
// Create nodes from members
members.forEach((member, index) => {
if (member && member.ip) {
nodes.push({
id: member.ip,
hostname: member.hostname || member.ip,
ip: member.ip,
status: member.status || 'UNKNOWN',
latency: member.latency || 0,
resources: member.resources || {},
x: Math.random() * 1200 + 100, // Better spacing for 1400px width
y: Math.random() * 800 + 100 // Better spacing for 1000px height
});
}
});
// Try to get cluster members from each node to build actual connections
for (const node of nodes) {
try {
const nodeResponse = await window.apiClient.getClusterMembersFromNode(node.ip);
if (nodeResponse && nodeResponse.members) {
nodeConnections.set(node.ip, nodeResponse.members);
}
} catch (error) {
console.warn(`Failed to get cluster members from node ${node.ip}:`, error);
// Continue with other nodes
}
}
// Build links based on actual connections
for (const [sourceIp, sourceMembers] of nodeConnections) {
for (const targetMember of sourceMembers) {
if (targetMember.ip && targetMember.ip !== sourceIp) {
// Check if we already have this link
const existingLink = links.find(link =>
(link.source === sourceIp && link.target === targetMember.ip) ||
(link.source === targetMember.ip && link.target === sourceIp)
);
if (!existingLink) {
const sourceNode = nodes.find(n => n.id === sourceIp);
const targetNode = nodes.find(n => n.id === targetMember.ip);
if (sourceNode && targetNode) {
const latency = targetMember.latency || this.estimateLatency(sourceNode, targetNode);
links.push({
source: sourceIp,
target: targetMember.ip,
latency: latency,
sourceNode: sourceNode,
targetNode: targetNode,
bidirectional: true
});
}
}
}
}
}
// If no actual connections found, create a basic mesh
if (links.length === 0) {
console.log('TopologyViewModel: No actual connections found, creating basic mesh');
for (let i = 0; i < nodes.length; i++) {
for (let j = i + 1; j < nodes.length; j++) {
const sourceNode = nodes[i];
const targetNode = nodes[j];
const estimatedLatency = this.estimateLatency(sourceNode, targetNode);
links.push({
source: sourceNode.id,
target: targetNode.id,
latency: estimatedLatency,
sourceNode: sourceNode,
targetNode: targetNode,
bidirectional: true
});
}
}
}
return { nodes, links };
}
// Estimate latency between two nodes
estimateLatency(sourceNode, targetNode) {
// Simple estimation - in a real implementation, you'd get actual measurements
const baseLatency = 5; // Base latency in ms
const randomVariation = Math.random() * 10; // Random variation
return Math.round(baseLatency + randomVariation);
}
// Select a node in the graph
selectNode(nodeId) {
this.set('selectedNode', nodeId);
}
// Clear node selection
clearSelection() {
this.set('selectedNode', null);
}
}