Files
spore-ui/public/demo-topology-view.html

641 lines
26 KiB
HTML

<!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>