v0.7.34 - We seemed to maybe finally fixed the monitoring error?
This commit is contained in:
@@ -80,10 +80,26 @@
|
||||
<td>Total Events</td>
|
||||
<td id="total-events">-</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Process ID</td>
|
||||
<td id="process-id">-</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Active Subscriptions</td>
|
||||
<td id="active-subscriptions">-</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Memory Usage</td>
|
||||
<td id="memory-usage">-</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>CPU Core</td>
|
||||
<td id="cpu-core">-</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>CPU Usage</td>
|
||||
<td id="cpu-usage">-</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Oldest Event</td>
|
||||
<td id="oldest-event">-</td>
|
||||
@@ -184,15 +200,14 @@
|
||||
<tr>
|
||||
<th>Subscription ID</th>
|
||||
<th>Client IP</th>
|
||||
<th>WSI Pointer</th>
|
||||
<th>Duration</th>
|
||||
<th>Events Sent</th>
|
||||
<th>Status</th>
|
||||
<th>Filters</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="subscription-details-table-body">
|
||||
<tr>
|
||||
<td colspan="6" style="text-align: center; font-style: italic;">No subscriptions active</td>
|
||||
<td colspan="5" style="text-align: center; font-style: italic;">No subscriptions active</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
391
api/index.js
391
api/index.js
@@ -23,6 +23,7 @@ let currentConfig = null;
|
||||
let relayPool = null;
|
||||
let subscriptionId = null;
|
||||
let isSubscribed = false; // Flag to prevent multiple simultaneous subscriptions
|
||||
let isSubscribing = false; // Flag to prevent re-entry during subscription setup
|
||||
// Relay connection state
|
||||
let relayInfo = null;
|
||||
let isRelayConnected = false;
|
||||
@@ -307,6 +308,13 @@ async function restoreAuthenticationState(pubkey) {
|
||||
|
||||
// Automatically set up relay connection based on current page URL
|
||||
async function setupAutomaticRelayConnection(showSections = false) {
|
||||
console.log('=== SETUP AUTOMATIC RELAY CONNECTION CALLED ===');
|
||||
console.log('Call stack:', new Error().stack);
|
||||
console.log('showSections:', showSections);
|
||||
console.log('Current isRelayConnected:', isRelayConnected);
|
||||
console.log('Current relayPool:', relayPool ? 'EXISTS' : 'NULL');
|
||||
console.log('Current isSubscribed:', isSubscribed);
|
||||
|
||||
try {
|
||||
// Get the current page URL and convert to WebSocket URL
|
||||
const currentUrl = window.location.href;
|
||||
@@ -322,8 +330,9 @@ async function setupAutomaticRelayConnection(showSections = false) {
|
||||
}
|
||||
|
||||
// Remove any path components to get just the base URL
|
||||
// CRITICAL: Always add trailing slash for consistent URL format
|
||||
const url = new URL(relayUrl);
|
||||
relayUrl = `${url.protocol}//${url.host}`;
|
||||
relayUrl = `${url.protocol}//${url.host}/`;
|
||||
|
||||
// Set the relay URL
|
||||
relayConnectionUrl.value = relayUrl;
|
||||
@@ -348,13 +357,8 @@ async function setupAutomaticRelayConnection(showSections = false) {
|
||||
relayPubkey = '4f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa';
|
||||
}
|
||||
|
||||
// Initialize relay pool for admin API communication
|
||||
if (!relayPool) {
|
||||
relayPool = new window.NostrTools.SimplePool();
|
||||
console.log('🔌 Initialized SimplePool for admin API communication');
|
||||
}
|
||||
|
||||
// Set up subscription to receive admin API responses
|
||||
// Note: subscribeToConfiguration() will create the SimplePool internally
|
||||
await subscribeToConfiguration();
|
||||
console.log('📡 Subscription established for admin API responses');
|
||||
|
||||
@@ -615,16 +619,22 @@ async function loadUserProfile() {
|
||||
'wss://relay.nostr.band',
|
||||
'wss://nos.lol',
|
||||
'wss://relay.primal.net',
|
||||
'wss://relay.snort.social',
|
||||
'wss://relay.laantungir.net'];
|
||||
'wss://relay.snort.social'
|
||||
];
|
||||
|
||||
// Get profile event (kind 0) for the user
|
||||
const events = await profilePool.querySync(relays, {
|
||||
// Get profile event (kind 0) for the user with timeout
|
||||
const timeoutPromise = new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Profile query timeout')), 5000)
|
||||
);
|
||||
|
||||
const queryPromise = profilePool.querySync(relays, {
|
||||
kinds: [0],
|
||||
authors: [userPubkey],
|
||||
limit: 1
|
||||
});
|
||||
|
||||
const events = await Promise.race([queryPromise, timeoutPromise]);
|
||||
|
||||
if (events.length > 0) {
|
||||
console.log('Profile event found:', events[0]);
|
||||
const profile = JSON.parse(events[0].content);
|
||||
@@ -648,8 +658,14 @@ async function loadUserProfile() {
|
||||
// Keep the npub display
|
||||
}
|
||||
|
||||
// Close the profile pool
|
||||
profilePool.close(relays);
|
||||
// Properly close the profile pool with error handling
|
||||
try {
|
||||
await profilePool.close(relays);
|
||||
// Give time for cleanup
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
} catch (closeError) {
|
||||
console.log('Profile pool close error (non-critical):', closeError.message);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.log('Profile loading failed: ' + error.message);
|
||||
@@ -755,20 +771,25 @@ async function logout() {
|
||||
// Stop auto-refresh before disconnecting
|
||||
stopStatsAutoRefresh();
|
||||
|
||||
|
||||
// Clean up configuration pool
|
||||
// Clean up relay pool
|
||||
if (relayPool) {
|
||||
log('Closing configuration pool...', 'INFO');
|
||||
log('Closing relay pool...', 'INFO');
|
||||
const url = relayConnectionUrl.value.trim();
|
||||
if (url) {
|
||||
relayPool.close([url]);
|
||||
try {
|
||||
await relayPool.close([url]);
|
||||
} catch (e) {
|
||||
console.log('Pool close error (non-critical):', e.message);
|
||||
}
|
||||
}
|
||||
relayPool = null;
|
||||
subscriptionId = null;
|
||||
// Reset subscription flag
|
||||
isSubscribed = false;
|
||||
}
|
||||
|
||||
// Reset subscription flags
|
||||
isSubscribed = false;
|
||||
isSubscribing = false;
|
||||
|
||||
await nlLite.logout();
|
||||
|
||||
userPubkey = null;
|
||||
@@ -778,12 +799,9 @@ async function logout() {
|
||||
// Reset relay connection state
|
||||
isRelayConnected = false;
|
||||
relayPubkey = null;
|
||||
// Reset subscription flag
|
||||
isSubscribed = false;
|
||||
|
||||
// Reset UI - hide profile and show login modal
|
||||
hideProfileFromHeader();
|
||||
// showLoginModal() removed - handled by handleLogoutEvent()
|
||||
|
||||
updateConfigStatus(false);
|
||||
updateAdminSectionsVisibility();
|
||||
@@ -815,42 +833,204 @@ function generateSubId() {
|
||||
return result;
|
||||
}
|
||||
|
||||
// WebSocket monitoring function to attach to SimplePool connections
|
||||
function attachWebSocketMonitoring(relayPool, url) {
|
||||
console.log('🔍 Attaching WebSocket monitoring to SimplePool...');
|
||||
|
||||
// SimplePool stores connections in _conn object
|
||||
if (relayPool && relayPool._conn) {
|
||||
// Monitor when connections are created
|
||||
const originalGetConnection = relayPool._conn[url];
|
||||
if (originalGetConnection) {
|
||||
console.log('📡 Found existing connection for URL:', url);
|
||||
|
||||
// Try to access the WebSocket if it's available
|
||||
const conn = relayPool._conn[url];
|
||||
if (conn && conn.ws) {
|
||||
attachWebSocketEventListeners(conn.ws, url);
|
||||
}
|
||||
}
|
||||
|
||||
// Override the connection getter to monitor new connections
|
||||
const originalConn = relayPool._conn;
|
||||
relayPool._conn = new Proxy(originalConn, {
|
||||
get(target, prop) {
|
||||
const conn = target[prop];
|
||||
if (conn && conn.ws && !conn.ws._monitored) {
|
||||
console.log('🔗 New WebSocket connection detected for:', prop);
|
||||
attachWebSocketEventListeners(conn.ws, prop);
|
||||
conn.ws._monitored = true;
|
||||
}
|
||||
return conn;
|
||||
},
|
||||
set(target, prop, value) {
|
||||
if (value && value.ws && !value.ws._monitored) {
|
||||
console.log('🔗 WebSocket connection being set for:', prop);
|
||||
attachWebSocketEventListeners(value.ws, prop);
|
||||
value.ws._monitored = true;
|
||||
}
|
||||
target[prop] = value;
|
||||
return true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
console.log('✅ WebSocket monitoring attached');
|
||||
}
|
||||
|
||||
function attachWebSocketEventListeners(ws, url) {
|
||||
console.log(`🎯 Attaching event listeners to WebSocket for ${url}`);
|
||||
|
||||
// Log connection open
|
||||
ws.addEventListener('open', (event) => {
|
||||
console.log(`🔓 WebSocket OPEN for ${url}:`, {
|
||||
readyState: ws.readyState,
|
||||
url: ws.url,
|
||||
protocol: ws.protocol,
|
||||
extensions: ws.extensions
|
||||
});
|
||||
});
|
||||
|
||||
// Log incoming messages with full details
|
||||
ws.addEventListener('message', (event) => {
|
||||
try {
|
||||
const data = event.data;
|
||||
console.log(`📨 WebSocket MESSAGE from ${url}:`, {
|
||||
type: event.type,
|
||||
data: data,
|
||||
dataLength: data.length,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
// Try to parse as JSON for Nostr messages
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
if (Array.isArray(parsed)) {
|
||||
const [type, ...args] = parsed;
|
||||
console.log(`📨 Parsed Nostr message [${type}]:`, args);
|
||||
} else {
|
||||
console.log(`📨 Parsed JSON:`, parsed);
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.log(`📨 Raw message (not JSON):`, data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ Error processing WebSocket message from ${url}:`, error);
|
||||
}
|
||||
});
|
||||
|
||||
// Log connection close with details
|
||||
ws.addEventListener('close', (event) => {
|
||||
console.log(`🔒 WebSocket CLOSE for ${url}:`, {
|
||||
code: event.code,
|
||||
reason: event.reason,
|
||||
wasClean: event.wasClean,
|
||||
readyState: ws.readyState,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
});
|
||||
|
||||
// Log errors with full details
|
||||
ws.addEventListener('error', (event) => {
|
||||
console.error(`❌ WebSocket ERROR for ${url}:`, {
|
||||
type: event.type,
|
||||
target: event.target,
|
||||
readyState: ws.readyState,
|
||||
url: ws.url,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
// Log additional WebSocket state
|
||||
console.error(`❌ WebSocket state details:`, {
|
||||
readyState: ws.readyState,
|
||||
bufferedAmount: ws.bufferedAmount,
|
||||
protocol: ws.protocol,
|
||||
extensions: ws.extensions,
|
||||
binaryType: ws.binaryType
|
||||
});
|
||||
});
|
||||
|
||||
// Override send method to log outgoing messages
|
||||
const originalSend = ws.send;
|
||||
ws.send = function(data) {
|
||||
console.log(`📤 WebSocket SEND to ${url}:`, {
|
||||
data: data,
|
||||
dataLength: data.length,
|
||||
readyState: ws.readyState,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
// Try to parse outgoing Nostr messages
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
if (Array.isArray(parsed)) {
|
||||
const [type, ...args] = parsed;
|
||||
console.log(`📤 Outgoing Nostr message [${type}]:`, args);
|
||||
} else {
|
||||
console.log(`📤 Outgoing JSON:`, parsed);
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.log(`📤 Outgoing raw message (not JSON):`, data);
|
||||
}
|
||||
|
||||
return originalSend.call(this, data);
|
||||
};
|
||||
|
||||
console.log(`✅ Event listeners attached to WebSocket for ${url}`);
|
||||
}
|
||||
|
||||
// Configuration subscription using nostr-tools SimplePool
|
||||
async function subscribeToConfiguration() {
|
||||
try {
|
||||
console.log('=== STARTING SIMPLEPOOL CONFIGURATION SUBSCRIPTION ===');
|
||||
console.log('=== SUBSCRIBE TO CONFIGURATION ===');
|
||||
console.log('Call stack:', new Error().stack);
|
||||
|
||||
// Prevent multiple simultaneous subscription attempts
|
||||
if (isSubscribed) {
|
||||
console.log('Subscription already established, skipping duplicate subscription attempt');
|
||||
// If pool already exists and subscribed, we're done
|
||||
if (relayPool && isSubscribed) {
|
||||
console.log('✅ Already subscribed, reusing existing pool');
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!isLoggedIn) {
|
||||
console.log('WARNING: Not logged in, but proceeding with subscription test');
|
||||
}
|
||||
|
||||
const url = relayConnectionUrl.value.trim();
|
||||
if (!url) {
|
||||
console.error('Please enter a relay URL');
|
||||
// Prevent concurrent subscription attempts
|
||||
if (isSubscribing) {
|
||||
console.log('⚠️ Subscription already in progress');
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log(`Connecting to relay via SimplePool: ${url}`);
|
||||
isSubscribing = true;
|
||||
|
||||
// Reuse existing pool if available, otherwise create new one
|
||||
const url = relayConnectionUrl.value.trim();
|
||||
if (!url) {
|
||||
console.error('No relay URL configured');
|
||||
isSubscribing = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log(`🔌 Connecting to relay: ${url}`);
|
||||
|
||||
// Create pool ONLY if it doesn't exist
|
||||
if (!relayPool) {
|
||||
console.log('Creating new SimplePool instance');
|
||||
console.log('✨ Creating NEW SimplePool for admin operations');
|
||||
relayPool = new window.NostrTools.SimplePool();
|
||||
|
||||
// Attach WebSocket monitoring to the new pool
|
||||
attachWebSocketMonitoring(relayPool, url);
|
||||
} else {
|
||||
console.log('Reusing existing SimplePool instance');
|
||||
console.log('♻️ Reusing existing SimplePool');
|
||||
}
|
||||
|
||||
subscriptionId = generateSubId();
|
||||
|
||||
console.log(`Generated subscription ID: ${subscriptionId}`);
|
||||
console.log(`User pubkey ${userPubkey}`)
|
||||
console.log(`📝 Generated subscription ID: ${subscriptionId}`);
|
||||
console.log(`👤 User pubkey: ${userPubkey}`);
|
||||
console.log(`🎯 About to call relayPool.subscribeMany with URL: ${url}`);
|
||||
console.log(`📊 relayPool._conn before subscribeMany:`, Object.keys(relayPool._conn || {}));
|
||||
|
||||
// Mark as subscribed BEFORE calling subscribeMany to prevent race conditions
|
||||
isSubscribed = true;
|
||||
|
||||
// Subscribe to kind 23457 events (admin response events), kind 4 (NIP-04 DMs), kind 1059 (NIP-17 GiftWrap), and kind 24567 (ephemeral monitoring events)
|
||||
console.log('🔔 Calling relayPool.subscribeMany...');
|
||||
const subscription = relayPool.subscribeMany([url], [{
|
||||
since: Math.floor(Date.now() / 1000) - 5, // Look back 5 seconds to avoid race condition
|
||||
kinds: [23457],
|
||||
@@ -872,20 +1052,21 @@ async function subscribeToConfiguration() {
|
||||
since: Math.floor(Date.now() / 1000), // Start from current time
|
||||
kinds: [24567], // Real-time ephemeral monitoring events
|
||||
authors: [getRelayPubkey()], // Only listen to monitoring events from the relay
|
||||
"#d": isLoggedIn ? ["event_kinds", "time_stats", "top_pubkeys", "active_subscriptions", "subscription_details"] : ["event_kinds", "time_stats", "top_pubkeys", "active_subscriptions"], // Include subscription_details only when authenticated
|
||||
"#d": isLoggedIn ? ["event_kinds", "time_stats", "top_pubkeys", "active_subscriptions", "subscription_details", "cpu_metrics"] : ["event_kinds", "time_stats", "top_pubkeys", "active_subscriptions", "cpu_metrics"], // Include subscription_details only when authenticated, cpu_metrics available to all
|
||||
limit: 50
|
||||
}], {
|
||||
async onevent(event) {
|
||||
console.log('=== EVENT RECEIVED VIA SIMPLEPOOL ===');
|
||||
console.log('Event data:', event);
|
||||
console.log('Event kind:', event.kind);
|
||||
console.log('Event tags:', event.tags);
|
||||
console.log('Event pubkey:', event.pubkey);
|
||||
console.log('=== END EVENT ===');
|
||||
// Simplified logging - one line per event
|
||||
if (event.kind === 24567) {
|
||||
const dTag = event.tags.find(tag => tag[0] === 'd');
|
||||
const dataType = dTag ? dTag[1] : 'unknown';
|
||||
console.log(`📊 Monitoring event: ${dataType}`);
|
||||
} else {
|
||||
console.log(`📨 Event received: kind ${event.kind}`);
|
||||
}
|
||||
|
||||
// Handle NIP-04 DMs
|
||||
if (event.kind === 4) {
|
||||
console.log('=== NIP-04 DM RECEIVED ===');
|
||||
try {
|
||||
// Decrypt the DM content
|
||||
const decryptedContent = await window.nostr.nip04.decrypt(event.pubkey, event.content);
|
||||
@@ -910,7 +1091,6 @@ async function subscribeToConfiguration() {
|
||||
|
||||
// Handle NIP-17 GiftWrap DMs
|
||||
if (event.kind === 1059) {
|
||||
console.log('=== NIP-17 GIFTWRAP RECEIVED ===');
|
||||
try {
|
||||
// Step 1: Unwrap gift wrap to get seal
|
||||
const sealJson = await window.nostr.nip44.decrypt(event.pubkey, event.content);
|
||||
@@ -958,10 +1138,7 @@ async function subscribeToConfiguration() {
|
||||
|
||||
// Handle monitoring events (kind 24567 - ephemeral)
|
||||
if (event.kind === 24567) {
|
||||
console.log('=== MONITORING EVENT RECEIVED ===');
|
||||
console.log('Monitoring event:', event);
|
||||
|
||||
// Process monitoring event
|
||||
// Process monitoring event (logging done above)
|
||||
processMonitoringEvent(event);
|
||||
}
|
||||
},
|
||||
@@ -975,23 +1152,30 @@ async function subscribeToConfiguration() {
|
||||
},
|
||||
onclose(reason) {
|
||||
console.log('Subscription closed:', reason);
|
||||
// Reset subscription state to allow re-subscription
|
||||
isSubscribed = false;
|
||||
isSubscribing = false;
|
||||
isRelayConnected = false;
|
||||
updateConfigStatus(false);
|
||||
log('WebSocket connection closed - subscription state reset', 'WARNING');
|
||||
}
|
||||
});
|
||||
|
||||
// Store subscription for cleanup
|
||||
relayPool.currentSubscription = subscription;
|
||||
|
||||
// Mark as subscribed to prevent duplicate attempts
|
||||
// Mark as subscribed
|
||||
isSubscribed = true;
|
||||
isSubscribing = false;
|
||||
|
||||
console.log('SimplePool subscription established');
|
||||
console.log('✅ Subscription established successfully');
|
||||
return true;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Configuration subscription failed:', error.message);
|
||||
console.error('Configuration subscription failed:', error);
|
||||
console.error('Error stack:', error.stack);
|
||||
isSubscribing = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1087,13 +1271,14 @@ function initializeEventRateChart() {
|
||||
eventRateChart = new ASCIIBarChart('event-rate-chart', {
|
||||
maxHeight: 11, // Chart height in lines
|
||||
maxDataPoints: 76, // Show last 76 bins (5+ minutes of history)
|
||||
title: 'Events', // Chart title
|
||||
title: 'New Events', // Chart title
|
||||
xAxisLabel: '', // No X-axis label
|
||||
yAxisLabel: '', // No Y-axis label
|
||||
autoFitWidth: true, // Enable responsive font sizing
|
||||
useBinMode: true, // Enable time bin aggregation
|
||||
binDuration: 4000, // 4-second time bins
|
||||
xAxisLabelFormat: 'elapsed' // Show elapsed time labels
|
||||
xAxisLabelFormat: 'elapsed', // Show elapsed time labels
|
||||
debug: false // Disable debug logging
|
||||
});
|
||||
|
||||
console.log('ASCIIBarChart instance created:', eventRateChart);
|
||||
@@ -1148,70 +1333,56 @@ function createChartStubElements() {
|
||||
// Handle monitoring events (kind 24567 - ephemeral)
|
||||
async function processMonitoringEvent(event) {
|
||||
try {
|
||||
console.log('=== PROCESSING MONITORING EVENT ===');
|
||||
console.log('Monitoring event:', event);
|
||||
|
||||
// Verify this is a kind 24567 ephemeral monitoring event
|
||||
if (event.kind !== 24567) {
|
||||
console.log('Ignoring non-monitoring event, kind:', event.kind);
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify the event is from the relay
|
||||
const expectedRelayPubkey = getRelayPubkey();
|
||||
if (event.pubkey !== expectedRelayPubkey) {
|
||||
console.log('Ignoring monitoring event from unknown pubkey:', event.pubkey);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check the d-tag to determine which type of monitoring event this is
|
||||
const dTag = event.tags.find(tag => tag[0] === 'd');
|
||||
if (!dTag) {
|
||||
console.log('Ignoring monitoring event without d-tag');
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse the monitoring data (content is JSON, not encrypted for monitoring events)
|
||||
const monitoringData = JSON.parse(event.content);
|
||||
console.log('Parsed monitoring data:', monitoringData);
|
||||
|
||||
// Don't add to chart here - we'll track actual event rate changes in updateStatsFromMonitoringEvent
|
||||
console.log('Monitoring event received - will track rate changes in stats update');
|
||||
|
||||
// Route to appropriate handler based on d-tag
|
||||
// Route to appropriate handler based on d-tag (no verbose logging)
|
||||
switch (dTag[1]) {
|
||||
case 'event_kinds':
|
||||
updateStatsFromMonitoringEvent(monitoringData);
|
||||
log('Real-time event_kinds monitoring data updated', 'INFO');
|
||||
break;
|
||||
|
||||
case 'time_stats':
|
||||
updateStatsFromTimeMonitoringEvent(monitoringData);
|
||||
log('Real-time time_stats monitoring data updated', 'INFO');
|
||||
break;
|
||||
|
||||
case 'top_pubkeys':
|
||||
updateStatsFromTopPubkeysMonitoringEvent(monitoringData);
|
||||
log('Real-time top_pubkeys monitoring data updated', 'INFO');
|
||||
break;
|
||||
|
||||
case 'active_subscriptions':
|
||||
updateStatsFromActiveSubscriptionsMonitoringEvent(monitoringData);
|
||||
log('Real-time active_subscriptions monitoring data updated', 'INFO');
|
||||
break;
|
||||
|
||||
case 'subscription_details':
|
||||
// Only process subscription details if user is authenticated
|
||||
if (isLoggedIn) {
|
||||
updateStatsFromSubscriptionDetailsMonitoringEvent(monitoringData);
|
||||
log('Real-time subscription_details monitoring data updated', 'INFO');
|
||||
} else {
|
||||
console.log('Ignoring subscription_details monitoring event - user not authenticated');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'cpu_metrics':
|
||||
updateStatsFromCpuMonitoringEvent(monitoringData);
|
||||
break;
|
||||
|
||||
default:
|
||||
console.log('Ignoring monitoring event with unknown d-tag:', dTag[1]);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3770,11 +3941,8 @@ function handleStatsQueryResponse(responseData) {
|
||||
// Update statistics display from real-time monitoring event
|
||||
function updateStatsFromMonitoringEvent(monitoringData) {
|
||||
try {
|
||||
log('Updating stats from monitoring event...', 'INFO');
|
||||
console.log('Monitoring data:', monitoringData);
|
||||
|
||||
if (monitoringData.data_type !== 'event_kinds') {
|
||||
log('Ignoring monitoring event with different data type', 'WARNING');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3801,8 +3969,6 @@ function updateStatsFromMonitoringEvent(monitoringData) {
|
||||
populateStatsKindsFromMonitoring(monitoringData.kinds, monitoringData.total_events);
|
||||
}
|
||||
|
||||
log('Real-time statistics updated from monitoring event', 'INFO');
|
||||
|
||||
} catch (error) {
|
||||
log(`Error updating stats from monitoring event: ${error.message}`, 'ERROR');
|
||||
}
|
||||
@@ -3811,11 +3977,7 @@ function updateStatsFromMonitoringEvent(monitoringData) {
|
||||
// Update statistics display from time_stats monitoring event
|
||||
function updateStatsFromTimeMonitoringEvent(monitoringData) {
|
||||
try {
|
||||
log('Updating time stats from monitoring event...', 'INFO');
|
||||
console.log('Time monitoring data:', monitoringData);
|
||||
|
||||
if (monitoringData.data_type !== 'time_stats') {
|
||||
log('Ignoring time monitoring event with different data type', 'WARNING');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3834,8 +3996,6 @@ function updateStatsFromTimeMonitoringEvent(monitoringData) {
|
||||
populateStatsTime({ time_stats: timeStats });
|
||||
}
|
||||
|
||||
log('Real-time time statistics updated from monitoring event', 'INFO');
|
||||
|
||||
} catch (error) {
|
||||
log(`Error updating time stats from monitoring event: ${error.message}`, 'ERROR');
|
||||
}
|
||||
@@ -3844,11 +4004,7 @@ function updateStatsFromTimeMonitoringEvent(monitoringData) {
|
||||
// Update statistics display from top_pubkeys monitoring event
|
||||
function updateStatsFromTopPubkeysMonitoringEvent(monitoringData) {
|
||||
try {
|
||||
log('Updating top pubkeys from monitoring event...', 'INFO');
|
||||
console.log('Top pubkeys monitoring data:', monitoringData);
|
||||
|
||||
if (monitoringData.data_type !== 'top_pubkeys') {
|
||||
log('Ignoring top pubkeys monitoring event with different data type', 'WARNING');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3858,8 +4014,6 @@ function updateStatsFromTopPubkeysMonitoringEvent(monitoringData) {
|
||||
populateStatsPubkeysFromMonitoring(monitoringData.pubkeys, monitoringData.total_events || 0);
|
||||
}
|
||||
|
||||
log('Real-time top pubkeys statistics updated from monitoring event', 'INFO');
|
||||
|
||||
} catch (error) {
|
||||
log(`Error updating top pubkeys from monitoring event: ${error.message}`, 'ERROR');
|
||||
}
|
||||
@@ -3868,11 +4022,7 @@ function updateStatsFromTopPubkeysMonitoringEvent(monitoringData) {
|
||||
// Update statistics display from active_subscriptions monitoring event
|
||||
function updateStatsFromActiveSubscriptionsMonitoringEvent(monitoringData) {
|
||||
try {
|
||||
log('Updating active subscriptions from monitoring event...', 'INFO');
|
||||
console.log('Active subscriptions monitoring data:', monitoringData);
|
||||
|
||||
if (monitoringData.data_type !== 'active_subscriptions') {
|
||||
log('Ignoring active subscriptions monitoring event with different data type', 'WARNING');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3882,8 +4032,6 @@ function updateStatsFromActiveSubscriptionsMonitoringEvent(monitoringData) {
|
||||
updateStatsCell('active-subscriptions', monitoringData.data.total_subscriptions.toString());
|
||||
}
|
||||
|
||||
log('Real-time active subscriptions statistics updated from monitoring event', 'INFO');
|
||||
|
||||
} catch (error) {
|
||||
log(`Error updating active subscriptions from monitoring event: ${error.message}`, 'ERROR');
|
||||
}
|
||||
@@ -3892,11 +4040,7 @@ function updateStatsFromActiveSubscriptionsMonitoringEvent(monitoringData) {
|
||||
// Update statistics display from subscription_details monitoring event
|
||||
function updateStatsFromSubscriptionDetailsMonitoringEvent(monitoringData) {
|
||||
try {
|
||||
log('Updating subscription details from monitoring event...', 'INFO');
|
||||
console.log('Subscription details monitoring data:', monitoringData);
|
||||
|
||||
if (monitoringData.data_type !== 'subscription_details') {
|
||||
log('Ignoring subscription details monitoring event with different data type', 'WARNING');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3905,13 +4049,43 @@ function updateStatsFromSubscriptionDetailsMonitoringEvent(monitoringData) {
|
||||
populateSubscriptionDetailsTable(monitoringData.data.subscriptions);
|
||||
}
|
||||
|
||||
log('Real-time subscription details statistics updated from monitoring event', 'INFO');
|
||||
|
||||
} catch (error) {
|
||||
log(`Error updating subscription details from monitoring event: ${error.message}`, 'ERROR');
|
||||
}
|
||||
}
|
||||
|
||||
// Update statistics display from CPU metrics monitoring event
|
||||
function updateStatsFromCpuMonitoringEvent(monitoringData) {
|
||||
try {
|
||||
if (monitoringData.data_type !== 'cpu_metrics') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update CPU metrics in the database statistics table
|
||||
if (monitoringData.process_id !== undefined) {
|
||||
updateStatsCell('process-id', monitoringData.process_id.toString());
|
||||
}
|
||||
|
||||
if (monitoringData.memory_usage_mb !== undefined) {
|
||||
updateStatsCell('memory-usage', monitoringData.memory_usage_mb.toFixed(1) + ' MB');
|
||||
}
|
||||
|
||||
if (monitoringData.current_cpu_core !== undefined) {
|
||||
updateStatsCell('cpu-core', 'Core ' + monitoringData.current_cpu_core);
|
||||
}
|
||||
|
||||
// Calculate CPU usage percentage if we have the data
|
||||
if (monitoringData.process_cpu_time !== undefined && monitoringData.system_cpu_time !== undefined) {
|
||||
// For now, just show the raw process CPU time (simplified)
|
||||
// In a real implementation, you'd calculate deltas over time
|
||||
updateStatsCell('cpu-usage', monitoringData.process_cpu_time + ' ticks');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
log(`Error updating CPU metrics from monitoring event: ${error.message}`, 'ERROR');
|
||||
}
|
||||
}
|
||||
|
||||
// Populate event kinds table from monitoring data
|
||||
function populateStatsKindsFromMonitoring(kindsData, totalEvents) {
|
||||
const tableBody = document.getElementById('stats-kinds-table-body');
|
||||
@@ -4076,7 +4250,7 @@ function populateSubscriptionDetailsTable(subscriptionsData) {
|
||||
|
||||
if (subscriptionsData.length === 0) {
|
||||
const row = document.createElement('tr');
|
||||
row.innerHTML = '<td colspan="6" style="text-align: center; font-style: italic;">No active subscriptions</td>';
|
||||
row.innerHTML = '<td colspan="5" style="text-align: center; font-style: italic;">No active subscriptions</td>';
|
||||
tableBody.appendChild(row);
|
||||
return;
|
||||
}
|
||||
@@ -4092,8 +4266,8 @@ function populateSubscriptionDetailsTable(subscriptionsData) {
|
||||
// Format client IP (show full IP for admin view)
|
||||
const clientIP = subscription.client_ip || 'unknown';
|
||||
|
||||
// Format status
|
||||
const status = subscription.active ? 'Active' : 'Inactive';
|
||||
// Format wsi_pointer (show full pointer)
|
||||
const wsiPointer = subscription.wsi_pointer || 'N/A';
|
||||
|
||||
// Format filters (show actual filter details)
|
||||
let filtersDisplay = 'None';
|
||||
@@ -4161,9 +4335,8 @@ function populateSubscriptionDetailsTable(subscriptionsData) {
|
||||
row.innerHTML = `
|
||||
<td style="font-family: 'Courier New', monospace; font-size: 12px;">${subscription.id || 'N/A'}</td>
|
||||
<td style="font-family: 'Courier New', monospace; font-size: 12px;">${clientIP}</td>
|
||||
<td style="font-family: 'Courier New', monospace; font-size: 12px;">${wsiPointer}</td>
|
||||
<td>${durationStr}</td>
|
||||
<td>${subscription.events_sent || 0}</td>
|
||||
<td>${status}</td>
|
||||
<td>${filtersDisplay}</td>
|
||||
`;
|
||||
tableBody.appendChild(row);
|
||||
|
||||
@@ -18,6 +18,7 @@ class ASCIIBarChart {
|
||||
* @param {boolean} [options.useBinMode=false] - Enable time bin mode for data aggregation
|
||||
* @param {number} [options.binDuration=10000] - Duration of each time bin in milliseconds (10 seconds default)
|
||||
* @param {string} [options.xAxisLabelFormat='elapsed'] - X-axis label format: 'elapsed', 'bins', 'timestamps', 'ranges'
|
||||
* @param {boolean} [options.debug=false] - Enable debug logging
|
||||
*/
|
||||
constructor(containerId, options = {}) {
|
||||
this.container = document.getElementById(containerId);
|
||||
@@ -29,6 +30,7 @@ class ASCIIBarChart {
|
||||
this.xAxisLabel = options.xAxisLabel || '';
|
||||
this.yAxisLabel = options.yAxisLabel || '';
|
||||
this.autoFitWidth = options.autoFitWidth !== false; // Default to true
|
||||
this.debug = options.debug || false; // Debug logging option
|
||||
|
||||
// Time bin configuration
|
||||
this.useBinMode = options.useBinMode !== false; // Default to true
|
||||
@@ -55,32 +57,21 @@ class ASCIIBarChart {
|
||||
this.initializeBins();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Add a new data point to the chart
|
||||
* @param {number} value - The numeric value to add
|
||||
*/
|
||||
addValue(value) {
|
||||
if (this.useBinMode) {
|
||||
// Time bin mode: increment count in current active bin
|
||||
this.checkBinRotation(); // Ensure we have an active bin
|
||||
this.bins[this.currentBinIndex].count++;
|
||||
this.totalDataPoints++;
|
||||
} else {
|
||||
// Legacy mode: add individual values
|
||||
this.data.push(value);
|
||||
this.totalDataPoints++;
|
||||
|
||||
// Keep only the most recent data points
|
||||
if (this.data.length > this.maxDataPoints) {
|
||||
this.data.shift();
|
||||
}
|
||||
}
|
||||
// Time bin mode: add value to current active bin count
|
||||
this.checkBinRotation(); // Ensure we have an active bin
|
||||
this.bins[this.currentBinIndex].count += value; // Changed from ++ to += value
|
||||
this.totalDataPoints++;
|
||||
|
||||
this.render();
|
||||
this.updateInfo();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Clear all data from the chart
|
||||
*/
|
||||
@@ -98,7 +89,7 @@ class ASCIIBarChart {
|
||||
this.render();
|
||||
this.updateInfo();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Calculate the width of the chart in characters
|
||||
* @returns {number} The chart width in characters
|
||||
@@ -119,14 +110,14 @@ class ASCIIBarChart {
|
||||
const totalWidth = yAxisPadding + yAxisNumbers + separator + dataWidth + padding;
|
||||
|
||||
// Only log when width changes
|
||||
if (this.lastChartWidth !== totalWidth) {
|
||||
if (this.debug && this.lastChartWidth !== totalWidth) {
|
||||
console.log('getChartWidth changed:', { dataLength, totalWidth, previous: this.lastChartWidth });
|
||||
this.lastChartWidth = totalWidth;
|
||||
}
|
||||
|
||||
return totalWidth;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Adjust font size to fit container width
|
||||
* @private
|
||||
@@ -142,7 +133,7 @@ class ASCIIBarChart {
|
||||
// Calculate optimal font size
|
||||
// For monospace fonts, character width is approximately 0.6 * font size
|
||||
// Use a slightly smaller ratio to fit more content
|
||||
const charWidthRatio = 0.6;
|
||||
const charWidthRatio = 0.7;
|
||||
const padding = 30; // Reduce padding to fit more content
|
||||
const availableWidth = containerWidth - padding;
|
||||
const optimalFontSize = Math.floor((availableWidth / chartWidth) / charWidthRatio);
|
||||
@@ -151,7 +142,7 @@ class ASCIIBarChart {
|
||||
const fontSize = Math.max(4, Math.min(20, optimalFontSize));
|
||||
|
||||
// Only log when font size changes
|
||||
if (this.lastFontSize !== fontSize) {
|
||||
if (this.debug && this.lastFontSize !== fontSize) {
|
||||
console.log('fontSize changed:', { containerWidth, chartWidth, fontSize, previous: this.lastFontSize });
|
||||
this.lastFontSize = fontSize;
|
||||
}
|
||||
@@ -159,7 +150,7 @@ class ASCIIBarChart {
|
||||
this.container.style.fontSize = fontSize + 'px';
|
||||
this.container.style.lineHeight = '1.0';
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Render the chart to the container
|
||||
* @private
|
||||
@@ -190,7 +181,9 @@ class ASCIIBarChart {
|
||||
}
|
||||
});
|
||||
|
||||
console.log('render() dataToRender:', dataToRender, 'bins length:', this.bins.length);
|
||||
if (this.debug) {
|
||||
console.log('render() dataToRender:', dataToRender, 'bins length:', this.bins.length);
|
||||
}
|
||||
maxValue = Math.max(...dataToRender);
|
||||
minValue = Math.min(...dataToRender);
|
||||
valueRange = maxValue - minValue;
|
||||
@@ -219,12 +212,12 @@ class ASCIIBarChart {
|
||||
const yAxisPadding = this.yAxisLabel ? ' ' : '';
|
||||
|
||||
// Add title if provided (centered)
|
||||
if (this.title) {
|
||||
// const chartWidth = 4 + this.maxDataPoints * 2; // Y-axis numbers + data columns // TEMP: commented for no-space test
|
||||
const chartWidth = 4 + this.maxDataPoints; // Y-axis numbers + data columns // TEMP: adjusted for no-space columns
|
||||
const titlePadding = Math.floor((chartWidth - this.title.length) / 2);
|
||||
output += yAxisPadding + ' '.repeat(Math.max(0, titlePadding)) + this.title + '\n\n';
|
||||
}
|
||||
if (this.title) {
|
||||
// const chartWidth = 4 + this.maxDataPoints * 2; // Y-axis numbers + data columns // TEMP: commented for no-space test
|
||||
const chartWidth = 4 + this.maxDataPoints; // Y-axis numbers + data columns // TEMP: adjusted for no-space columns
|
||||
const titlePadding = Math.floor((chartWidth - this.title.length) / 2);
|
||||
output += yAxisPadding + ' '.repeat(Math.max(0, titlePadding)) + this.title + '\n\n';
|
||||
}
|
||||
|
||||
// Draw from top to bottom
|
||||
for (let row = scale; row > 0; row--) {
|
||||
@@ -243,8 +236,8 @@ class ASCIIBarChart {
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate the actual count value this row represents (0 at bottom, increasing upward)
|
||||
const rowCount = (row - 1) * scaleFactor;
|
||||
// Calculate the actual count value this row represents (1 at bottom, increasing upward)
|
||||
const rowCount = (row - 1) * scaleFactor + 1;
|
||||
|
||||
// Add Y-axis label (show actual count values)
|
||||
line += String(rowCount).padStart(3, ' ') + ' |';
|
||||
@@ -267,75 +260,75 @@ class ASCIIBarChart {
|
||||
}
|
||||
|
||||
// Draw X-axis
|
||||
// output += yAxisPadding + ' +' + '-'.repeat(this.maxDataPoints * 2) + '\n'; // TEMP: commented out for no-space test
|
||||
output += yAxisPadding + ' +' + '-'.repeat(this.maxDataPoints) + '\n'; // TEMP: back to original length
|
||||
// output += yAxisPadding + ' +' + '-'.repeat(this.maxDataPoints * 2) + '\n'; // TEMP: commented out for no-space test
|
||||
output += yAxisPadding + ' +' + '-'.repeat(this.maxDataPoints) + '\n'; // TEMP: back to original length
|
||||
|
||||
// Draw X-axis labels based on mode and format
|
||||
let xAxisLabels = yAxisPadding + ' '; // Initial padding to align with X-axis
|
||||
let xAxisLabels = yAxisPadding + ' '; // Initial padding to align with X-axis
|
||||
|
||||
// Determine label interval (every 5 columns)
|
||||
const labelInterval = 5;
|
||||
// Determine label interval (every 5 columns)
|
||||
const labelInterval = 5;
|
||||
|
||||
// Generate all labels first and store in array
|
||||
let labels = [];
|
||||
for (let i = 0; i < this.maxDataPoints; i++) {
|
||||
if (i % labelInterval === 0) {
|
||||
let label = '';
|
||||
if (this.useBinMode) {
|
||||
// For bin mode, show labels for all possible positions
|
||||
// i=0 is leftmost (most recent), i=maxDataPoints-1 is rightmost (oldest)
|
||||
const elapsedSec = (i * this.binDuration) / 1000;
|
||||
// Format with appropriate precision for sub-second bins
|
||||
if (this.binDuration < 1000) {
|
||||
// Show decimal seconds for sub-second bins
|
||||
label = elapsedSec.toFixed(1) + 's';
|
||||
} else {
|
||||
// Show whole seconds for 1+ second bins
|
||||
label = String(Math.round(elapsedSec)) + 's';
|
||||
}
|
||||
} else {
|
||||
// For legacy mode, show data point numbers
|
||||
const startIndex = Math.max(1, this.totalDataPoints - this.maxDataPoints + 1);
|
||||
label = String(startIndex + i);
|
||||
}
|
||||
labels.push(label);
|
||||
}
|
||||
}
|
||||
// Generate all labels first and store in array
|
||||
let labels = [];
|
||||
for (let i = 0; i < this.maxDataPoints; i++) {
|
||||
if (i % labelInterval === 0) {
|
||||
let label = '';
|
||||
if (this.useBinMode) {
|
||||
// For bin mode, show labels for all possible positions
|
||||
// i=0 is leftmost (most recent), i=maxDataPoints-1 is rightmost (oldest)
|
||||
const elapsedSec = (i * this.binDuration) / 1000;
|
||||
// Format with appropriate precision for sub-second bins
|
||||
if (this.binDuration < 1000) {
|
||||
// Show decimal seconds for sub-second bins
|
||||
label = elapsedSec.toFixed(1) + 's';
|
||||
} else {
|
||||
// Show whole seconds for 1+ second bins
|
||||
label = String(Math.round(elapsedSec)) + 's';
|
||||
}
|
||||
} else {
|
||||
// For legacy mode, show data point numbers
|
||||
const startIndex = Math.max(1, this.totalDataPoints - this.maxDataPoints + 1);
|
||||
label = String(startIndex + i);
|
||||
}
|
||||
labels.push(label);
|
||||
}
|
||||
}
|
||||
|
||||
// Build the label string with calculated spacing
|
||||
for (let i = 0; i < labels.length; i++) {
|
||||
const label = labels[i];
|
||||
xAxisLabels += label;
|
||||
// Build the label string with calculated spacing
|
||||
for (let i = 0; i < labels.length; i++) {
|
||||
const label = labels[i];
|
||||
xAxisLabels += label;
|
||||
|
||||
// Add spacing: labelInterval - label.length (except for last label)
|
||||
if (i < labels.length - 1) {
|
||||
const spacing = labelInterval - label.length;
|
||||
xAxisLabels += ' '.repeat(spacing);
|
||||
}
|
||||
}
|
||||
// Add spacing: labelInterval - label.length (except for last label)
|
||||
if (i < labels.length - 1) {
|
||||
const spacing = labelInterval - label.length;
|
||||
xAxisLabels += ' '.repeat(spacing);
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure the label line extends to match the X-axis dash line length
|
||||
// The dash line is this.maxDataPoints characters long, starting after " +"
|
||||
const dashLineLength = this.maxDataPoints;
|
||||
const minLabelLineLength = yAxisPadding.length + 4 + dashLineLength; // 4 for " "
|
||||
if (xAxisLabels.length < minLabelLineLength) {
|
||||
xAxisLabels += ' '.repeat(minLabelLineLength - xAxisLabels.length);
|
||||
}
|
||||
// Ensure the label line extends to match the X-axis dash line length
|
||||
// The dash line is this.maxDataPoints characters long, starting after " +"
|
||||
const dashLineLength = this.maxDataPoints;
|
||||
const minLabelLineLength = yAxisPadding.length + 4 + dashLineLength; // 4 for " "
|
||||
if (xAxisLabels.length < minLabelLineLength) {
|
||||
xAxisLabels += ' '.repeat(minLabelLineLength - xAxisLabels.length);
|
||||
}
|
||||
output += xAxisLabels + '\n';
|
||||
|
||||
// Add X-axis label if provided
|
||||
if (this.xAxisLabel) {
|
||||
// const labelPadding = Math.floor((this.maxDataPoints * 2 - this.xAxisLabel.length) / 2); // TEMP: commented for no-space test
|
||||
const labelPadding = Math.floor((this.maxDataPoints - this.xAxisLabel.length) / 2); // TEMP: adjusted for no-space columns
|
||||
output += '\n' + yAxisPadding + ' ' + ' '.repeat(Math.max(0, labelPadding)) + this.xAxisLabel + '\n';
|
||||
}
|
||||
if (this.xAxisLabel) {
|
||||
// const labelPadding = Math.floor((this.maxDataPoints * 2 - this.xAxisLabel.length) / 2); // TEMP: commented for no-space test
|
||||
const labelPadding = Math.floor((this.maxDataPoints - this.xAxisLabel.length) / 2); // TEMP: adjusted for no-space columns
|
||||
output += '\n' + yAxisPadding + ' ' + ' '.repeat(Math.max(0, labelPadding)) + this.xAxisLabel + '\n';
|
||||
}
|
||||
|
||||
this.container.textContent = output;
|
||||
|
||||
// Adjust font size to fit width (only once at initialization)
|
||||
if (this.autoFitWidth) {
|
||||
this.adjustFontSize();
|
||||
}
|
||||
if (this.autoFitWidth) {
|
||||
this.adjustFontSize();
|
||||
}
|
||||
|
||||
// Update the external info display
|
||||
if (this.useBinMode) {
|
||||
@@ -350,7 +343,7 @@ class ASCIIBarChart {
|
||||
document.getElementById('scale').textContent = `Min: ${minValue}, Max: ${maxValue}, Height: ${scale}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Update the info display
|
||||
* @private
|
||||
|
||||
Reference in New Issue
Block a user