Compare commits
4 Commits
f6dc4e8bf3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 09052cbfc2 | |||
| 2a7d170824 | |||
| cbe13f1d84 | |||
| 65b491640b |
11
.dockerignore
Normal file
11
.dockerignore
Normal file
@@ -0,0 +1,11 @@
|
||||
.git
|
||||
.gitignore
|
||||
.cursor
|
||||
*.md
|
||||
node_modules
|
||||
README.md
|
||||
docs
|
||||
test
|
||||
openapitools.json
|
||||
*.backup
|
||||
|
||||
41
Dockerfile
Normal file
41
Dockerfile
Normal file
@@ -0,0 +1,41 @@
|
||||
# Build stage
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci --only=production
|
||||
|
||||
# Runtime stage
|
||||
FROM node:20-alpine
|
||||
|
||||
# Install wget for health checks
|
||||
RUN apk --no-cache add wget
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy dependencies from builder
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
|
||||
# Copy application files
|
||||
COPY package*.json ./
|
||||
COPY index.js index-standalone.js ./
|
||||
COPY public ./public
|
||||
COPY src ./src
|
||||
|
||||
# Create non-root user (let Alpine assign available GID/UID)
|
||||
RUN addgroup spore && \
|
||||
adduser -D -s /bin/sh -G spore spore && \
|
||||
chown -R spore:spore /app
|
||||
|
||||
USER spore
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3000
|
||||
|
||||
# Run the application
|
||||
CMD ["node", "index.js"]
|
||||
|
||||
54
Makefile
Normal file
54
Makefile
Normal file
@@ -0,0 +1,54 @@
|
||||
.PHONY: install build run clean docker-build docker-run docker-push docker-build-multiarch docker-push-multiarch
|
||||
|
||||
# Install dependencies
|
||||
install:
|
||||
npm install
|
||||
|
||||
# Build the application (if needed)
|
||||
build: install
|
||||
|
||||
# Run the application
|
||||
run:
|
||||
node index.js
|
||||
|
||||
# Start in development mode
|
||||
dev:
|
||||
node index.js
|
||||
|
||||
# Clean build artifacts
|
||||
clean:
|
||||
rm -rf node_modules
|
||||
rm -f package-lock.json
|
||||
|
||||
# Docker variables
|
||||
DOCKER_REGISTRY ?=
|
||||
IMAGE_NAME = wirelos/spore-ui
|
||||
IMAGE_TAG ?= latest
|
||||
FULL_IMAGE_NAME = $(if $(DOCKER_REGISTRY),$(DOCKER_REGISTRY)/$(IMAGE_NAME),$(IMAGE_NAME)):$(IMAGE_TAG)
|
||||
|
||||
# Build Docker image
|
||||
docker-build:
|
||||
docker build -t $(FULL_IMAGE_NAME) .
|
||||
|
||||
# Run Docker container
|
||||
docker-run:
|
||||
docker run -p 3000:3000 --rm $(FULL_IMAGE_NAME)
|
||||
|
||||
# Push Docker image
|
||||
docker-push:
|
||||
docker push $(FULL_IMAGE_NAME)
|
||||
|
||||
# Build multiarch Docker image
|
||||
docker-build-multiarch:
|
||||
docker buildx build --platform linux/amd64,linux/arm64 \
|
||||
-t $(FULL_IMAGE_NAME) \
|
||||
--push \
|
||||
.
|
||||
|
||||
# Push multiarch Docker image (if not pushed during build)
|
||||
docker-push-multiarch:
|
||||
docker buildx build --platform linux/amd64,linux/arm64 \
|
||||
-t $(FULL_IMAGE_NAME) \
|
||||
--push \
|
||||
.
|
||||
|
||||
@@ -25,6 +25,7 @@ This frontend server works together with the **SPORE Gateway** (spore-gateway) b
|
||||

|
||||
### Events
|
||||

|
||||

|
||||
### Monitoring
|
||||

