641 lines
26 KiB
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> |