feat: events visualization
This commit is contained in:
@@ -39,6 +39,16 @@
|
||||
</svg>
|
||||
Topology
|
||||
</a>
|
||||
<a href="/events" class="nav-tab" data-view="events">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16" style="margin-right:6px;">
|
||||
<circle cx="6" cy="6" r="2" fill="currentColor"/>
|
||||
<circle cx="18" cy="6" r="2" fill="currentColor"/>
|
||||
<circle cx="12" cy="18" r="2" fill="currentColor"/>
|
||||
<line x1="6" y1="6" x2="12" y2="18"/>
|
||||
<line x1="18" y1="6" x2="12" y2="18"/>
|
||||
</svg>
|
||||
Events
|
||||
</a>
|
||||
<a href="/monitoring" class="nav-tab" data-view="monitoring">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16" style="margin-right:6px;">
|
||||
<path d="M3 12h3l2 7 4-14 3 10 2-6h4"/>
|
||||
@@ -238,6 +248,16 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="events-view" class="view-content">
|
||||
<div class="view-section" style="height: 100%; display: flex; flex-direction: column;">
|
||||
<div id="events-graph-container" style="flex: 1; min-height: 0;">
|
||||
<div class="loading">
|
||||
<div>Waiting for websocket events...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="./vendor/d3.v7.min.js"></script>
|
||||
@@ -264,6 +284,7 @@
|
||||
<script src="./scripts/components/ClusterStatusComponent.js"></script>
|
||||
<script src="./scripts/components/TopologyGraphComponent.js"></script>
|
||||
<script src="./scripts/components/MonitoringViewComponent.js"></script>
|
||||
<script src="./scripts/components/EventComponent.js"></script>
|
||||
<script src="./scripts/components/ComponentsLoader.js"></script>
|
||||
<script src="./scripts/theme-manager.js"></script>
|
||||
<script src="./scripts/app.js"></script>
|
||||
|
||||
@@ -17,7 +17,8 @@ document.addEventListener('DOMContentLoaded', async function() {
|
||||
const clusterFirmwareViewModel = new ClusterFirmwareViewModel();
|
||||
const topologyViewModel = new TopologyViewModel();
|
||||
const monitoringViewModel = new MonitoringViewModel();
|
||||
logger.debug('App: View models created:', { clusterViewModel, firmwareViewModel, clusterFirmwareViewModel, topologyViewModel, monitoringViewModel });
|
||||
const eventsViewModel = new EventViewModel();
|
||||
logger.debug('App: View models created:', { clusterViewModel, firmwareViewModel, clusterFirmwareViewModel, topologyViewModel, monitoringViewModel, eventsViewModel });
|
||||
|
||||
// Connect firmware view model to cluster data
|
||||
clusterViewModel.subscribe('members', (members) => {
|
||||
@@ -65,6 +66,7 @@ document.addEventListener('DOMContentLoaded', async function() {
|
||||
app.registerRoute('topology', TopologyGraphComponent, 'topology-view', topologyViewModel);
|
||||
app.registerRoute('firmware', FirmwareViewComponent, 'firmware-view', firmwareViewModel);
|
||||
app.registerRoute('monitoring', MonitoringViewComponent, 'monitoring-view', monitoringViewModel);
|
||||
app.registerRoute('events', EventComponent, 'events-view', eventsViewModel);
|
||||
logger.debug('App: Routes registered and components pre-initialized');
|
||||
|
||||
// Initialize cluster status component for header badge
|
||||
|
||||
653
public/scripts/components/EventComponent.js
Normal file
653
public/scripts/components/EventComponent.js
Normal file
@@ -0,0 +1,653 @@
|
||||
// Events Component - Visualizes websocket events as a graph
|
||||
class EventComponent extends Component {
|
||||
constructor(container, viewModel, eventBus) {
|
||||
super(container, viewModel, eventBus);
|
||||
|
||||
this.svg = null;
|
||||
this.simulation = null;
|
||||
this.zoom = null;
|
||||
this.width = 0;
|
||||
this.height = 0;
|
||||
this.isInitialized = false;
|
||||
|
||||
// Center node data - will be initialized with proper coordinates later
|
||||
this.centerNode = { id: 'center', type: 'center', label: 'Events', x: 0, y: 0 };
|
||||
|
||||
// Track nodes for D3
|
||||
this.graphNodes = [];
|
||||
this.graphLinks = [];
|
||||
|
||||
// Track recent events to trigger animations
|
||||
this.lastSeenEvents = new Set();
|
||||
}
|
||||
|
||||
updateDimensions() {
|
||||
const container = this.findElement('#events-graph-container');
|
||||
if (!container) {
|
||||
logger.error('EventComponent: Container not found!');
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = container.getBoundingClientRect();
|
||||
this.width = rect.width || 1400;
|
||||
this.height = rect.height || 800;
|
||||
|
||||
this.width = Math.max(this.width, 800);
|
||||
this.height = Math.max(this.height, 600);
|
||||
}
|
||||
|
||||
mount() {
|
||||
if (this.isMounted) return;
|
||||
|
||||
if (!this.isInitialized) {
|
||||
this.initialize();
|
||||
}
|
||||
|
||||
this.isMounted = true;
|
||||
this.setupEventListeners();
|
||||
this.setupViewModelListeners();
|
||||
this.render();
|
||||
}
|
||||
|
||||
initialize() {
|
||||
if (this.isInitialized) return;
|
||||
|
||||
// Create SVG
|
||||
const container = this.findElement('#events-graph-container');
|
||||
if (!container) {
|
||||
logger.error('EventComponent: Container not found');
|
||||
return;
|
||||
}
|
||||
|
||||
this.updateDimensions();
|
||||
|
||||
// Clear container
|
||||
container.innerHTML = '';
|
||||
|
||||
// Create SVG with D3 - match topology view style
|
||||
this.svg = d3.select(container)
|
||||
.append('svg')
|
||||
.attr('width', '100%')
|
||||
.attr('height', '100%')
|
||||
.attr('viewBox', `0 0 ${this.width} ${this.height}`)
|
||||
.style('border', '1px solid rgba(255, 255, 255, 0.1)')
|
||||
.style('border-radius', '12px');
|
||||
|
||||
// Create zoom behavior
|
||||
this.zoom = d3.zoom()
|
||||
.scaleExtent([0.5, 5])
|
||||
.on('zoom', (event) => {
|
||||
this.svg.select('g').attr('transform', event.transform);
|
||||
});
|
||||
|
||||
this.svg.call(this.zoom);
|
||||
|
||||
// Create main group for graph elements
|
||||
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)');
|
||||
|
||||
// Initialize simulation
|
||||
this.simulation = d3.forceSimulation()
|
||||
.force('link', d3.forceLink().id(d => d.id).distance(100))
|
||||
.force('charge', d3.forceManyBody().strength(-300))
|
||||
.force('center', d3.forceCenter(this.width / 2, this.height / 2))
|
||||
.force('collision', d3.forceCollide().radius(35))
|
||||
.alphaDecay(0.0228) // Slower decay to allow simulation to run longer
|
||||
.velocityDecay(0.4); // Higher velocity decay for smoother, less jumpy movement
|
||||
|
||||
this.isInitialized = true;
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
// Handle window resize
|
||||
this.addEventListener(window, 'resize', () => {
|
||||
if (this.resizeTimeout) clearTimeout(this.resizeTimeout);
|
||||
this.resizeTimeout = setTimeout(() => {
|
||||
this.handleResize();
|
||||
}, 250);
|
||||
});
|
||||
}
|
||||
|
||||
handleResize() {
|
||||
const container = this.findElement('#events-graph-container');
|
||||
if (container && this.svg) {
|
||||
this.updateDimensions();
|
||||
this.svg.attr('viewBox', `0 0 ${this.width} ${this.height}`);
|
||||
if (this.simulation) {
|
||||
this.simulation.force('center', d3.forceCenter(this.width / 2, this.height / 2));
|
||||
this.simulation.alpha(0.3).restart();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setupViewModelListeners() {
|
||||
// Listen for events changes
|
||||
this.subscribeToProperty('events', (newValue, previousValue) => {
|
||||
// Check for new or updated events to animate
|
||||
if (newValue && newValue.size > 0 && previousValue) {
|
||||
for (const [topic, data] of newValue) {
|
||||
const prevData = previousValue.get(topic);
|
||||
if (!prevData || data.count > prevData.count) {
|
||||
// New event or count increased - trigger animation
|
||||
this.animateEventMessage(topic, data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove empty state if it exists
|
||||
if (this.svg) {
|
||||
const g = this.svg.select('g');
|
||||
if (!g.empty()) {
|
||||
g.selectAll('.empty-state').remove();
|
||||
}
|
||||
}
|
||||
this.updateGraph();
|
||||
});
|
||||
|
||||
// Listen for lastUpdateTime changes
|
||||
this.subscribeToProperty('lastUpdateTime', () => {
|
||||
this.updateGraph();
|
||||
});
|
||||
}
|
||||
|
||||
updateGraph() {
|
||||
if (!this.isInitialized || !this.svg) {
|
||||
return;
|
||||
}
|
||||
|
||||
const events = this.viewModel.get('events');
|
||||
|
||||
// Get existing nodes from simulation to preserve positions
|
||||
const existingNodesMap = new Map();
|
||||
if (this.simulation && this.simulation.nodes) {
|
||||
this.simulation.nodes().forEach(node => {
|
||||
existingNodesMap.set(node.id, node);
|
||||
});
|
||||
}
|
||||
|
||||
// Create nodes map with center node (always present)
|
||||
const nodesMap = new Map();
|
||||
const centerX = this.width / 2;
|
||||
const centerY = this.height / 2;
|
||||
|
||||
const centerNodeWithPos = {
|
||||
...this.centerNode,
|
||||
x: centerX,
|
||||
y: centerY,
|
||||
fx: centerX, // Fix x position
|
||||
fy: centerY // Fix y position
|
||||
};
|
||||
nodesMap.set('center', centerNodeWithPos);
|
||||
|
||||
// Only process events if they exist
|
||||
if (events && events.size > 0) {
|
||||
// First pass: create all unique topic part nodes
|
||||
for (const [topic, data] of events) {
|
||||
data.parts.forEach((part, index) => {
|
||||
if (!nodesMap.has(part)) {
|
||||
const existingNode = existingNodesMap.get(part);
|
||||
|
||||
if (existingNode && existingNode.x && existingNode.y) {
|
||||
// Preserve existing position, including fixed state for center node
|
||||
nodesMap.set(part, {
|
||||
id: part,
|
||||
type: 'topic',
|
||||
label: part,
|
||||
count: 0,
|
||||
x: existingNode.x,
|
||||
y: existingNode.y,
|
||||
fx: existingNode.fx,
|
||||
fy: existingNode.fy
|
||||
});
|
||||
} else {
|
||||
// Initialize new node with random position
|
||||
const angle = Math.random() * Math.PI * 2;
|
||||
const distance = 150 + Math.random() * 100;
|
||||
nodesMap.set(part, {
|
||||
id: part,
|
||||
type: 'topic',
|
||||
label: part,
|
||||
count: 0,
|
||||
x: (this.width / 2) + Math.cos(angle) * distance,
|
||||
y: (this.height / 2) + Math.sin(angle) * distance,
|
||||
fx: null,
|
||||
fy: null
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Second pass: count occurrences of each part
|
||||
for (const [topic, data] of events) {
|
||||
data.parts.forEach(part => {
|
||||
const node = nodesMap.get(part);
|
||||
if (node) {
|
||||
node.count += data.count;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.graphNodes = Array.from(nodesMap.values());
|
||||
|
||||
// Build links as chains for each topic
|
||||
// For "cluster/update", create: center -> cluster -> update
|
||||
this.graphLinks = [];
|
||||
const linkSet = new Set(); // Track links to avoid duplicates
|
||||
|
||||
if (events && events.size > 0) {
|
||||
for (const [topic, data] of events) {
|
||||
const parts = data.parts;
|
||||
|
||||
if (parts.length === 0) continue;
|
||||
|
||||
// Connect center to first part
|
||||
const firstPart = parts[0];
|
||||
const centerToFirst = `center-${firstPart}`;
|
||||
if (!linkSet.has(centerToFirst)) {
|
||||
this.graphLinks.push({
|
||||
source: 'center',
|
||||
target: firstPart,
|
||||
type: 'center-link',
|
||||
count: data.count,
|
||||
topic: topic
|
||||
});
|
||||
linkSet.add(centerToFirst);
|
||||
} else {
|
||||
// Update count for existing link
|
||||
const existingLink = this.graphLinks.find(l =>
|
||||
`${l.source}-${l.target}` === centerToFirst
|
||||
);
|
||||
if (existingLink) {
|
||||
existingLink.count += data.count;
|
||||
}
|
||||
}
|
||||
|
||||
// Connect each part to the next (creating a chain)
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
const source = parts[i];
|
||||
const target = parts[i + 1];
|
||||
const linkKey = `${source}-${target}`;
|
||||
|
||||
if (!linkSet.has(linkKey)) {
|
||||
this.graphLinks.push({
|
||||
source: source,
|
||||
target: target,
|
||||
type: 'topic-link',
|
||||
topic: topic,
|
||||
count: data.count
|
||||
});
|
||||
linkSet.add(linkKey);
|
||||
} else {
|
||||
// Update count for existing link
|
||||
const existingLink = this.graphLinks.find(l =>
|
||||
`${l.source}-${l.target}` === linkKey
|
||||
);
|
||||
if (existingLink) {
|
||||
existingLink.count += data.count;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update D3 simulation
|
||||
this.updateSimulation();
|
||||
}
|
||||
|
||||
updateSimulation() {
|
||||
const g = this.svg.select('g');
|
||||
if (!g || g.empty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update links
|
||||
const links = g.selectAll('.link')
|
||||
.data(this.graphLinks, d => `${d.source}-${d.target}`);
|
||||
|
||||
// Remove old links
|
||||
links.exit()
|
||||
.transition()
|
||||
.duration(300)
|
||||
.style('opacity', 0)
|
||||
.remove();
|
||||
|
||||
// Add new links
|
||||
const linksEnter = links.enter()
|
||||
.append('line')
|
||||
.attr('class', d => `link ${d.type}`)
|
||||
.attr('stroke-opacity', 0);
|
||||
|
||||
// Merge and create linksUpdate
|
||||
const linksUpdate = linksEnter.merge(links);
|
||||
|
||||
// Add hover interactions to all links (existing ones will be updated)
|
||||
linksUpdate
|
||||
.on('mouseover', function(event, d) {
|
||||
d3.select(event.currentTarget)
|
||||
.attr('stroke-width', d => d.type === 'topic-link' ? Math.max(3, Math.min(5, Math.sqrt(d.count) + 2)) : 3.5)
|
||||
.attr('stroke-opacity', 0.9);
|
||||
})
|
||||
.on('mouseout', function(event, d) {
|
||||
d3.select(event.currentTarget)
|
||||
.attr('stroke-width', d => d.type === 'topic-link' ? Math.max(1.5, Math.min(4, Math.sqrt(d.count) + 1)) : 2.5)
|
||||
.attr('stroke-opacity', 0.7);
|
||||
});
|
||||
|
||||
// Update link attributes directly (no transitions to avoid event conflicts)
|
||||
linksUpdate
|
||||
.attr('stroke', d => {
|
||||
if (d.type === 'center-link') return '#3498db';
|
||||
return '#10b981';
|
||||
})
|
||||
.attr('stroke-opacity', 0.7)
|
||||
.attr('stroke-width', d => {
|
||||
if (d.type === 'topic-link') return Math.max(1.5, Math.min(4, Math.sqrt(d.count) + 1));
|
||||
return 2.5;
|
||||
});
|
||||
|
||||
// Update nodes
|
||||
const nodes = g.selectAll('.node')
|
||||
.data(this.graphNodes, d => d.id);
|
||||
|
||||
// Remove old nodes
|
||||
nodes.exit()
|
||||
.transition()
|
||||
.duration(300)
|
||||
.style('opacity', 0)
|
||||
.remove();
|
||||
|
||||
// Add new nodes
|
||||
const nodesEnter = nodes.enter()
|
||||
.append('g')
|
||||
.attr('class', d => `node ${d.type}`)
|
||||
.style('opacity', 0)
|
||||
.call(this.drag());
|
||||
|
||||
// Add circle
|
||||
nodesEnter.append('circle')
|
||||
.attr('r', d => d.type === 'center' ? 18 : 14)
|
||||
.attr('fill', d => {
|
||||
if (d.type === 'center') return '#3498db';
|
||||
return '#10b981';
|
||||
})
|
||||
.attr('stroke', '#fff')
|
||||
.attr('stroke-width', 2.5);
|
||||
|
||||
// Add text labels
|
||||
nodesEnter.append('text')
|
||||
.attr('dy', d => d.type === 'center' ? -28 : -20)
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('font-size', d => d.type === 'center' ? '16px' : '13px')
|
||||
.attr('fill', 'var(--text-primary)')
|
||||
.attr('font-weight', '600')
|
||||
.text(d => d.label);
|
||||
|
||||
// Merge and update all nodes
|
||||
const nodesUpdate = nodesEnter.merge(nodes);
|
||||
|
||||
// Update node visibility
|
||||
nodesUpdate.style('opacity', 1);
|
||||
|
||||
// Update circles
|
||||
nodesUpdate.select('circle')
|
||||
.attr('r', d => d.type === 'center' ? 18 : 14)
|
||||
.attr('fill', d => {
|
||||
if (d.type === 'center') return '#3498db';
|
||||
return '#10b981';
|
||||
});
|
||||
|
||||
// Update text labels
|
||||
nodesUpdate.select('text')
|
||||
.text(d => d.label);
|
||||
|
||||
// Remove any existing count badges
|
||||
nodesUpdate.selectAll('.count-badge').remove();
|
||||
|
||||
// Update simulation
|
||||
this.simulation.nodes(this.graphNodes);
|
||||
|
||||
// Only add links force if there are links
|
||||
if (this.graphLinks.length > 0) {
|
||||
const linkForce = this.simulation.force('link');
|
||||
if (linkForce) {
|
||||
linkForce.links(this.graphLinks);
|
||||
} else {
|
||||
// Create the link force if it doesn't exist
|
||||
this.simulation.force('link', d3.forceLink().id(d => d.id).distance(100));
|
||||
this.simulation.force('link').links(this.graphLinks);
|
||||
}
|
||||
} else {
|
||||
// Remove link force if no links
|
||||
this.simulation.force('link', null);
|
||||
}
|
||||
|
||||
// Update positions on tick
|
||||
this.simulation.on('tick', () => {
|
||||
if (linksUpdate && this.graphLinks.length > 0) {
|
||||
linksUpdate.each(function(d) {
|
||||
const link = d3.select(this);
|
||||
|
||||
// Calculate direction from source to target
|
||||
const dx = d.target.x - d.source.x;
|
||||
const dy = d.target.y - d.source.y;
|
||||
const length = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
if (length > 0) {
|
||||
// Normalize direction vector
|
||||
const nx = dx / length;
|
||||
const ny = dy / length;
|
||||
|
||||
// Edge radius based on target node type
|
||||
const targetRadius = d.target.type === 'center' ? 18 : 14;
|
||||
const sourceRadius = d.source.type === 'center' ? 18 : 14;
|
||||
|
||||
// Start from edge of source node
|
||||
const x1 = d.source.x + nx * sourceRadius;
|
||||
const y1 = d.source.y + ny * sourceRadius;
|
||||
|
||||
// End at edge of target node
|
||||
const x2 = d.target.x - nx * targetRadius;
|
||||
const y2 = d.target.y - ny * targetRadius;
|
||||
|
||||
link.attr('x1', x1)
|
||||
.attr('y1', y1)
|
||||
.attr('x2', x2)
|
||||
.attr('y2', y2);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
nodesUpdate
|
||||
.attr('transform', d => `translate(${d.x},${d.y})`);
|
||||
});
|
||||
|
||||
// Use lower alpha to avoid jumping - just tweak positions gently
|
||||
this.simulation.alpha(0.3).restart();
|
||||
}
|
||||
|
||||
drag() {
|
||||
const simulation = this.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 = d.x;
|
||||
d.fy = d.y;
|
||||
}
|
||||
|
||||
return d3.drag()
|
||||
.on('start', dragstarted)
|
||||
.on('drag', dragged)
|
||||
.on('end', dragended);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.isInitialized) {
|
||||
this.initialize();
|
||||
}
|
||||
|
||||
const events = this.viewModel.get('events');
|
||||
|
||||
// If no events exist yet, show an empty state message
|
||||
if (!events || events.size === 0) {
|
||||
this.showEmptyState();
|
||||
} else {
|
||||
// Remove empty state if it exists
|
||||
const g = this.svg.select('g');
|
||||
if (!g.empty()) {
|
||||
g.selectAll('.empty-state').remove();
|
||||
}
|
||||
}
|
||||
|
||||
// Always call updateGraph to show at least the center node
|
||||
this.updateGraph();
|
||||
}
|
||||
|
||||
showEmptyState() {
|
||||
if (!this.isInitialized || !this.svg) return;
|
||||
|
||||
const g = this.svg.select('g');
|
||||
if (g.empty()) return;
|
||||
|
||||
// Remove any existing message
|
||||
g.selectAll('.empty-state').remove();
|
||||
|
||||
// Account for the initial zoom transform (scale(1.4) translate(-200, -150))
|
||||
// We need to place the text in the center of the transformed coordinate space
|
||||
const transformX = -200;
|
||||
const transformY = -150;
|
||||
const scale = 1.4;
|
||||
|
||||
// Calculate centered position in transformed space
|
||||
const x = ((this.width / 2) - transformX) / scale;
|
||||
const y = ((this.height / 2) - transformY) / scale;
|
||||
|
||||
const emptyMsg = g.append('text')
|
||||
.attr('class', 'empty-state')
|
||||
.attr('x', x)
|
||||
.attr('y', y)
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('font-size', '18px')
|
||||
.attr('fill', 'var(--text-secondary)')
|
||||
.attr('font-weight', '500')
|
||||
.text('Waiting for websocket events...');
|
||||
}
|
||||
|
||||
onPause() {
|
||||
if (this.simulation) {
|
||||
this.simulation.stop();
|
||||
}
|
||||
}
|
||||
|
||||
onResume() {
|
||||
if (this.simulation) {
|
||||
this.simulation.alpha(0.3).restart();
|
||||
}
|
||||
}
|
||||
|
||||
shouldRenderOnResume() {
|
||||
return false;
|
||||
}
|
||||
|
||||
animateEventMessage(topic, data) {
|
||||
const g = this.svg.select('g');
|
||||
if (g.empty()) return;
|
||||
|
||||
const parts = data.parts || topic.split('/').filter(p => p);
|
||||
if (parts.length === 0) return;
|
||||
|
||||
// Wait for the next tick to ensure nodes exist
|
||||
setTimeout(() => {
|
||||
// Build the chain: center -> first -> second -> ... -> last
|
||||
const chain = ['center', ...parts];
|
||||
|
||||
// Animate along each segment of the chain
|
||||
let delay = 0;
|
||||
for (let i = 0; i < chain.length - 1; i++) {
|
||||
const sourceId = chain[i];
|
||||
const targetId = chain[i + 1];
|
||||
|
||||
const sourceNode = this.graphNodes.find(n => n.id === sourceId);
|
||||
const targetNode = this.graphNodes.find(n => n.id === targetId);
|
||||
|
||||
if (!sourceNode || !targetNode) continue;
|
||||
|
||||
setTimeout(() => {
|
||||
this.animateDotAlongLink(sourceNode, targetNode, g);
|
||||
}, delay);
|
||||
|
||||
// Add delay between segments (staggered animation)
|
||||
delay += 400; // Duration (300ms) + small gap (100ms)
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
animateDotAlongLink(sourceNode, targetNode, g) {
|
||||
if (!sourceNode || !targetNode || !g) return;
|
||||
|
||||
// Calculate positions
|
||||
const dx = targetNode.x - sourceNode.x;
|
||||
const dy = targetNode.y - sourceNode.y;
|
||||
const length = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
if (length === 0) return;
|
||||
|
||||
// Normalize direction
|
||||
const nx = dx / length;
|
||||
const ny = dy / length;
|
||||
|
||||
// Calculate node radii
|
||||
const sourceRadius = sourceNode.type === 'center' ? 18 : 14;
|
||||
const targetRadius = targetNode.type === 'center' ? 18 : 14;
|
||||
|
||||
// Start from edge of source node
|
||||
const startX = sourceNode.x + nx * sourceRadius;
|
||||
const startY = sourceNode.y + ny * sourceRadius;
|
||||
|
||||
// End at edge of target node
|
||||
const endX = targetNode.x - nx * targetRadius;
|
||||
const endY = targetNode.y - ny * targetRadius;
|
||||
|
||||
// Create animation dot
|
||||
const animationGroup = g.append('g').attr('class', 'animation-group');
|
||||
|
||||
const dot = animationGroup.append('circle')
|
||||
.attr('r', 4)
|
||||
.attr('fill', '#FFD700')
|
||||
.attr('stroke', '#fff')
|
||||
.attr('stroke-width', 2)
|
||||
.attr('cx', startX)
|
||||
.attr('cy', startY);
|
||||
|
||||
// Animate the dot along the path
|
||||
dot.transition()
|
||||
.duration(300)
|
||||
.ease(d3.easeLinear)
|
||||
.attr('cx', endX)
|
||||
.attr('cy', endY)
|
||||
.on('end', function() {
|
||||
// Fade out after reaching destination
|
||||
dot.transition()
|
||||
.duration(100)
|
||||
.style('opacity', 0)
|
||||
.remove();
|
||||
animationGroup.remove();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
window.EventComponent = EventComponent;
|
||||
@@ -1235,4 +1235,147 @@ class WiFiConfigViewModel extends ViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
window.WiFiConfigViewModel = WiFiConfigViewModel;
|
||||
window.WiFiConfigViewModel = WiFiConfigViewModel;
|
||||
|
||||
// Events View Model for websocket event visualization
|
||||
class EventViewModel extends ViewModel {
|
||||
constructor() {
|
||||
super();
|
||||
this.setMultiple({
|
||||
events: new Map(), // Map of topic -> { parts: [], count: number, lastSeen: timestamp }
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastUpdateTime: null
|
||||
});
|
||||
|
||||
// Set up WebSocket listeners for real-time updates
|
||||
this.setupWebSocketListeners();
|
||||
}
|
||||
|
||||
// Set up WebSocket event listeners
|
||||
setupWebSocketListeners() {
|
||||
if (!window.wsClient) {
|
||||
// Retry after a short delay to allow wsClient to initialize
|
||||
setTimeout(() => this.setupWebSocketListeners(), 1000);
|
||||
return;
|
||||
}
|
||||
|
||||
// Listen for all websocket messages
|
||||
window.wsClient.on('message', (data) => {
|
||||
const topic = data.topic || data.type;
|
||||
|
||||
if (topic) {
|
||||
this.addTopic(topic, data);
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for connection status changes
|
||||
window.wsClient.on('connected', () => {
|
||||
logger.info('EventViewModel: WebSocket connected');
|
||||
});
|
||||
|
||||
window.wsClient.on('disconnected', () => {
|
||||
logger.debug('EventViewModel: WebSocket disconnected');
|
||||
});
|
||||
}
|
||||
|
||||
// Add a topic (parsed by "/" separator)
|
||||
addTopic(topic, data = null) {
|
||||
// Get current events as a new Map to ensure change detection
|
||||
const events = new Map(this.get('events'));
|
||||
|
||||
// Handle nested events from cluster/event
|
||||
let fullTopic = topic;
|
||||
if (topic === 'cluster/event' && data && data.data) {
|
||||
try {
|
||||
const parsedData = typeof data.data === 'string' ? JSON.parse(data.data) : data.data;
|
||||
if (parsedData && parsedData.event) {
|
||||
// Create nested topic chain: cluster/event/api/neopattern
|
||||
fullTopic = `${topic}/${parsedData.event}`;
|
||||
}
|
||||
} catch (e) {
|
||||
// If parsing fails, just use the original topic
|
||||
}
|
||||
}
|
||||
|
||||
const parts = fullTopic.split('/').filter(p => p);
|
||||
|
||||
if (events.has(fullTopic)) {
|
||||
// Update existing event - create new object to ensure change detection
|
||||
const existing = events.get(fullTopic);
|
||||
events.set(fullTopic, {
|
||||
topic: existing.topic,
|
||||
parts: existing.parts,
|
||||
count: existing.count + 1,
|
||||
firstSeen: existing.firstSeen,
|
||||
lastSeen: new Date().toISOString(),
|
||||
lastData: data
|
||||
});
|
||||
} else {
|
||||
// Create new event entry
|
||||
events.set(fullTopic, {
|
||||
topic: fullTopic,
|
||||
parts: parts,
|
||||
count: 1,
|
||||
firstSeen: new Date().toISOString(),
|
||||
lastSeen: new Date().toISOString(),
|
||||
lastData: data
|
||||
});
|
||||
}
|
||||
|
||||
// Use set to trigger change notification
|
||||
this.set('events', events);
|
||||
this.set('lastUpdateTime', new Date().toISOString());
|
||||
}
|
||||
|
||||
// Get all topic parts (unique segments after splitting by "/")
|
||||
getTopicParts() {
|
||||
const events = this.get('events');
|
||||
const allParts = new Set();
|
||||
|
||||
for (const [topic, data] of events) {
|
||||
data.parts.forEach(part => allParts.add(part));
|
||||
}
|
||||
|
||||
return Array.from(allParts).map(part => {
|
||||
// Count how many events contain this part
|
||||
let count = 0;
|
||||
for (const [topic, data] of events) {
|
||||
if (data.parts.includes(part)) {
|
||||
count += data.count;
|
||||
}
|
||||
}
|
||||
|
||||
return { part, count };
|
||||
});
|
||||
}
|
||||
|
||||
// Clear all events
|
||||
clearTopics() {
|
||||
this.set('events', new Map());
|
||||
this.set('lastUpdateTime', new Date().toISOString());
|
||||
}
|
||||
|
||||
// Get connections between topic parts (for graph edges)
|
||||
getTopicConnections() {
|
||||
const topics = this.get('topics');
|
||||
const connections = [];
|
||||
|
||||
for (const [topic, data] of topics) {
|
||||
const parts = data.parts;
|
||||
// Create connections between adjacent parts
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
connections.push({
|
||||
source: parts[i],
|
||||
target: parts[i + 1],
|
||||
topic: topic,
|
||||
count: data.count
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return connections;
|
||||
}
|
||||
}
|
||||
|
||||
window.EventViewModel = EventViewModel;
|
||||
@@ -559,11 +559,12 @@ p {
|
||||
}
|
||||
|
||||
/* Topology graph node interactions */
|
||||
#topology-graph-container .node {
|
||||
#topology-graph-container .node, #events-graph-container .node {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#topology-graph-container .node:hover circle:first-child {
|
||||
#topology-graph-container .node:hover circle:first-child,
|
||||
#events-graph-container .node:hover circle:first-child {
|
||||
filter: brightness(1.2);
|
||||
}
|
||||
|
||||
@@ -4335,7 +4336,7 @@ select.param-input:focus {
|
||||
width: 100%; /* Use full container width */
|
||||
}
|
||||
|
||||
#topology-graph-container {
|
||||
#topology-graph-container, #events-graph-container {
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 12px;
|
||||
height: 100%;
|
||||
@@ -4351,24 +4352,27 @@ select.param-input:focus {
|
||||
max-height: 100%; /* Ensure it doesn't exceed parent height */
|
||||
}
|
||||
|
||||
#topology-graph-container svg {
|
||||
#topology-graph-container svg, #events-graph-container svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#topology-graph-container .loading,
|
||||
#topology-graph-container .error,
|
||||
#topology-graph-container .no-data {
|
||||
#topology-graph-container .no-data,
|
||||
#events-graph-container .loading,
|
||||
#events-graph-container .error,
|
||||
#events-graph-container .no-data {
|
||||
text-align: center;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
#topology-graph-container .error {
|
||||
#topology-graph-container .error, #events-graph-container .error {
|
||||
color: var(--accent-error);
|
||||
}
|
||||
|
||||
#topology-graph-container .no-data {
|
||||
#topology-graph-container .no-data, #events-graph-container .no-data {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user