|
||||
### Firmware
|
||||
|
||||
BIN
assets/events-messages.png
Normal file
BIN
assets/events-messages.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 453 KiB |
@@ -19,6 +19,12 @@ class EventComponent extends Component {
|
||||
|
||||
// Track recent events to trigger animations
|
||||
this.lastSeenEvents = new Set();
|
||||
|
||||
// Track rendered message IDs for animation
|
||||
this.renderedMessageIds = new Set();
|
||||
|
||||
// Track selected node for highlighting
|
||||
this.selectedNodeId = null;
|
||||
}
|
||||
|
||||
updateDimensions() {
|
||||
@@ -414,15 +420,25 @@ class EventComponent extends Component {
|
||||
.style('opacity', 0)
|
||||
.call(this.drag());
|
||||
|
||||
// Add circle
|
||||
nodesEnter.append('circle')
|
||||
// Add circle with click handler for non-center nodes
|
||||
const circlesEnter = 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);
|
||||
.attr('stroke-width', 2.5)
|
||||
.style('cursor', d => d.type === 'center' ? 'default' : 'pointer');
|
||||
|
||||
// Add outer ring for highlighted nodes
|
||||
nodesEnter.append('circle')
|
||||
.attr('class', 'node-highlight')
|
||||
.attr('r', d => d.type === 'center' ? 22 : 18)
|
||||
.attr('fill', 'none')
|
||||
.attr('stroke', '#FFD700')
|
||||
.attr('stroke-width', 3)
|
||||
.attr('opacity', 0);
|
||||
|
||||
// Add text labels
|
||||
nodesEnter.append('text')
|
||||
@@ -433,20 +449,37 @@ class EventComponent extends Component {
|
||||
.attr('font-weight', '600')
|
||||
.text(d => d.label);
|
||||
|
||||
// Add click handler to open drawer for topic nodes
|
||||
nodesEnter.on('click', (event, d) => {
|
||||
if (d.type !== 'center' && d.id) {
|
||||
this.selectedNodeId = d.id;
|
||||
// Update the graph to show highlight
|
||||
if (this.svg) {
|
||||
this.updateSimulation();
|
||||
}
|
||||
this.openTopicDrawer(d.id);
|
||||
}
|
||||
});
|
||||
|
||||
// Merge and update all nodes
|
||||
const nodesUpdate = nodesEnter.merge(nodes);
|
||||
|
||||
// Update node visibility
|
||||
nodesUpdate.style('opacity', 1);
|
||||
|
||||
// Update circles
|
||||
nodesUpdate.select('circle')
|
||||
// Update circles (excluding highlight ring)
|
||||
nodesUpdate.select('circle:not(.node-highlight)')
|
||||
.attr('r', d => d.type === 'center' ? 18 : 14)
|
||||
.attr('fill', d => {
|
||||
if (d.type === 'center') return '#3498db';
|
||||
return '#10b981';
|
||||
});
|
||||
|
||||
// Update highlight rings
|
||||
nodesUpdate.select('.node-highlight')
|
||||
.attr('opacity', d => d.id === this.selectedNodeId ? 1 : 0)
|
||||
.classed('highlighted', d => d.id === this.selectedNodeId);
|
||||
|
||||
// Update text labels
|
||||
nodesUpdate.select('text')
|
||||
.text(d => d.label);
|
||||
@@ -807,6 +840,289 @@ class EventComponent extends Component {
|
||||
animationGroup.remove();
|
||||
});
|
||||
}
|
||||
|
||||
// Get topics that contain this node ID (part)
|
||||
getTopicsForNodeId(nodeId) {
|
||||
const events = this.viewModel.get('events');
|
||||
const topics = [];
|
||||
|
||||
for (const [topic, data] of events) {
|
||||
if (data.parts && data.parts.includes(nodeId)) {
|
||||
topics.push(topic);
|
||||
}
|
||||
}
|
||||
|
||||
return topics;
|
||||
}
|
||||
|
||||
// Open drawer to show messages for a topic node
|
||||
openTopicDrawer(nodeId) {
|
||||
// Find topics that contain this node
|
||||
const topics = this.getTopicsForNodeId(nodeId);
|
||||
|
||||
if (topics.length === 0) {
|
||||
logger.warn('EventComponent: No topics found for node', nodeId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Store all matching topics
|
||||
this._selectedNodeId = nodeId;
|
||||
this._selectedTopics = topics;
|
||||
|
||||
// Set selected topic in view model (first topic for backwards compatibility)
|
||||
this.viewModel.setSelectedTopic(topics[0]);
|
||||
|
||||
// Update highlight state
|
||||
this.selectedNodeId = nodeId;
|
||||
this.updateSimulation();
|
||||
|
||||
// Open drawer with all matching topics
|
||||
this.openMessagesDrawer(nodeId, topics);
|
||||
}
|
||||
|
||||
// Open the messages drawer
|
||||
openMessagesDrawer(nodeId, topics) {
|
||||
if (!window.DrawerComponent) {
|
||||
logger.error('EventComponent: DrawerComponent not available');
|
||||
return;
|
||||
}
|
||||
|
||||
const drawer = new window.DrawerComponent();
|
||||
|
||||
const title = topics.length === 1
|
||||
? `Live Messages: ${topics[0]}`
|
||||
: `Live Messages: ${nodeId} (${topics.length} topics)`;
|
||||
|
||||
drawer.openDrawer(
|
||||
title,
|
||||
(container) => {
|
||||
this.renderMessageList(container, nodeId, topics);
|
||||
},
|
||||
null,
|
||||
() => {
|
||||
// On close callback - cleanup
|
||||
if (this._messageUnsubscribe) {
|
||||
this._messageUnsubscribe();
|
||||
this._messageUnsubscribe = null;
|
||||
}
|
||||
this._selectedNodeId = null;
|
||||
this._selectedTopics = null;
|
||||
this.selectedNodeId = null;
|
||||
// Update graph to remove highlight
|
||||
this.updateSimulation();
|
||||
this.viewModel.clearSelectedTopic();
|
||||
},
|
||||
true // Hide terminal button for events drawer
|
||||
);
|
||||
}
|
||||
|
||||
// Render message list in the drawer
|
||||
renderMessageList(container, nodeId, topics) {
|
||||
// Clear loading state
|
||||
container.innerHTML = '';
|
||||
|
||||
// Create scrollable container
|
||||
const messagesContainer = document.createElement('div');
|
||||
messagesContainer.className = 'topic-messages-container';
|
||||
|
||||
// Create header info
|
||||
const header = document.createElement('div');
|
||||
header.className = 'message-list-header';
|
||||
|
||||
// Build topic display
|
||||
let topicDisplay = '';
|
||||
if (topics.length === 1) {
|
||||
topicDisplay = `<code class="topic-code">${topics[0]}</code>`;
|
||||
} else {
|
||||
topicDisplay = `
|
||||
${topics.map(t => `<code class="topic-code" style="margin-right: 4px;">${t}</code>`).join('')}
|
||||
<br><small class="topic-multi-indicator">(${topics.length} topics for node: ${nodeId})</small>
|
||||
`;
|
||||
}
|
||||
|
||||
header.innerHTML = `
|
||||
<div class="message-list-header-title">
|
||||
Topics: ${topicDisplay}
|
||||
</div>
|
||||
<div class="message-list-header-subtitle">
|
||||
Showing live websocket messages for these topics
|
||||
</div>
|
||||
`;
|
||||
messagesContainer.appendChild(header);
|
||||
|
||||
// Message list
|
||||
const messageList = document.createElement('div');
|
||||
messageList.id = `message-list-${nodeId}`;
|
||||
messageList.className = 'message-list';
|
||||
messagesContainer.appendChild(messageList);
|
||||
|
||||
container.appendChild(messagesContainer);
|
||||
|
||||
// Initial render
|
||||
this.updateMessageList(nodeId, topics);
|
||||
|
||||
// Subscribe to allMessagesCounter changes to update in real-time
|
||||
// Store unsubscribe function for cleanup
|
||||
const updateHandler = () => {
|
||||
this.updateMessageList(nodeId, topics);
|
||||
};
|
||||
|
||||
// Subscribe to the counter which changes on every new message
|
||||
this._messageUnsubscribe = this.viewModel.subscribe('allMessagesCounter', updateHandler);
|
||||
}
|
||||
|
||||
// Update the message list display
|
||||
updateMessageList(nodeId, topics) {
|
||||
const messageList = document.getElementById(`message-list-${nodeId}`);
|
||||
if (!messageList) return;
|
||||
|
||||
// Get messages for all topics
|
||||
const allMessages = [];
|
||||
topics.forEach(topic => {
|
||||
const messages = this.viewModel.getMessagesForTopic(topic);
|
||||
allMessages.push(...messages);
|
||||
});
|
||||
|
||||
// Sort by timestamp (newest first)
|
||||
allMessages.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
|
||||
|
||||
// Clear existing content
|
||||
messageList.innerHTML = '';
|
||||
|
||||
if (allMessages.length === 0) {
|
||||
const emptyMsg = document.createElement('div');
|
||||
emptyMsg.className = 'empty-message-state';
|
||||
emptyMsg.textContent = 'No messages yet. Waiting for websocket events...';
|
||||
messageList.appendChild(emptyMsg);
|
||||
return;
|
||||
}
|
||||
|
||||
// Show messages (newest at top)
|
||||
allMessages.forEach((msg, index) => {
|
||||
// Check if this is a new message (not previously rendered)
|
||||
const isNewMessage = !this.renderedMessageIds.has(msg.id);
|
||||
|
||||
const messageDiv = document.createElement('div');
|
||||
messageDiv.className = 'message-item';
|
||||
if (isNewMessage) {
|
||||
// Add animation class for new messages
|
||||
messageDiv.classList.add('message-item-new');
|
||||
}
|
||||
|
||||
// Timestamp
|
||||
const timestamp = new Date(msg.timestamp);
|
||||
const timeStr = timestamp.toLocaleTimeString();
|
||||
|
||||
// Get full message data (exclude internal properties)
|
||||
const messageData = this.getDisplayableMessageData(msg);
|
||||
|
||||
// Format message for display
|
||||
let displayText = '';
|
||||
if (messageData && Object.keys(messageData).length > 0) {
|
||||
try {
|
||||
displayText = JSON.stringify(messageData, null, 2);
|
||||
} catch (e) {
|
||||
displayText = String(messageData);
|
||||
}
|
||||
} else {
|
||||
displayText = '(empty message)';
|
||||
}
|
||||
|
||||
const topicIndicator = topics.length > 1 ? ` - ${msg.topic || 'unknown'}` : '';
|
||||
|
||||
messageDiv.innerHTML = `
|
||||
<div class="message-item-header">
|
||||
<div class="message-id">#${allMessages.length - index}${topicIndicator}</div>
|
||||
<div class="message-timestamp">${timeStr}</div>
|
||||
</div>
|
||||
<pre class="message-content">${this.escapeHtml(displayText)}</pre>
|
||||
`;
|
||||
|
||||
messageList.appendChild(messageDiv);
|
||||
|
||||
// Mark as rendered
|
||||
this.renderedMessageIds.add(msg.id);
|
||||
});
|
||||
|
||||
// Auto-scroll to top (newest messages)
|
||||
messageList.scrollTop = 0;
|
||||
}
|
||||
|
||||
// Get displayable message data (exclude internal properties)
|
||||
getDisplayableMessageData(msg) {
|
||||
// Clone the message and exclude internal properties
|
||||
const data = { ...msg };
|
||||
delete data.id;
|
||||
delete data.timestamp;
|
||||
delete data.topic;
|
||||
|
||||
// If the message has a 'data' property, that's usually what we want to show
|
||||
// Otherwise show all properties
|
||||
if (data.data !== undefined) {
|
||||
return this.unescapeJson(data.data);
|
||||
}
|
||||
|
||||
return this.unescapeJson(data);
|
||||
}
|
||||
|
||||
// Recursively unescape JSON strings
|
||||
unescapeJson(obj, depth = 0) {
|
||||
// Prevent infinite recursion
|
||||
if (depth > 10) return obj;
|
||||
|
||||
// If it's a string, try to parse as JSON
|
||||
if (typeof obj === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(obj);
|
||||
// If parsing succeeded and it's an object/array, recursively unescape
|
||||
if (typeof parsed === 'object' && parsed !== null) {
|
||||
return this.unescapeJson(parsed, depth + 1);
|
||||
}
|
||||
return parsed;
|
||||
} catch (e) {
|
||||
// Not a valid JSON string, return as-is
|
||||
return obj;
|
||||
}
|
||||
}
|
||||
|
||||
// If it's an array, process each element
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map(item => this.unescapeJson(item, depth + 1));
|
||||
}
|
||||
|
||||
// If it's an object, process each property
|
||||
if (obj && typeof obj === 'object') {
|
||||
const result = {};
|
||||
for (const key in obj) {
|
||||
if (obj.hasOwnProperty(key)) {
|
||||
result[key] = this.unescapeJson(obj[key], depth + 1);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Not a string, array, or object - return as-is
|
||||
return obj;
|
||||
}
|
||||
|
||||
// Escape HTML to prevent XSS
|
||||
escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// Cleanup method
|
||||
unmount() {
|
||||
// Unsubscribe from messages if subscribed
|
||||
if (this._messageUnsubscribe) {
|
||||
this._messageUnsubscribe();
|
||||
this._messageUnsubscribe = null;
|
||||
}
|
||||
|
||||
// Call parent unmount
|
||||
super.unmount();
|
||||
}
|
||||
}
|
||||
|
||||
window.EventComponent = EventComponent;
|
||||
|
||||
@@ -1243,6 +1243,9 @@ class EventViewModel extends ViewModel {
|
||||
super();
|
||||
this.setMultiple({
|
||||
events: new Map(), // Map of topic -> { parts: [], count: number, lastSeen: timestamp }
|
||||
allMessages: [], // Array of all messages for live viewing
|
||||
allMessagesCounter: 0, // Counter to trigger updates
|
||||
selectedTopic: null, // Currently selected topic for drawer
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastUpdateTime: null
|
||||
@@ -1265,6 +1268,26 @@ class EventViewModel extends ViewModel {
|
||||
const topic = data.topic || data.type;
|
||||
|
||||
if (topic) {
|
||||
// Store the full message
|
||||
const allMessages = this.get('allMessages') || [];
|
||||
const newMessage = {
|
||||
...data,
|
||||
topic: topic,
|
||||
timestamp: new Date().toISOString(),
|
||||
id: `${topic}-${Date.now()}-${Math.random()}`
|
||||
};
|
||||
allMessages.push(newMessage);
|
||||
|
||||
// Keep only the last 1000 messages to prevent memory issues
|
||||
const maxMessages = 1000;
|
||||
if (allMessages.length > maxMessages) {
|
||||
allMessages.splice(0, allMessages.length - maxMessages);
|
||||
}
|
||||
|
||||
// Update messages and trigger change notification
|
||||
this.set('allMessages', allMessages);
|
||||
this.set('allMessagesCounter', this.get('allMessagesCounter') + 1);
|
||||
|
||||
this.addTopic(topic, data);
|
||||
}
|
||||
});
|
||||
@@ -1278,6 +1301,36 @@ class EventViewModel extends ViewModel {
|
||||
logger.debug('EventViewModel: WebSocket disconnected');
|
||||
});
|
||||
}
|
||||
|
||||
// Get messages for a specific topic
|
||||
getMessagesForTopic(topic) {
|
||||
const allMessages = this.get('allMessages') || [];
|
||||
return allMessages.filter(msg => {
|
||||
// Handle nested events from cluster/event
|
||||
if (msg.topic === 'cluster/event' && msg.data) {
|
||||
try {
|
||||
const parsedData = typeof msg.data === 'string' ? JSON.parse(msg.data) : msg.data;
|
||||
if (parsedData && parsedData.event) {
|
||||
const fullTopic = `${msg.topic}/${parsedData.event}`;
|
||||
return fullTopic === topic;
|
||||
}
|
||||
} catch (e) {
|
||||
// If parsing fails, just use the original topic
|
||||
}
|
||||
}
|
||||
return msg.topic === topic;
|
||||
});
|
||||
}
|
||||
|
||||
// Set the selected topic for drawer
|
||||
setSelectedTopic(topic) {
|
||||
this.set('selectedTopic', topic);
|
||||
}
|
||||
|
||||
// Clear selected topic
|
||||
clearSelectedTopic() {
|
||||
this.set('selectedTopic', null);
|
||||
}
|
||||
|
||||
// Add a topic (parsed by "/" separator)
|
||||
addTopic(topic, data = null) {
|
||||
|
||||
@@ -3,6 +3,127 @@
|
||||
transform: rotate(-90deg);
|
||||
transform-origin: 50% 50%;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
MESSAGE LIST STYLES FOR EVENTS DRAWER
|
||||
======================================== */
|
||||
|
||||
.topic-messages-container {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.message-list-header {
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid var(--border-color, rgba(255, 255, 255, 0.1));
|
||||
}
|
||||
|
||||
.message-list-header-title {
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.message-list-header-subtitle {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.topic-code {
|
||||
background: var(--card-background);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.message-item {
|
||||
margin-bottom: 12px;
|
||||
padding: 12px;
|
||||
background: var(--card-background, rgba(30, 30, 30, 0.5));
|
||||
border-left: 3px solid var(--primary-color, #3498db);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.message-item-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.message-id {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.message-timestamp {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
font-family: monospace;
|
||||
font-size: 0.9em;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.empty-message-state {
|
||||
text-align: center;
|
||||
padding: 32px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.topic-multi-indicator {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Message animations - only for new messages */
|
||||
.message-item-new {
|
||||
animation: messageSlideIn 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
}
|
||||
|
||||
@keyframes messageSlideIn {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(-15px) scale(0.98);
|
||||
max-height: 0;
|
||||
margin-bottom: 0;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.8;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
max-height: 1000px;
|
||||
margin-bottom: 12px;
|
||||
padding-top: 12px;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Node highlight animation - only for selected nodes */
|
||||
.node-highlight.highlighted {
|
||||
animation: nodePulse 2s ease-in-out infinite;
|
||||
filter: drop-shadow(0 0 3px rgba(255, 215, 0, 0.6));
|
||||
}
|
||||
|
||||
@keyframes nodePulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.6;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
Reference in New Issue
Block a user