New Blade layout, copy, and visual system for the monitoring tab; RRD hosts use data-statistics-chart hooks; tables use pxStatTableKit and data-statistics-data-row; network and comparison panels use new selectors; CSV helpers renamed (pxExport*); modals and telemetry refreshed. Made-with: Cursor
2009 lines
113 KiB
PHP
2009 lines
113 KiB
PHP
@script
|
|
<script>
|
|
let _scriptInit = true; // Must start with let/const for Alpine.evaluate() IIFE wrapping
|
|
|
|
// Extract IP from string, handling ports for both IPv4 and IPv6
|
|
window.extractIP = function(ip) {
|
|
if (!ip || typeof ip !== 'string') return ip;
|
|
|
|
ip = ip.trim();
|
|
|
|
// Handle IPv4 with port (e.g., "192.168.1.1:8080")
|
|
const ipv4WithPort = /^(\d+\.\d+\.\d+\.\d+):(\d+)$/.exec(ip);
|
|
if (ipv4WithPort) {
|
|
return ipv4WithPort[1];
|
|
}
|
|
|
|
// Handle IPv6 with port in brackets (e.g., "[2001:db8::1]:8080")
|
|
const ipv6WithPort = /^\[([^\]]+)\]:(\d+)$/.exec(ip);
|
|
if (ipv6WithPort) {
|
|
return ipv6WithPort[1];
|
|
}
|
|
|
|
// Check if it's a valid IPv6 address (contains multiple colons)
|
|
if (ip.includes(':') && (ip.match(/:/g) || []).length >= 2) {
|
|
// Might be IPv6, return as-is
|
|
return ip;
|
|
}
|
|
|
|
return ip;
|
|
};
|
|
|
|
// Check if an IP address is public (not private/localhost)
|
|
window.isPublicIP = function(ip) {
|
|
if (!ip || ip === '—' || typeof ip !== 'string') return false;
|
|
|
|
// Extract IP address (handles ports properly for both IPv4 and IPv6)
|
|
ip = window.extractIP(ip);
|
|
|
|
// Check for localhost
|
|
if (ip === 'localhost' || ip === '127.0.0.1' || ip === '::1') return false;
|
|
|
|
// Check for IPv4 private ranges
|
|
const ipv4PrivateRanges = [
|
|
/^10\./, // 10.0.0.0/8
|
|
/^172\.(1[6-9]|2[0-9]|3[0-1])\./, // 172.16.0.0/12
|
|
/^192\.168\./, // 192.168.0.0/16
|
|
/^169\.254\./, // 169.254.0.0/16 (link-local)
|
|
/^127\./, // 127.0.0.0/8
|
|
/^0\./ // 0.0.0.0/8
|
|
];
|
|
|
|
// Check if it's IPv4
|
|
const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/;
|
|
if (ipv4Regex.test(ip)) {
|
|
// Check against private ranges
|
|
if (ipv4PrivateRanges.some(range => range.test(ip))) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// Check if it's IPv6
|
|
if (ip.includes(':')) {
|
|
// Check for IPv6 private ranges
|
|
// Link-local (fe80::/10)
|
|
if (/^fe[89ab][0-9a-fA-F]:/i.test(ip)) {
|
|
return false;
|
|
}
|
|
|
|
// Unique Local Address - ULA (fc00::/7)
|
|
if (/^f[cd][0-9a-fA-F]/i.test(ip)) {
|
|
return false;
|
|
}
|
|
|
|
// IPv4-mapped IPv6 (::ffff:x.x.x.x)
|
|
if (ip.startsWith('::ffff:')) {
|
|
const ipv4Part = ip.substring(7);
|
|
return window.isPublicIP(ipv4Part);
|
|
}
|
|
|
|
// If it's a valid-looking IPv6 and not in private ranges, consider it public
|
|
// Basic IPv6 validation
|
|
const ipv6Regex = /^([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}$/;
|
|
return ipv6Regex.test(ip);
|
|
}
|
|
|
|
return false;
|
|
};
|
|
|
|
// Fetch IP geolocation data via server-side proxy (uses HTTP protocol server-side)
|
|
window.fetchIPLocation = async function(ip) {
|
|
if (!window.isPublicIP(ip)) {
|
|
return { status: 'fail', message: 'Not a public IP address' };
|
|
}
|
|
|
|
// Extract IP address (handles ports properly for both IPv4 and IPv6)
|
|
ip = window.extractIP(ip);
|
|
|
|
try {
|
|
// Use Livewire to call server-side method which fetches from http://ip-api.com/json/{ip}
|
|
// This avoids mixed content issues when the site is served over HTTPS
|
|
const data = await @this.call('fetchIPLocation', ip);
|
|
return data || { status: 'fail', message: 'No data returned' };
|
|
} catch (error) {
|
|
console.error('Error fetching IP location:', error);
|
|
return { status: 'fail', message: 'Failed to fetch location data: ' + (error.message || 'Unknown error') };
|
|
}
|
|
};
|
|
|
|
// Helper function to format descriptions with sections (used by Direction, Protocol, and State modals)
|
|
const formatStateDescription = (overview, meaning, scenarios, notes = null) => {
|
|
let html = '<div class="space-y-4">';
|
|
if (overview) {
|
|
html += `<div><h4 class="font-semibold text-base mb-2">Overview<\/h4><p>${overview}<\/p><\/div>`;
|
|
}
|
|
if (meaning) {
|
|
html += `<div><h4 class="font-semibold text-base mb-2">What This Means<\/h4><p>${meaning}<\/p><\/div>`;
|
|
}
|
|
if (scenarios) {
|
|
html += `<div><h4 class="font-semibold text-base mb-2">Common Scenarios<\/h4>${scenarios}<\/div>`;
|
|
}
|
|
if (notes) {
|
|
html += `<div><h4 class="font-semibold text-base mb-2">Additional Notes<\/h4><p>${notes}<\/p><\/div>`;
|
|
}
|
|
html += '<\/div>';
|
|
return html;
|
|
};
|
|
|
|
// Direction descriptions mapping
|
|
window.getDirectionDescription = function(direction) {
|
|
if (!direction || typeof direction !== 'string') {
|
|
return '<div class="space-y-4"><p>Unknown direction<\/p><\/div>';
|
|
}
|
|
|
|
const directionUpper = direction.toUpperCase();
|
|
const directionDescriptions = {
|
|
'INBOUND': formatStateDescription(
|
|
'Inbound traffic represents network connections coming TO your server. This means data is being sent from an external source (another computer, device, or service) and arriving at your server.',
|
|
'All directions are shown from your server\'s perspective. "Inbound" means the connection is coming IN to your server - external clients or services are connecting to your server.',
|
|
'<p>Inbound connections typically include:<\/p><ul class="list-disc list-inside space-y-1 ml-2 mt-2"><li>External clients connecting to your server (e.g., web browsers accessing your website)<\/li><li>API requests from external services<\/li><li>File uploads or data being sent to your server<\/li><li>Remote connections (SSH, RDP) to your server<\/li><li>Email being received by your mail server<\/li><li>DNS queries to your DNS server<\/li><\/ul>'
|
|
),
|
|
'OUTBOUND': formatStateDescription(
|
|
'Outbound traffic represents network connections going FROM your server. This means data is being sent from your server to an external destination (another computer, device, or service).',
|
|
'All directions are shown from your server\'s perspective. "Outbound" means the connection is going OUT from your server - your server is initiating connections to external systems.',
|
|
'<p>Outbound connections typically include:<\/p><ul class="list-disc list-inside space-y-1 ml-2 mt-2"><li>Your server making requests to external APIs or services<\/li><li>Database connections to remote servers<\/li><li>Your server downloading files or updates<\/li><li>Your server initiating connections to other systems<\/li><li>DNS queries from your server to external DNS servers<\/li><li>Email being sent from your mail server<\/li><\/ul>'
|
|
),
|
|
};
|
|
|
|
return directionDescriptions[directionUpper] || formatStateDescription(
|
|
`Direction "${direction}" represents the flow of network traffic relative to your server.`,
|
|
'All directions are shown from your server\'s perspective, indicating whether traffic is coming into your server (inbound) or going out from your server (outbound).',
|
|
''
|
|
);
|
|
};
|
|
|
|
// Protocol descriptions mapping
|
|
window.getProtocolDescription = function(protocol) {
|
|
if (!protocol || typeof protocol !== 'string') {
|
|
return formatStateDescription('Unknown protocol', '', '');
|
|
}
|
|
|
|
const protocolUpper = protocol.toUpperCase();
|
|
const protocolDescriptions = {
|
|
'TCP': formatStateDescription(
|
|
'TCP (Transmission Control Protocol) is a connection-oriented protocol that provides reliable, ordered, and error-checked delivery of data between applications.',
|
|
'TCP establishes a connection before data transfer and ensures data integrity through acknowledgments and retransmissions. It guarantees that data is delivered in order and without errors.',
|
|
'<p>Common uses include:<\/p><ul class="list-disc list-inside space-y-1 ml-2 mt-2"><li>Web browsing (HTTP/HTTPS)<\/li><li>Email (SMTP, IMAP, POP3)<\/li><li>File transfers (FTP)<\/li><li>Remote access (SSH, RDP)<\/li><li>Database connections<\/li><li>API communications<\/li><\/ul>',
|
|
'TCP is the most commonly used transport protocol on the Internet and provides reliable communication at the cost of slightly higher overhead compared to UDP.'
|
|
),
|
|
'UDP': formatStateDescription(
|
|
'UDP (User Datagram Protocol) is a connectionless protocol that provides fast, low-overhead data transmission without guarantees of delivery, ordering, or duplicate protection.',
|
|
'UDP does not establish connections before sending data and does not provide reliability features like acknowledgments or retransmissions. It prioritizes speed over reliability.',
|
|
'<p>Common uses include:<\/p><ul class="list-disc list-inside space-y-1 ml-2 mt-2"><li>DNS queries<\/li><li>Video streaming<\/li><li>Online gaming<\/li><li>VoIP (Voice over IP)<\/li><li>Real-time applications<\/li><li>DHCP (Dynamic Host Configuration Protocol)<\/li><\/ul>',
|
|
'UDP is ideal for applications where speed is more important than reliability, or where occasional packet loss is acceptable.'
|
|
),
|
|
'ICMP': formatStateDescription(
|
|
'ICMP (Internet Control Message Protocol) is a network layer protocol used for diagnostic and control purposes.',
|
|
'ICMP is used by network devices to send error messages and operational information. It is essential for network troubleshooting and does not carry application data.',
|
|
'<p>Common uses include:<\/p><ul class="list-disc list-inside space-y-1 ml-2 mt-2"><li>Ping (echo request/reply) for connectivity testing<\/li><li>Traceroute for path discovery<\/li><li>Error reporting (destination unreachable, time exceeded)<\/li><li>Network diagnostics<\/li><\/ul>',
|
|
'ICMP is a stateless protocol and does not use connection states like TCP or UDP. It operates at the network layer alongside IP.'
|
|
),
|
|
'ICMPV6': formatStateDescription(
|
|
'ICMPv6 (Internet Control Message Protocol version 6) is the IPv6 equivalent of ICMP, used for error reporting and diagnostic functions in IPv6 networks.',
|
|
'ICMPv6 includes additional features beyond what ICMP provides for IPv4, including integrated support for address configuration and network discovery.',
|
|
'<p>Key features include:<\/p><ul class="list-disc list-inside space-y-1 ml-2 mt-2"><li>Neighbor Discovery Protocol (NDP) for IPv6<\/li><li>Path MTU Discovery for IPv6<\/li><li>Router discovery and configuration<\/li><li>Error reporting and diagnostics<\/li><\/ul>',
|
|
'ICMPv6 is essential for IPv6 network operation and provides functions that in IPv4 are handled by multiple protocols (ARP, ICMP, etc.).'
|
|
),
|
|
'IGMP': formatStateDescription(
|
|
'IGMP (Internet Group Management Protocol) is a communications protocol used by hosts and adjacent routers to establish multicast group memberships.',
|
|
'IGMP is used to manage multicast group membership on IPv4 networks, allowing hosts to join and leave multicast groups and routers to track which hosts are members of which groups.',
|
|
'<p>Common uses include:<\/p><ul class="list-disc list-inside space-y-1 ml-2 mt-2"><li>Multicast video streaming<\/li><li>Software distribution to multiple hosts<\/li><li>Network discovery protocols<\/li><li>IP television (IPTV)<\/li><\/ul>',
|
|
'IGMP enables efficient one-to-many communication by allowing routers to forward multicast traffic only to networks with active group members.'
|
|
),
|
|
'ESP': formatStateDescription(
|
|
'ESP (Encapsulating Security Payload) is a protocol used in IPsec to provide confidentiality, data origin authentication, connectionless integrity, and anti-replay protection for IP packets.',
|
|
'ESP encrypts the payload (data) of IP packets and provides authentication to ensure data integrity and origin verification.',
|
|
'<p>Common uses include:<\/p><ul class="list-disc list-inside space-y-1 ml-2 mt-2"><li>VPN connections (Virtual Private Networks)<\/li><li>Secure site-to-site connections<\/li><li>Encrypted network tunnels<\/li><li>Secure remote access<\/li><\/ul>',
|
|
'ESP is part of the IPsec suite and can be used alone or in combination with AH (Authentication Header) for enhanced security.'
|
|
),
|
|
'AH': formatStateDescription(
|
|
'AH (Authentication Header) is a protocol used in IPsec to provide connectionless integrity, data origin authentication, and optional anti-replay protection for IP packets.',
|
|
'AH authenticates IP packets to ensure they have not been modified in transit and verifies the origin of the data. Unlike ESP, AH does not provide encryption.',
|
|
'<p>Common uses include:<\/p><ul class="list-disc list-inside space-y-1 ml-2 mt-2"><li>VPN connections where integrity is critical<\/li><li>Secure network tunnels<\/li><li>Site-to-site connections<\/li><li>Combined with ESP for enhanced security<\/li><\/ul>',
|
|
'AH provides authentication without encryption, making it useful when you need to verify data integrity but encryption is not required or is handled separately.'
|
|
),
|
|
'GRE': formatStateDescription(
|
|
'GRE (Generic Routing Encapsulation) is a tunneling protocol used to encapsulate a wide variety of network layer protocols inside virtual point-to-point links over an Internet Protocol network.',
|
|
'GRE creates tunnels that can carry many different protocols, allowing you to transport protocols that would not normally be routable over an IP network.',
|
|
'<p>Common uses include:<\/p><ul class="list-disc list-inside space-y-1 ml-2 mt-2"><li>VPN tunnels<\/li><li>Site-to-site connections<\/li><li>Multicast tunneling<\/li><li>Carrying non-IP protocols over IP networks<\/li><li>Network virtualization<\/li><\/ul>',
|
|
'GRE is a simple, lightweight tunneling protocol that adds minimal overhead and is widely supported across network equipment.'
|
|
),
|
|
'SCTP': formatStateDescription(
|
|
'SCTP (Stream Control Transmission Protocol) is a transport layer protocol that provides reliable, message-oriented communication with congestion control and multi-homing support.',
|
|
'SCTP combines features of TCP (reliability, congestion control) with some UDP characteristics (message-oriented) and adds unique features like multi-homing and message bundling.',
|
|
'<p>Common uses include:<\/p><ul class="list-disc list-inside space-y-1 ml-2 mt-2"><li>Telecommunications signaling (SIGTRAN)<\/li><li>WebRTC data channels<\/li><li>Database replication<\/li><li>Applications requiring multi-homed connections<\/li><\/ul>',
|
|
'SCTP is less common than TCP or UDP but provides advantages for specific use cases, especially in telecommunications and applications requiring multiple network paths.'
|
|
),
|
|
'DCCP': formatStateDescription(
|
|
'DCCP (Datagram Congestion Control Protocol) is a message-oriented transport layer protocol that provides congestion control for unreliable datagrams.',
|
|
'DCCP provides congestion control (like TCP) but without reliability guarantees (like UDP), making it suitable for applications that need congestion control but can tolerate packet loss.',
|
|
'<p>Common uses include:<\/p><ul class="list-disc list-inside space-y-1 ml-2 mt-2"><li>Streaming media applications<\/li><li>Online gaming where congestion control is needed<\/li><li>Real-time applications requiring flow control<\/li><\/ul>',
|
|
'DCCP is relatively uncommon and provides a middle ground between TCP (reliable but slower) and UDP (fast but no congestion control).'
|
|
),
|
|
'OSPF': formatStateDescription(
|
|
'OSPF (Open Shortest Path First) is a routing protocol used in IP networks to determine the best path for data packets based on link-state routing algorithms.',
|
|
'OSPF is an Interior Gateway Protocol (IGP) that routers use to exchange routing information and build a map of the network topology to calculate optimal routes.',
|
|
'<p>Key features include:<\/p><ul class="list-disc list-inside space-y-1 ml-2 mt-2"><li>Link-state routing for efficient path calculation<\/li><li>Fast convergence after network changes<\/li><li>Support for variable-length subnet masks (VLSM)<\/li><li>Authentication support for security<\/li><\/ul>',
|
|
'OSPF is commonly used in enterprise networks and internet service provider networks for internal routing.'
|
|
),
|
|
'EIGRP': formatStateDescription(
|
|
'EIGRP (Enhanced Interior Gateway Routing Protocol) is a Cisco proprietary routing protocol that combines the benefits of distance-vector and link-state routing protocols.',
|
|
'EIGRP is an advanced Interior Gateway Protocol that uses Diffusing Update Algorithm (DUAL) to provide fast convergence and efficient use of bandwidth.',
|
|
'<p>Key features include:<\/p><ul class="list-disc list-inside space-y-1 ml-2 mt-2"><li>Fast convergence<\/li><li>Low bandwidth usage<\/li><li>Support for multiple network layer protocols<\/li><li>Route summarization capabilities<\/li><\/ul>',
|
|
'EIGRP is primarily used in Cisco-based networks and provides efficient routing with minimal network overhead.'
|
|
),
|
|
};
|
|
|
|
return protocolDescriptions[protocolUpper] || formatStateDescription(
|
|
`The protocol "${protocol}" is a network protocol identifier that defines the rules and format for data communication between network devices.`,
|
|
'Network protocols specify how data is formatted, transmitted, and received over a network.',
|
|
''
|
|
);
|
|
};
|
|
|
|
// Connection state descriptions mapping
|
|
window.getStateDescription = function(state) {
|
|
if (!state || typeof state !== 'string') {
|
|
return 'Unknown connection state';
|
|
}
|
|
|
|
// Handle special case for ICMP (0:0)
|
|
if (state === '0:0' || state.trim() === '0:0') {
|
|
return `<div class="space-y-4">
|
|
<div>
|
|
<h4 class="font-semibold text-base mb-2">Overview<\/h4>
|
|
<p>0:0 indicates that there is no state level data for this connection. This state is typically only found on ICMP (Internet Control Message Protocol) states, since ICMP does not have state levels like other protocols (TCP, UDP, etc.).<\/p>
|
|
<\/div>
|
|
<div>
|
|
<h4 class="font-semibold text-base mb-2">What This Means<\/h4>
|
|
<p>ICMP is a stateless protocol used for diagnostic and control purposes such as ping (echo request/reply), traceroute, or error reporting. The "0:0" format means there is no connection state tracking because ICMP packets are connectionless and don't establish sessions like TCP connections do.<\/p>
|
|
<\/div>
|
|
<div>
|
|
<h4 class="font-semibold text-base mb-2">Is This Normal?<\/h4>
|
|
<p>Yes, this is completely normal for ICMP traffic and simply indicates the protocol's stateless nature. ICMP connections will always show 0:0 since they don't use connection states.<\/p>
|
|
<\/div>
|
|
<\/div>`;
|
|
}
|
|
|
|
// Handle colon-separated states (e.g., "TIME_WAIT:TIME_WAIT" or "SINGLE:MULTIPLE")
|
|
let stateParts = state.split(':');
|
|
let primaryState = stateParts[0] ? stateParts[0].trim().toUpperCase() : '';
|
|
let secondaryState = stateParts[1] ? stateParts[1].trim().toUpperCase() : '';
|
|
|
|
// Handle UDP states (SINGLE:MULTIPLE or MULTIPLE:SINGLE) - common DNS pattern
|
|
if ((primaryState === 'SINGLE' && secondaryState === 'MULTIPLE') ||
|
|
(primaryState === 'MULTIPLE' && secondaryState === 'SINGLE')) {
|
|
return `<div class="space-y-4">
|
|
<div>
|
|
<h4 class="font-semibold text-base mb-2">Overview<\/h4>
|
|
<p>SINGLE:MULTIPLE (or MULTIPLE:SINGLE) is commonly observed with DNS queries and other UDP-based services. According to pfSense documentation, this pattern indicates asymmetric state tracking for UDP connections.<\/p>
|
|
<\/div>
|
|
<div>
|
|
<h4 class="font-semibold text-base mb-2">State Format Explanation<\/h4>
|
|
<ul class="list-disc list-inside space-y-1 ml-2">
|
|
<li>The part before the colon represents the connection state for the source side<\/li>
|
|
<li>The part after the colon represents the connection state for the destination side<\/li>
|
|
<li><strong>SINGLE<\/strong> means the firewall has observed a single packet from that direction<\/li>
|
|
<li><strong>MULTIPLE<\/strong> means the firewall has observed multiple packets from that direction<\/li>
|
|
<\/ul>
|
|
<\/div>
|
|
<div>
|
|
<h4 class="font-semibold text-base mb-2">Common Scenarios<\/h4>
|
|
<p>This pattern typically occurs when a single request packet was sent, but the response came in multiple packets. This is very common with DNS queries where a small query packet is sent, but the response with DNS records is larger and may be split into multiple packets.<\/p>
|
|
<\/div>
|
|
<div>
|
|
<h4 class="font-semibold text-base mb-2">Protocol Behavior<\/h4>
|
|
<p>For UDP and other connectionless protocols, the firewall tracks state separately for each direction since UDP has no formal connection establishment like TCP does. This is completely normal for UDP connections.<\/p>
|
|
<\/div>
|
|
<\/div>`;
|
|
}
|
|
|
|
// If both parts are the same (e.g., "TIME_WAIT:TIME_WAIT", "ESTABLISHED:ESTABLISHED")
|
|
// Extract just the primary state for the description
|
|
const stateUpper = primaryState || state.toUpperCase();
|
|
const stateDescriptions = {
|
|
'ESTABLISHED': formatStateDescription(
|
|
'ESTABLISHED means a TCP connection is considered fully established by this side. According to pfSense documentation, this indicates the connection handshake (three-way handshake) has been completed and this side is actively exchanging data.',
|
|
'When you see ESTABLISHED:ESTABLISHED, it means both sides have a fully established two-way TCP connection. This is the normal, healthy state for active network sessions. Both directions are synchronized and actively communicating.',
|
|
'<p>Common examples include:<\/p><ul class="list-disc list-inside space-y-1 ml-2 mt-2"><li>Web browsing (HTTP/HTTPS)<\/li><li>File transfers<\/li><li>API communications<\/li><li>Database connections<\/li><li>SSH sessions<\/li><li>Any active TCP service interaction<\/li><\/ul>'
|
|
),
|
|
'SYN_SENT': formatStateDescription(
|
|
'SYN_SENT means, for TCP connections, the side showing this state sent a TCP SYN packet attempting to start a connection handshake. According to pfSense documentation, this indicates the connection is being initiated.',
|
|
'This is a normal intermediate state that should quickly transition to ESTABLISHED or SYN_RECEIVED. If it persists, it indicates a connection attempt that failed or timed out.',
|
|
'<p>When you see SYN_SENT:CLOSED, it means the side showing SYN_SENT has sent a TCP SYN packet but no response has been received from the far side. This often occurs when:<\/p><ul class="list-disc list-inside space-y-1 ml-2 mt-2"><li>The packet was blocked by a firewall or security rule<\/li><li>The packet was dropped or filtered along the network path<\/li><li>The destination is not responding or unavailable<\/li><li>Network routing issues prevent the packet from reaching its destination<\/li><li>The destination service is not running or not listening on the target port<\/li><\/ul>'
|
|
),
|
|
'SYN_RECEIVED': formatStateDescription(
|
|
'SYN_RECEIVED means this side has received a TCP SYN packet from the remote endpoint and has sent a SYN-ACK response.',
|
|
'The connection is in the process of being established and is waiting for the final ACK packet to complete the three-way handshake.',
|
|
'<p>This state typically appears:<\/p><ul class="list-disc list-inside space-y-1 ml-2 mt-2"><li>On the receiving side when accepting inbound connection requests<\/li><li>During the second step of the three-way handshake<\/li><li>As a transitional state that should quickly move to ESTABLISHED<\/li><\/ul><p class="mt-2">The connection handshake is almost complete and should quickly transition to ESTABLISHED once the final acknowledgment is received from the initiating side.<\/p>'
|
|
),
|
|
'FIN_WAIT_1': formatStateDescription(
|
|
'FIN_WAIT_1 means a TCP connection is in the process of closing. According to pfSense documentation, TIME_WAIT/FIN_WAIT indicates a TCP connection is in the process of closing and finishing up.',
|
|
'FIN_WAIT_1 specifically means this side has sent a FIN (finish) packet to request closing the connection and is waiting for an acknowledgment (ACK) from the remote endpoint. This is the first step in the graceful connection termination process.',
|
|
'<p>This state appears during normal connection shutdown sequences. The connection is being closed, but the remote side has not yet acknowledged the close request.<\/p>'
|
|
),
|
|
'FIN_WAIT_2': formatStateDescription(
|
|
'FIN_WAIT_2 means a TCP connection is in the process of closing.',
|
|
'This side\'s FIN packet has been acknowledged by the remote endpoint, but the remote endpoint has not yet sent its own FIN packet to close the connection. The connection is partially closed from this side\'s perspective - it has signaled it wants to close and received acknowledgment, but is waiting for the remote side to also signal its intention to close.',
|
|
'<p>This is a transitional state during connection termination where one side has initiated closure. The connection remains in this state until the remote endpoint sends its own FIN packet to complete the closure.<\/p>'
|
|
),
|
|
'TIME_WAIT': formatStateDescription(
|
|
'TIME_WAIT means a TCP connection is in the process of closing and finishing up.',
|
|
'The connection has been closed, but this side is keeping it in a waiting state for a period of time (typically 2-4 minutes) before fully releasing the connection resources.',
|
|
'<p>This ensures that any delayed, out-of-order, or duplicate packets from the recently closed connection can be properly handled and discarded before a new connection potentially reuses the same port and sequence numbers.<\/p><p class="mt-2">This is a normal part of TCP connection cleanup and helps prevent issues where old packets from a closed connection could interfere with new connections.<\/p>'
|
|
),
|
|
'CLOSE_WAIT': formatStateDescription(
|
|
'CLOSE_WAIT means this side has received a FIN packet from the remote endpoint (meaning the remote side wants to close the connection), and this side is waiting for the local application to finish processing and send its own FIN packet to complete the closure.',
|
|
'This state appears when the remote endpoint initiates the connection closure.',
|
|
'<p>The local application needs to:<\/p><ul class="list-disc list-inside space-y-1 ml-2 mt-2"><li>Complete any pending operations<\/li><li>Clean up resources<\/li><li>Respond with its own close signal to complete the graceful shutdown<\/li><\/ul>'
|
|
),
|
|
'CLOSING': formatStateDescription(
|
|
'CLOSING means both sides of the connection are simultaneously trying to close it.',
|
|
'Both this side and the remote endpoint have sent FIN packets and are waiting for acknowledgments from each other.',
|
|
'<p>This is relatively uncommon but can occur when both sides decide to close the connection at nearly the same time. The connection is in the process of closing from both directions simultaneously, with both sides having signaled their intention to close.<\/p>'
|
|
),
|
|
'LAST_ACK': formatStateDescription(
|
|
'LAST_ACK means this side has sent a FIN packet and received an acknowledgment, but is waiting for the final acknowledgment (ACK) from the remote endpoint to complete the connection termination process.',
|
|
'This is the final stage of closing a connection.',
|
|
'<p>Once this final ACK is received, the connection will transition to CLOSED and all resources will be fully released. This state indicates the connection is almost completely closed and just awaiting final confirmation.<\/p>'
|
|
),
|
|
'LISTEN': formatStateDescription(
|
|
'LISTEN means this side is waiting for incoming connection requests on a specific port.',
|
|
'This state appears on servers that are ready to accept new connections from clients. The service is actively listening and waiting for clients to initiate connections.',
|
|
'<p>Common examples include:<\/p><ul class="list-disc list-inside space-y-1 ml-2 mt-2"><li>Web servers listening on port 80 (HTTP) or 443 (HTTPS)<\/li><li>SSH daemons on port 22<\/li><li>Database servers on their respective ports<\/li><li>Any service waiting to accept incoming connections<\/li><\/ul><p class="mt-2">This is normal for server applications that need to accept incoming connections.<\/p>'
|
|
),
|
|
'CLOSED': formatStateDescription(
|
|
'CLOSED means, for TCP connections, the side with this status considers the connection closed, or the firewall has not received any traffic matching this state.',
|
|
'According to pfSense documentation, this indicates either the connection was never established, has been fully closed, or no packets have been observed.',
|
|
'<p>All connection resources have been released, and the connection can no longer be used for data exchange. This is the final state after a connection has been completely closed, or the initial state before a connection is established.<\/p>'
|
|
),
|
|
'NEW': formatStateDescription(
|
|
'NEW means a new connection has been detected but is not yet fully established.',
|
|
'This state appears during the very initial phase of connection setup, typically after the first packet is received but before the connection handshake is completed or recognized.',
|
|
'<p>It\'s a transitional state that should quickly move to:<\/p><ul class="list-disc list-inside space-y-1 ml-2 mt-2"><li>SYN_RECEIVED (for inbound connections)<\/li><li>ESTABLISHED (for fully established connections)<\/li><\/ul><p class="mt-2">Connections should not remain in NEW state for long periods. If they do, it may indicate an issue with connection establishment.<\/p>'
|
|
),
|
|
'RELATED': formatStateDescription(
|
|
'RELATED means this connection is associated with an existing connection, often used by firewalls for state tracking and security purposes.',
|
|
'The firewall marks these connections as RELATED so they can be properly managed together, allowing the firewall to recognize that the connection is legitimate and related to an established primary connection.',
|
|
'<p>This commonly appears with protocols like FTP that use multiple connections:<\/p><ul class="list-disc list-inside space-y-1 ml-2 mt-2"><li>A control connection for commands<\/li><li>Separate data connections for file transfers<\/li><\/ul><p class="mt-2">This helps maintain security while allowing complex protocols that require multiple connections to function properly.<\/p>'
|
|
),
|
|
'INVALID': formatStateDescription(
|
|
'INVALID means the connection state does not conform to standard TCP/IP connection state machine rules, or it has been explicitly marked as invalid by the firewall due to policy violations or security concerns.',
|
|
'This indicates that the connection has violated protocol rules or security policies.',
|
|
'<p>This may indicate:<\/p><ul class="list-disc list-inside space-y-1 ml-2 mt-2"><li>Malformed or corrupted connection attempts that violate protocol rules<\/li><li>Connection attempts that don\'t follow proper state transitions<\/li><li>Connections flagged by security policies or intrusion detection systems<\/li><li>Potential security threats, port scans, or attack attempts<\/li><li>Protocol anomalies or implementation errors<\/li><li>Packets that don\'t match expected state machine transitions<\/li><\/ul>',
|
|
'<strong>Security Note:<\/strong> If you see many INVALID connections, especially from the same source, it may warrant security investigation as it could indicate malicious activity, port scanning, or misconfigured applications.'
|
|
),
|
|
'NO_TRAFFIC': formatStateDescription(
|
|
'NO_TRAFFIC means the firewall has not received any packets that match this state from this direction. According to pfSense documentation, this indicates no packets have been observed from this side.',
|
|
'For UDP and other connectionless protocols, this means no response has been received from the destination side after an initial packet was sent.',
|
|
'<p>This is common when you see SINGLE:NO_TRAFFIC, where one side sent a packet (SINGLE) but received no response (NO_TRAFFIC). According to pfSense, this is similar to SYN_SENT:CLOSED but for UDP and other connectionless protocols - no response has been received from the destination side.<\/p><p class="mt-2">This could indicate:<\/p><ul class="list-disc list-inside space-y-1 ml-2 mt-2"><li>The destination is not responding<\/li><li>Packets are being dropped by a firewall<\/li><li>The service is not available<\/li><li>Network connectivity issues<\/li><\/ul>'
|
|
),
|
|
'SINGLE': formatStateDescription(
|
|
'SINGLE means the firewall has observed a single packet on this state from this side. According to pfSense documentation, this indicates only one packet has been seen from this direction.',
|
|
'For UDP and other connectionless protocols, this is common for UDP-based services where requests and responses may be asymmetric.',
|
|
'<p>Common patterns:<\/p><ul class="list-disc list-inside space-y-1 ml-2 mt-2"><li><strong>SINGLE:NO_TRAFFIC<\/strong> - A request was sent but no response was received<\/li><li><strong>SINGLE:MULTIPLE<\/strong> - According to pfSense documentation, this is commonly observed with DNS where the client sends one packet but receives a large response in multiple packets<\/li><\/ul><p class="mt-2">This pattern is normal for UDP connections, especially DNS queries where the query is small but the response with DNS records is larger.<\/p>'
|
|
),
|
|
'MULTIPLE': formatStateDescription(
|
|
'MULTIPLE means the firewall has observed multiple packets on this state from this side. According to pfSense documentation, this indicates several packets have been seen from this direction.',
|
|
'For UDP and other connectionless protocols, this means multiple packets have been observed, indicating ongoing communication.',
|
|
'<p>When you see MULTIPLE:MULTIPLE, according to pfSense documentation, there are multiple packets in both directions, which is normal for a fully operational UDP connection.<\/p><p class="mt-2">This suggests active, ongoing bidirectional communication between both endpoints and indicates a healthy UDP-based service interaction.<\/p>'
|
|
),
|
|
};
|
|
|
|
// If we have a colon-separated state and the parts are the same (e.g., "TIME_WAIT:TIME_WAIT", "ESTABLISHED:ESTABLISHED")
|
|
if (state.includes(':') && primaryState === secondaryState && stateDescriptions[stateUpper]) {
|
|
const baseDescription = stateDescriptions[stateUpper];
|
|
const formatNote = `<div class="mt-4 pt-4 border-t border-neutral/20">
|
|
<h4 class="font-semibold text-base mb-2">State Format<\/h4>
|
|
<p>The state "${state}" shows the same state for both directions (${primaryState}:${secondaryState}). The colon (:) separates:<\/p>
|
|
<ul class="list-disc list-inside space-y-1 ml-2 mt-2">
|
|
<li><strong>Left side (${primaryState})<\/strong> = connection state for source-to-destination direction<\/li>
|
|
<li><strong>Right side (${secondaryState})<\/strong> = connection state for destination-to-source direction<\/li>
|
|
<\/ul>
|
|
<p class="mt-2">When both parts are identical, it indicates both directions are synchronized in the same state, which is typical for TCP connections.<\/p>
|
|
<\/div>`;
|
|
return baseDescription.replace('<\/div>', formatNote + '<\/div>');
|
|
}
|
|
|
|
// Handle asymmetric states (e.g., SYN_SENT:CLOSED, SINGLE:NO_TRAFFIC)
|
|
if (state.includes(':') && primaryState !== secondaryState) {
|
|
// Special handling for SYN_SENT:CLOSED - common pfSense pattern
|
|
if (primaryState === 'SYN_SENT' && secondaryState === 'CLOSED') {
|
|
return formatStateDescription(
|
|
'SYN_SENT:CLOSED is a common pairing according to pfSense documentation.',
|
|
'The side showing SYN_SENT has sent a TCP SYN packet to initiate a connection, but no response has been received from the far side (hence CLOSED).',
|
|
'<p>According to pfSense documentation, this often occurs when:<\/p><ul class="list-disc list-inside space-y-1 ml-2 mt-2"><li>The packet was blocked by a firewall or security rule<\/li><li>The packet did not reach its destination<\/li><li>The destination is not responding or unavailable<\/li><li>Network routing issues prevent the packet from being delivered<\/li><li>The destination service is not running or not listening on the target port<\/li><\/ul>',
|
|
'This is a normal state for connection attempts that fail or timeout. It indicates the connection attempt was initiated but never completed.'
|
|
);
|
|
}
|
|
|
|
// Special handling for SINGLE:NO_TRAFFIC - common UDP pattern
|
|
if ((primaryState === 'SINGLE' && secondaryState === 'NO_TRAFFIC') ||
|
|
(primaryState === 'NO_TRAFFIC' && secondaryState === 'SINGLE')) {
|
|
return formatStateDescription(
|
|
'SINGLE:NO_TRAFFIC is a common pairing for UDP and other connectionless protocols according to pfSense documentation.',
|
|
'One side has sent a single packet (SINGLE), but no response has been received from the destination side (NO_TRAFFIC). According to pfSense documentation, this is similar to SYN_SENT:CLOSED but for UDP and other connectionless protocols.',
|
|
'<p>This commonly occurs when:<\/p><ul class="list-disc list-inside space-y-1 ml-2 mt-2"><li>A UDP request was sent but the destination did not respond<\/li><li>The response packet was dropped or filtered by a firewall<\/li><li>The destination service is not available or not responding<\/li><li>Network connectivity issues prevent the response from being received<\/li><\/ul>',
|
|
'This is typical for UDP-based protocols like DNS when queries go unanswered.'
|
|
);
|
|
}
|
|
|
|
// Generic asymmetric state explanation
|
|
const primarySummary = stateDescriptions[primaryState] ?
|
|
(stateDescriptions[primaryState].match(/<p>(.*?)<\/p>/)?.[1] || `The ${primaryState} state`) :
|
|
`The ${primaryState} state`;
|
|
const secondarySummary = stateDescriptions[secondaryState] ?
|
|
(stateDescriptions[secondaryState].match(/<p>(.*?)<\/p>/)?.[1] || `The ${secondaryState} state`) :
|
|
`The ${secondaryState} state`;
|
|
|
|
return formatStateDescription(
|
|
'This connection shows different states for each direction.',
|
|
`According to pfSense documentation, each state entry contains two values with a colon between them, marking which value represents the state of the source (left) and which represents the destination (right). This asymmetric state pattern indicates that the connection is not synchronized between both directions.`,
|
|
`<div class="space-y-3">
|
|
<div>
|
|
<p class="font-semibold mb-1">Source → Destination (${primaryState}):<\/p>
|
|
<p class="ml-4 text-base/80">${primarySummary}<\/p>
|
|
<\/div>
|
|
<div>
|
|
<p class="font-semibold mb-1">Destination → Source (${secondaryState}):<\/p>
|
|
<p class="ml-4 text-base/80">${secondarySummary}<\/p>
|
|
<\/div>
|
|
<\/div>`,
|
|
'Each direction may be at a different stage of connection establishment, maintenance, or termination. This is common during connection setup, closure, or when one side is not responding.'
|
|
);
|
|
}
|
|
|
|
// Try to look up the state (for non-colon-separated states or if we couldn't match above)
|
|
if (stateDescriptions[stateUpper]) {
|
|
return stateDescriptions[stateUpper];
|
|
}
|
|
|
|
// Fallback for unknown states
|
|
return formatStateDescription(
|
|
`The "${primaryState || state}" state represents the current phase of the network connection lifecycle.`,
|
|
'This connection is in a state that indicates how it is being established, maintained, or terminated between your server and the remote endpoint.',
|
|
'<p>The state format uses a colon (:) to separate the state for each direction when both directions are tracked.<\/p>'
|
|
);
|
|
};
|
|
|
|
// Port descriptions mapping
|
|
window.getPortDescription = function(port) {
|
|
const portDescriptions = {
|
|
20: 'FTP Data Transfer - File Transfer Protocol data connection',
|
|
21: 'FTP Control - File Transfer Protocol control connection',
|
|
22: 'SSH - Secure Shell, used for secure remote login and command execution',
|
|
23: 'Telnet - Unencrypted remote terminal access (insecure)',
|
|
25: 'SMTP - Simple Mail Transfer Protocol for sending email',
|
|
53: 'DNS - Domain Name System for resolving domain names to IP addresses',
|
|
67: 'DHCP Server - Dynamic Host Configuration Protocol server',
|
|
68: 'DHCP Client - Dynamic Host Configuration Protocol client',
|
|
69: 'TFTP - Trivial File Transfer Protocol',
|
|
80: 'HTTP - Hypertext Transfer Protocol for web traffic',
|
|
88: 'Kerberos - Network authentication protocol',
|
|
110: 'POP3 - Post Office Protocol version 3 for receiving email',
|
|
119: 'NNTP - Network News Transfer Protocol',
|
|
123: 'NTP - Network Time Protocol for time synchronization',
|
|
135: 'MSRPC - Microsoft Remote Procedure Call',
|
|
139: 'NetBIOS - Network Basic Input/Output System',
|
|
143: 'IMAP - Internet Message Access Protocol for email',
|
|
161: 'SNMP - Simple Network Management Protocol',
|
|
162: 'SNMP Trap - Simple Network Management Protocol trap',
|
|
389: 'LDAP - Lightweight Directory Access Protocol',
|
|
443: 'HTTPS - Hypertext Transfer Protocol Secure (encrypted web traffic)',
|
|
445: 'SMB - Server Message Block for file sharing (Windows)',
|
|
465: 'SMTPS - SMTP over SSL/TLS',
|
|
514: 'Syslog - System logging protocol',
|
|
515: 'LPD/LPR - Line Printer Daemon/Line Printer Remote',
|
|
587: 'SMTP Submission - Mail submission port',
|
|
636: 'LDAPS - LDAP over SSL/TLS',
|
|
993: 'IMAPS - IMAP over SSL/TLS',
|
|
995: 'POP3S - POP3 over SSL/TLS',
|
|
1433: 'MSSQL - Microsoft SQL Server database',
|
|
1521: 'Oracle Database - Oracle database listener',
|
|
3306: 'MySQL - MySQL database server',
|
|
3389: 'RDP - Remote Desktop Protocol (Windows)',
|
|
5432: 'PostgreSQL - PostgreSQL database server',
|
|
5900: 'VNC - Virtual Network Computing remote desktop',
|
|
6379: 'Redis - Redis in-memory data store',
|
|
8080: 'HTTP Alternate - Alternative HTTP port, often used for web proxies',
|
|
8443: 'HTTPS Alternate - Alternative HTTPS port',
|
|
27017: 'MongoDB - MongoDB database server',
|
|
27018: 'MongoDB Shard - MongoDB sharding port',
|
|
27019: 'MongoDB Config - MongoDB configuration server',
|
|
};
|
|
|
|
// Check for specific known port first
|
|
if (portDescriptions[port]) {
|
|
return portDescriptions[port];
|
|
}
|
|
|
|
// Then check for common port ranges
|
|
if (port >= 1024 && port <= 49151) {
|
|
return 'Registered/User Port - Port in the registered range, commonly used by applications';
|
|
} else if (port >= 49152 && port <= 65535) {
|
|
return 'Dynamic/Private Port - Ephemeral port range used for temporary connections';
|
|
}
|
|
|
|
return 'Unknown Port - No standard service associated with this port number';
|
|
};
|
|
|
|
window.formatBytes = function formatBytes(a, b) {
|
|
if (b === undefined) b = 2;
|
|
if (!+a || a < 0 || !isFinite(a)) return "0 Bytes";
|
|
var c = 0 > b ? 0 : b;
|
|
var d = Math.floor(Math.log(a) / Math.log(1024));
|
|
if (d < 0 || d > 8) return "0 Bytes";
|
|
return parseFloat((a/Math.pow(1024,d)).toFixed(c)) + " " + ["Bytes","KiB","MiB","GiB","TiB","PiB","EiB","ZiB","YiB"][d];
|
|
};
|
|
|
|
window.pxStatTableKit = function(tableId) {
|
|
// Check for network_search query parameter for the connections table
|
|
var initialSearch = '';
|
|
if (tableId === 'px-stat-net-sessions') {
|
|
var urlParams = new URLSearchParams(window.location.search);
|
|
var networkSearch = urlParams.get('network_search');
|
|
if (networkSearch) {
|
|
initialSearch = networkSearch;
|
|
// Clean up URL by removing the query parameter (optional)
|
|
// window.history.replaceState({}, document.title, window.location.pathname + window.location.hash);
|
|
}
|
|
}
|
|
|
|
var component = {
|
|
search: initialSearch,
|
|
sortColumn: null,
|
|
sortDirection: 'asc',
|
|
init: function() {
|
|
// If there's an initial search, trigger the filter after a short delay
|
|
// to ensure the table is fully rendered
|
|
if (this.search) {
|
|
var self = this;
|
|
setTimeout(function() {
|
|
self.filterTable();
|
|
}, 100);
|
|
}
|
|
},
|
|
filterTable: function() {
|
|
var table = document.getElementById(tableId);
|
|
if (!table) {
|
|
console.warn('Table not found:', tableId);
|
|
return;
|
|
}
|
|
|
|
var searchTerm = component.search.toLowerCase().trim();
|
|
|
|
// Get all tbody elements in the table
|
|
var allTbodies = [];
|
|
if (table.tBodies && table.tBodies.length > 0) {
|
|
allTbodies = Array.from(table.tBodies);
|
|
} else {
|
|
allTbodies = Array.from(table.querySelectorAll('tbody'));
|
|
}
|
|
|
|
if (allTbodies.length === 0) {
|
|
console.warn('No tbody found in table:', tableId);
|
|
return;
|
|
}
|
|
|
|
var mainTbody = allTbodies[0];
|
|
|
|
// Helper function to check if a row matches the search term
|
|
// Uses data-* attributes for accurate matching instead of textContent
|
|
// (textContent includes hidden modal content causing false positives)
|
|
function rowMatchesSearch(row, term) {
|
|
if (!term) return true;
|
|
|
|
// Define searchable data attributes for each table type
|
|
var searchableAttrs = [];
|
|
|
|
if (tableId === 'px-stat-net-sessions') {
|
|
// Active Connections table
|
|
searchableAttrs = ['direction', 'protocol', 'source', 'destination', 'state'];
|
|
} else if (tableId === 'px-stat-net-peers') {
|
|
// Top Peers table
|
|
searchableAttrs = ['endpoint', 'port'];
|
|
} else if (tableId === 'px-stat-net-ports') {
|
|
// Top Ports table
|
|
searchableAttrs = ['port'];
|
|
}
|
|
|
|
// Search in defined data attributes
|
|
for (var i = 0; i < searchableAttrs.length; i++) {
|
|
var attrValue = row.getAttribute('data-' + searchableAttrs[i]);
|
|
if (attrValue && attrValue.toLowerCase().includes(term)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
// Check if nested tbodies are children of mainTbody or siblings
|
|
var nestedTbodiesInFirst = [];
|
|
var firstTbodyChildren = mainTbody.children;
|
|
for (var i = 0; i < firstTbodyChildren.length; i++) {
|
|
var child = firstTbodyChildren[i];
|
|
if (child.tagName && child.tagName.toUpperCase() === 'TBODY') {
|
|
nestedTbodiesInFirst.push(child);
|
|
}
|
|
}
|
|
|
|
// Also check childNodes
|
|
if (nestedTbodiesInFirst.length === 0) {
|
|
var firstTbodyChildNodes = mainTbody.childNodes;
|
|
for (var j = 0; j < firstTbodyChildNodes.length; j++) {
|
|
var node = firstTbodyChildNodes[j];
|
|
if (node.nodeType === 1 && node.tagName && node.tagName.toUpperCase() === 'TBODY') {
|
|
nestedTbodiesInFirst.push(node);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Filter nested tbodies that are children of mainTbody
|
|
if (nestedTbodiesInFirst.length > 0) {
|
|
nestedTbodiesInFirst.forEach(function(tbody) {
|
|
var row = tbody.querySelector('tr[data-statistics-data-row]');
|
|
if (row) {
|
|
var matches = rowMatchesSearch(row, searchTerm);
|
|
tbody.style.display = matches ? '' : 'none';
|
|
}
|
|
});
|
|
} else if (allTbodies.length > 1) {
|
|
// Multiple tbodies - likely browser moved nested ones to be siblings
|
|
// Skip the first one (main/empty) and filter the rest
|
|
for (var k = 1; k < allTbodies.length; k++) {
|
|
var tbody = allTbodies[k];
|
|
var row = tbody.querySelector('tr[data-statistics-data-row]');
|
|
if (row) {
|
|
var matches = rowMatchesSearch(row, searchTerm);
|
|
tbody.style.display = matches ? '' : 'none';
|
|
}
|
|
}
|
|
} else {
|
|
// Simple structure - rows directly in the first tbody
|
|
var rows = mainTbody.querySelectorAll('tr[data-statistics-data-row]');
|
|
rows.forEach(function(row) {
|
|
var matches = rowMatchesSearch(row, searchTerm);
|
|
row.style.display = matches ? '' : 'none';
|
|
});
|
|
}
|
|
},
|
|
sortTable: function(column, event) {
|
|
// Prevent any event propagation issues
|
|
if (event) {
|
|
event.stopPropagation();
|
|
event.preventDefault();
|
|
}
|
|
|
|
var table = document.getElementById(tableId);
|
|
if (!table) {
|
|
console.warn('Table not found:', tableId);
|
|
return;
|
|
}
|
|
|
|
// Fast path: simple numeric sort for Network Analysis "Traffic" column
|
|
if (tableId === 'px-stat-net-sessions' && column === 'traffic') {
|
|
var tbody = table.tBodies && table.tBodies[0] ? table.tBodies[0] : table.querySelector('tbody');
|
|
if (!tbody) {
|
|
console.warn('No tbody for sessions grid');
|
|
return;
|
|
}
|
|
|
|
// Toggle sort direction
|
|
if (component.sortColumn === column) {
|
|
component.sortDirection = component.sortDirection === 'asc' ? 'desc' : 'asc';
|
|
} else {
|
|
component.sortColumn = column;
|
|
component.sortDirection = 'desc'; // default to highest traffic first
|
|
}
|
|
|
|
var rows = Array.from(tbody.querySelectorAll('tr[data-statistics-data-row]')).filter(function (row) {
|
|
return row.style.display !== 'none';
|
|
});
|
|
|
|
rows.sort(function (a, b) {
|
|
var aBytes = parseFloat(a.getAttribute('data-traffic') || '0') || 0;
|
|
var bBytes = parseFloat(b.getAttribute('data-traffic') || '0') || 0;
|
|
|
|
if (component.sortDirection === 'asc') {
|
|
return aBytes - bBytes;
|
|
}
|
|
return bBytes - aBytes;
|
|
});
|
|
|
|
rows.forEach(function (row) {
|
|
tbody.appendChild(row);
|
|
});
|
|
|
|
return;
|
|
}
|
|
|
|
if (component.sortColumn === column) {
|
|
component.sortDirection = component.sortDirection === 'asc' ? 'desc' : 'asc';
|
|
} else {
|
|
component.sortColumn = column;
|
|
component.sortDirection = 'asc';
|
|
}
|
|
|
|
// Get all tbody elements in the table
|
|
// In nested structures, browsers may move nested tbodies to be siblings
|
|
var allTbodies = [];
|
|
if (table.tBodies && table.tBodies.length > 0) {
|
|
allTbodies = Array.from(table.tBodies);
|
|
} else {
|
|
allTbodies = Array.from(table.querySelectorAll('tbody'));
|
|
}
|
|
|
|
if (allTbodies.length === 0) {
|
|
console.warn('No tbody found in table:', tableId);
|
|
return;
|
|
}
|
|
|
|
// Check if this is a nested tbody structure
|
|
// If we have multiple tbodies, and the first one is empty or has nested tbodies as children
|
|
var mainTbody = allTbodies[0];
|
|
var itemsToSort = [];
|
|
|
|
// First, check if the first tbody has nested tbody children
|
|
var nestedTbodiesInFirst = [];
|
|
var firstTbodyChildren = mainTbody.children;
|
|
for (var i = 0; i < firstTbodyChildren.length; i++) {
|
|
var child = firstTbodyChildren[i];
|
|
if (child.tagName && child.tagName.toUpperCase() === 'TBODY') {
|
|
nestedTbodiesInFirst.push(child);
|
|
}
|
|
}
|
|
|
|
// Also check childNodes for nested tbodies
|
|
if (nestedTbodiesInFirst.length === 0) {
|
|
var firstTbodyChildNodes = mainTbody.childNodes;
|
|
for (var j = 0; j < firstTbodyChildNodes.length; j++) {
|
|
var node = firstTbodyChildNodes[j];
|
|
if (node.nodeType === 1 && node.tagName && node.tagName.toUpperCase() === 'TBODY') {
|
|
nestedTbodiesInFirst.push(node);
|
|
}
|
|
}
|
|
}
|
|
|
|
// If we found nested tbodies in the first tbody, use those
|
|
if (nestedTbodiesInFirst.length > 0) {
|
|
var tbodies = nestedTbodiesInFirst.filter(function(el) {
|
|
return el.style.display !== 'none';
|
|
});
|
|
|
|
tbodies.forEach(function(tbody) {
|
|
var row = tbody.querySelector('tr[data-statistics-data-row]');
|
|
if (row) {
|
|
itemsToSort.push({ element: tbody, row: row });
|
|
}
|
|
});
|
|
} else if (allTbodies.length > 1) {
|
|
// Multiple tbodies - likely browser moved nested ones to be siblings
|
|
// Skip the first one (main/empty) and use the rest
|
|
for (var k = 1; k < allTbodies.length; k++) {
|
|
var tbody = allTbodies[k];
|
|
if (tbody.style.display !== 'none') {
|
|
var row = tbody.querySelector('tr[data-statistics-data-row]');
|
|
if (row) {
|
|
itemsToSort.push({ element: tbody, row: row });
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
// Simple structure - rows directly in the first tbody
|
|
var directRows = Array.from(mainTbody.querySelectorAll('tr[data-statistics-data-row]')).filter(function(row) {
|
|
return row.style.display !== 'none';
|
|
});
|
|
|
|
directRows.forEach(function(row) {
|
|
itemsToSort.push({ element: row, row: row });
|
|
});
|
|
}
|
|
|
|
if (itemsToSort.length === 0) {
|
|
console.warn('No items to sort in table:', tableId, 'mainTbody children:', mainTbody.children.length);
|
|
return;
|
|
}
|
|
|
|
// Convert column name to data attribute format (hyphens)
|
|
var dataAttr = 'data-' + column.replace(/_/g, '-');
|
|
|
|
itemsToSort.sort(function(a, b) {
|
|
var aVal = a.row.getAttribute(dataAttr) || '';
|
|
var bVal = b.row.getAttribute(dataAttr) || '';
|
|
|
|
// Column helpers
|
|
var isDateColumn = column === 'date-time' || column === 'start-time' || column === 'end-time';
|
|
// Treat these columns as numeric even if stored as strings/data-attributes
|
|
var isNumericColumn = (
|
|
column === 'traffic' ||
|
|
column === 'duration' ||
|
|
column === 'bytes' ||
|
|
column === 'size'
|
|
);
|
|
|
|
if (isDateColumn) {
|
|
// Handle date values (format: YYYY-MM-DD HH:mm:ss)
|
|
// Parse dates explicitly with multiple fallback methods
|
|
var parseDate = function(dateStr) {
|
|
if (!dateStr || dateStr === '') {
|
|
return null;
|
|
}
|
|
|
|
// Method 1: Convert YYYY-MM-DD HH:mm:ss to YYYY-MM-DDTHH:mm:ss for ISO parsing
|
|
var isoStr = dateStr.replace(' ', 'T');
|
|
var date = new Date(isoStr);
|
|
if (!isNaN(date.getTime())) {
|
|
return date.getTime();
|
|
}
|
|
|
|
// Method 2: Try parsing as-is
|
|
date = new Date(dateStr);
|
|
if (!isNaN(date.getTime())) {
|
|
return date.getTime();
|
|
}
|
|
|
|
// Method 3: Manual parsing (YYYY-MM-DD HH:mm:ss)
|
|
var parts = dateStr.match(/^(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2}):(\d{2})$/);
|
|
if (parts && parts.length === 7) {
|
|
// parts[0] is full match, parts[1-6] are the groups
|
|
date = new Date(
|
|
parseInt(parts[1], 10), // year
|
|
parseInt(parts[2], 10) - 1, // month (0-indexed)
|
|
parseInt(parts[3], 10), // day
|
|
parseInt(parts[4], 10), // hour
|
|
parseInt(parts[5], 10), // minute
|
|
parseInt(parts[6], 10) // second
|
|
);
|
|
if (!isNaN(date.getTime())) {
|
|
return date.getTime();
|
|
}
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
var aTime = parseDate(aVal);
|
|
var bTime = parseDate(bVal);
|
|
|
|
// Handle empty dates - put them at the end
|
|
if (aTime === null && bTime === null) {
|
|
return 0; // Both empty, equal
|
|
}
|
|
if (aTime === null) {
|
|
return 1; // a is empty, put it last
|
|
}
|
|
if (bTime === null) {
|
|
return -1; // b is empty, put it last
|
|
}
|
|
|
|
// Both have valid dates
|
|
if (component.sortDirection === 'asc') {
|
|
return aTime - bTime;
|
|
} else {
|
|
return bTime - aTime;
|
|
}
|
|
}
|
|
|
|
// Handle numeric values (for traffic, duration, etc.)
|
|
if (isNumericColumn) {
|
|
var aNum = parseFloat(aVal);
|
|
var bNum = parseFloat(bVal);
|
|
|
|
// Normalise NaN and empty values to 0 so rows still participate in sort
|
|
if (isNaN(aNum)) aNum = 0;
|
|
if (isNaN(bNum)) bNum = 0;
|
|
|
|
if (component.sortDirection === 'asc') {
|
|
return aNum - bNum;
|
|
} else {
|
|
return bNum - aNum;
|
|
}
|
|
} else {
|
|
// Generic numeric handling for other cases where both values are valid numbers
|
|
var aNumGeneric = parseFloat(aVal);
|
|
var bNumGeneric = parseFloat(bVal);
|
|
if (!isNaN(aNumGeneric) && !isNaN(bNumGeneric) && aVal !== '' && bVal !== '') {
|
|
return component.sortDirection === 'asc' ? aNumGeneric - bNumGeneric : bNumGeneric - aNumGeneric;
|
|
}
|
|
}
|
|
|
|
// Handle string values
|
|
aVal = (aVal || '').toLowerCase();
|
|
bVal = (bVal || '').toLowerCase();
|
|
if (component.sortDirection === 'asc') {
|
|
return aVal.localeCompare(bVal);
|
|
} else {
|
|
return bVal.localeCompare(aVal);
|
|
}
|
|
});
|
|
|
|
// Reorder elements - appendChild will move existing elements
|
|
// which preserves Alpine.js state and event handlers
|
|
itemsToSort.forEach(function(item) {
|
|
// Determine the correct parent based on where the element currently is
|
|
var currentParent = item.element.parentElement;
|
|
if (currentParent === mainTbody) {
|
|
// Nested tbody is a child of mainTbody
|
|
mainTbody.appendChild(item.element);
|
|
} else if (currentParent === table) {
|
|
// Nested tbody is a sibling of mainTbody (browser moved it)
|
|
table.appendChild(item.element);
|
|
} else {
|
|
// Fallback - try mainTbody first
|
|
mainTbody.appendChild(item.element);
|
|
}
|
|
});
|
|
}
|
|
};
|
|
return component;
|
|
};
|
|
|
|
var maxMem = {{ $status?->maxmem ?? 0 }};
|
|
|
|
var baseOptions = {
|
|
chart: {
|
|
type: 'area',
|
|
height: 300,
|
|
zoom: {
|
|
enabled: false
|
|
},
|
|
toolbar: {
|
|
show: false
|
|
},
|
|
background: 'transparent'
|
|
},
|
|
grid: {
|
|
show: true,
|
|
borderColor: 'hsl(var(--color-neutral))',
|
|
strokeDashArray: 3,
|
|
position: 'back',
|
|
xaxis: {
|
|
lines: {
|
|
show: false,
|
|
}
|
|
},
|
|
yaxis: {
|
|
lines: {
|
|
show: true,
|
|
}
|
|
},
|
|
row: {
|
|
colors: 'hsl(var(--color-neutral))',
|
|
opacity: 0
|
|
}
|
|
},
|
|
theme: {
|
|
mode: document.documentElement.classList.contains('dark') ? 'dark' : 'light',
|
|
},
|
|
xaxis: {
|
|
type: 'datetime',
|
|
labels: {
|
|
datetimeUTC: false,
|
|
style: {
|
|
colors: 'color-mix(in oklab, hsl(var(--color-base)) 60%, transparent)',
|
|
},
|
|
},
|
|
},
|
|
stroke: {
|
|
curve: 'smooth',
|
|
width: 2,
|
|
},
|
|
dataLabels: {
|
|
enabled: false
|
|
},
|
|
legend: {
|
|
show: false,
|
|
position: 'top',
|
|
horizontalAlign: 'right',
|
|
floating: true,
|
|
showForSingleSeries: true,
|
|
}
|
|
}
|
|
|
|
var cpuOptions = Object.assign({}, baseOptions, {
|
|
series: [{
|
|
name: 'CPU %',
|
|
data: [],
|
|
}],
|
|
yaxis: {
|
|
labels: {
|
|
formatter: function(val) {
|
|
return val.toFixed(1) + "%";
|
|
},
|
|
style: {
|
|
colors: ['color-mix(in oklab, hsl(var(--color-base)) 60%, transparent)'],
|
|
},
|
|
}
|
|
},
|
|
colors: ['#008ffb'],
|
|
});
|
|
|
|
var memoryOptions = Object.assign({}, baseOptions, {
|
|
// Single series for memory usage; capacity is reflected via the y-axis max
|
|
series: [{
|
|
name: 'RAM (bytes)',
|
|
data: [],
|
|
}],
|
|
yaxis: {
|
|
labels: {
|
|
formatter: function(val) {
|
|
return formatBytes(val, 1);
|
|
},
|
|
style: {
|
|
colors: ['color-mix(in oklab, hsl(var(--color-base)) 60%, transparent)'],
|
|
},
|
|
},
|
|
// Only lock the max when we have a valid positive value;
|
|
// otherwise let ApexCharts auto-scale so the graph is never flat or broken.
|
|
max: (typeof maxMem === 'number' && maxMem > 0) ? maxMem : undefined,
|
|
min: 0,
|
|
},
|
|
colors: ['#00E396'],
|
|
});
|
|
|
|
var networkOptions = Object.assign({}, baseOptions, {
|
|
series: [{
|
|
name: 'Ingress',
|
|
data: []
|
|
}, {
|
|
name: 'Egress',
|
|
data: []
|
|
}, {
|
|
name: 'P95 combined',
|
|
data: []
|
|
}],
|
|
yaxis: {
|
|
labels: {
|
|
formatter: function(val) {
|
|
return formatBytes(val);
|
|
},
|
|
style: {
|
|
colors: ['color-mix(in oklab, hsl(var(--color-base)) 60%, transparent)'],
|
|
},
|
|
}
|
|
},
|
|
colors: ['#a37443', '#dcce58', '#ef4444'],
|
|
stroke: {
|
|
curve: 'smooth',
|
|
width: [2, 2, 2],
|
|
dashArray: [0, 0, 5]
|
|
},
|
|
fill: {
|
|
type: ['gradient', 'gradient', 'solid'],
|
|
opacity: [0.4, 0.4, 0]
|
|
},
|
|
});
|
|
|
|
var diskOptions = Object.assign({}, baseOptions, {
|
|
series: [{
|
|
name: 'Read B/s',
|
|
data: []
|
|
}, {
|
|
name: 'Write B/s',
|
|
data: []
|
|
}],
|
|
yaxis: {
|
|
labels: {
|
|
formatter: function(val) {
|
|
return formatBytes(val);
|
|
},
|
|
style: {
|
|
colors: ['color-mix(in oklab, hsl(var(--color-base)) 60%, transparent)'],
|
|
},
|
|
}
|
|
},
|
|
colors: ['#9256b5', '#60496f'],
|
|
});
|
|
|
|
var cpuChart;
|
|
var memoryChart;
|
|
var networkChart;
|
|
var diskChart;
|
|
|
|
// Track which charts are present on the page and whether they have received data.
|
|
var cpuChartExpected = false;
|
|
var memoryChartExpected = false;
|
|
var networkChartExpected = false;
|
|
var diskChartExpected = false;
|
|
|
|
var cpuChartHasData = false;
|
|
var memoryChartHasData = false;
|
|
var networkChartHasData = false;
|
|
var diskChartHasData = false;
|
|
|
|
// Track individual retry attempts per chart for more reliable loading
|
|
var chartRetryAttempts = {
|
|
cpu: 0,
|
|
memory: 0,
|
|
network: 0,
|
|
disk: 0
|
|
};
|
|
var maxChartRetryAttempts = 5;
|
|
var chartRetryDelay = 1000; // 1 second between retries
|
|
|
|
// Check if all charts have loaded and dispatch event to hide loading overlay
|
|
function checkAllChartsLoaded() {
|
|
var allExpectedChartsHaveData =
|
|
(!cpuChartExpected || cpuChartHasData) &&
|
|
(!memoryChartExpected || memoryChartHasData) &&
|
|
(!networkChartExpected || networkChartHasData) &&
|
|
(!diskChartExpected || diskChartHasData);
|
|
|
|
if (allExpectedChartsHaveData) {
|
|
try {
|
|
window.dispatchEvent(new CustomEvent('proxmox-all-stats-loaded'));
|
|
} catch (e) {}
|
|
}
|
|
}
|
|
|
|
// Request individual chart data with retry logic
|
|
function requestChartData(chartType) {
|
|
if (chartRetryAttempts[chartType] >= maxChartRetryAttempts) {
|
|
console.warn('Max retry attempts reached for ' + chartType + ' chart');
|
|
return;
|
|
}
|
|
|
|
chartRetryAttempts[chartType]++;
|
|
try {
|
|
// Dispatch individual chart event for more reliable loading
|
|
Livewire.dispatch('proxmox:stats:' + chartType);
|
|
} catch (e) {
|
|
console.warn('Failed to dispatch ' + chartType + ' stats event:', e);
|
|
}
|
|
}
|
|
|
|
// Check if a specific chart needs data and retry if necessary
|
|
function checkAndRetryChart(chartType, chartHasData, chartExpected) {
|
|
if (!chartExpected || chartHasData) {
|
|
return; // Chart not needed or already has data
|
|
}
|
|
|
|
// Check if all charts have loaded and dispatch event
|
|
checkAllChartsLoaded();
|
|
|
|
setTimeout(function() {
|
|
// Re-check the current state (these vars are updated by event handlers)
|
|
var hasData = false;
|
|
switch(chartType) {
|
|
case 'cpu': hasData = cpuChartHasData; break;
|
|
case 'memory': hasData = memoryChartHasData; break;
|
|
case 'network': hasData = networkChartHasData; break;
|
|
case 'disk': hasData = diskChartHasData; break;
|
|
}
|
|
|
|
if (!hasData && chartRetryAttempts[chartType] < maxChartRetryAttempts) {
|
|
requestChartData(chartType);
|
|
checkAndRetryChart(chartType, false, true);
|
|
}
|
|
}, chartRetryDelay);
|
|
}
|
|
|
|
function dispatchStatsWithRetry() {
|
|
// Reset retry counters
|
|
chartRetryAttempts = { cpu: 0, memory: 0, network: 0, disk: 0 };
|
|
|
|
// Request all chart data in parallel using individual events
|
|
// This ensures each chart loads independently - if one fails, others still work
|
|
if (cpuChartExpected) {
|
|
requestChartData('cpu');
|
|
checkAndRetryChart('cpu', false, true);
|
|
}
|
|
if (memoryChartExpected) {
|
|
requestChartData('memory');
|
|
checkAndRetryChart('memory', false, true);
|
|
}
|
|
if (networkChartExpected) {
|
|
requestChartData('network');
|
|
checkAndRetryChart('network', false, true);
|
|
}
|
|
if (diskChartExpected) {
|
|
requestChartData('disk');
|
|
checkAndRetryChart('disk', false, true);
|
|
}
|
|
}
|
|
|
|
function initializeCharts() {
|
|
// Destroy existing charts if they exist
|
|
if (cpuChart) {
|
|
try {
|
|
cpuChart.destroy();
|
|
} catch (e) {}
|
|
cpuChart = null;
|
|
}
|
|
if (memoryChart) {
|
|
try {
|
|
memoryChart.destroy();
|
|
} catch (e) {}
|
|
memoryChart = null;
|
|
}
|
|
if (networkChart) {
|
|
try {
|
|
networkChart.destroy();
|
|
} catch (e) {}
|
|
networkChart = null;
|
|
}
|
|
if (diskChart) {
|
|
try {
|
|
diskChart.destroy();
|
|
} catch (e) {}
|
|
diskChart = null;
|
|
}
|
|
|
|
requestAnimationFrame(function() {
|
|
if (typeof ApexCharts === 'undefined') {
|
|
_apexRetryCount = (_apexRetryCount || 0) + 1;
|
|
if (_apexRetryCount > 25) {
|
|
console.error('ApexCharts failed to load after 25 retries');
|
|
return;
|
|
}
|
|
console.warn('ApexCharts not loaded, retrying (' + _apexRetryCount + '/25)...');
|
|
setTimeout(initializeCharts, 200);
|
|
return;
|
|
}
|
|
_apexRetryCount = 0;
|
|
|
|
var cpuElement = document.querySelector('[data-statistics-chart="cpu"]');
|
|
var memoryElement = document.querySelector('[data-statistics-chart="memory"]');
|
|
var networkElement = document.querySelector('[data-statistics-chart="network"]');
|
|
var diskElement = document.querySelector('[data-statistics-chart="disk"]');
|
|
|
|
cpuChartExpected = !!cpuElement;
|
|
memoryChartExpected = !!memoryElement;
|
|
networkChartExpected = !!networkElement;
|
|
diskChartExpected = !!diskElement;
|
|
|
|
// Reset data flags on re-initialization
|
|
cpuChartHasData = false;
|
|
memoryChartHasData = false;
|
|
networkChartHasData = false;
|
|
diskChartHasData = false;
|
|
|
|
if (cpuElement) {
|
|
cpuChart = new ApexCharts(cpuElement, cpuOptions);
|
|
cpuChart.render();
|
|
}
|
|
|
|
if (memoryElement) {
|
|
memoryChart = new ApexCharts(memoryElement, memoryOptions);
|
|
memoryChart.render();
|
|
}
|
|
|
|
if (networkElement) {
|
|
networkChart = new ApexCharts(networkElement, networkOptions);
|
|
networkChart.render();
|
|
}
|
|
|
|
if (diskElement) {
|
|
diskChart = new ApexCharts(diskElement, diskOptions);
|
|
diskChart.render();
|
|
}
|
|
|
|
// Request stats immediately - don't wait for charts to fully render
|
|
// This speeds up initial data loading
|
|
dispatchStatsWithRetry();
|
|
});
|
|
}
|
|
|
|
// Trigger initial stats request as soon as possible, before charts are ready
|
|
// This allows backend to start fetching data in parallel with chart initialization
|
|
// Using individual chart events for more reliable loading
|
|
if (typeof Livewire !== 'undefined') {
|
|
setTimeout(function() {
|
|
try {
|
|
// Dispatch all four chart requests in parallel for faster initial load
|
|
Livewire.dispatch('proxmox:stats:cpu');
|
|
Livewire.dispatch('proxmox:stats:memory');
|
|
Livewire.dispatch('proxmox:stats:network');
|
|
Livewire.dispatch('proxmox:stats:disk');
|
|
} catch (e) {
|
|
console.warn('Early stats dispatch failed:', e);
|
|
}
|
|
}, 100);
|
|
}
|
|
|
|
// Set up event listeners - these persist across Livewire navigation
|
|
// Store cleanup functions for each listener
|
|
var eventCleanupFunctions = [];
|
|
|
|
function setupEventListeners() {
|
|
// First, clean up any existing listeners
|
|
eventCleanupFunctions.forEach(function(cleanup) {
|
|
if (typeof cleanup === 'function') {
|
|
cleanup();
|
|
}
|
|
});
|
|
eventCleanupFunctions = [];
|
|
|
|
// Get the current server ID from the DOM (dynamically)
|
|
var serverIdElement = document.querySelector('[data-server-id]');
|
|
var currentServerId = serverIdElement ? serverIdElement.getAttribute('data-server-id') : '{{ $server->id }}';
|
|
var cpuCleanup = Livewire.on('proxmox:stats:' + currentServerId + ':cpu', function(eventData) {
|
|
if (!cpuChart) {
|
|
console.warn('CPU chart not initialized');
|
|
return;
|
|
}
|
|
try {
|
|
// Handle Livewire event data format - could be wrapped in array or direct
|
|
var data = eventData;
|
|
if (Array.isArray(eventData) && eventData.length > 0) {
|
|
// If it's an array, take first element (Livewire might wrap it)
|
|
data = eventData[0];
|
|
}
|
|
if (!data || !Array.isArray(data) || data.length === 0) {
|
|
console.warn('No CPU data received', eventData);
|
|
return;
|
|
}
|
|
cpuChart.updateSeries([{
|
|
name: 'CPU %',
|
|
data: data.map(function(item) {
|
|
return {
|
|
x: item.time * 1000,
|
|
y: item.cpu ? item.cpu * 100 : 0,
|
|
}
|
|
}),
|
|
}]);
|
|
|
|
cpuChartHasData = true;
|
|
checkAllChartsLoaded();
|
|
} catch (e) {
|
|
console.error('Error updating CPU chart:', e, eventData);
|
|
}
|
|
});
|
|
eventCleanupFunctions.push(cpuCleanup);
|
|
|
|
var memoryCleanup = Livewire.on('proxmox:stats:' + currentServerId + ':memory', function(eventData) {
|
|
if (!memoryChart) {
|
|
console.warn('Memory chart not initialized');
|
|
return;
|
|
}
|
|
try {
|
|
var data = eventData;
|
|
if (Array.isArray(eventData) && eventData.length > 0) {
|
|
data = eventData[0];
|
|
}
|
|
if (!data || !Array.isArray(data) || data.length === 0) {
|
|
console.warn('No Memory data received', eventData);
|
|
return;
|
|
}
|
|
memoryChart.updateSeries([{
|
|
name: 'RAM (bytes)',
|
|
data: data.map(function(item) {
|
|
return {
|
|
x: item.time * 1000,
|
|
y: item.mem ? item.mem : 0,
|
|
}
|
|
}),
|
|
}]);
|
|
|
|
memoryChartHasData = true;
|
|
checkAllChartsLoaded();
|
|
} catch (e) {
|
|
console.error('Error updating Memory chart:', e, eventData);
|
|
}
|
|
});
|
|
eventCleanupFunctions.push(memoryCleanup);
|
|
|
|
var networkCleanup = Livewire.on('proxmox:stats:' + currentServerId + ':network', function(eventData) {
|
|
if (!networkChart) {
|
|
console.warn('Network chart not initialized');
|
|
return;
|
|
}
|
|
try {
|
|
var data = eventData;
|
|
if (Array.isArray(eventData) && eventData.length > 0) {
|
|
data = eventData[0];
|
|
}
|
|
if (!data || !Array.isArray(data) || data.length === 0) {
|
|
console.warn('No Network data received', eventData);
|
|
return;
|
|
}
|
|
|
|
// Calculate 95th percentile of combined network traffic
|
|
var allTrafficValues = data.map(function(item) {
|
|
return (item.netin || 0) + (item.netout || 0);
|
|
}).filter(function(v) { return v > 0; }).sort(function(a, b) { return a - b; });
|
|
|
|
var p95Value = 0;
|
|
if (allTrafficValues.length > 0) {
|
|
var p95Index = Math.floor(allTrafficValues.length * 0.95);
|
|
p95Value = allTrafficValues[Math.min(p95Index, allTrafficValues.length - 1)];
|
|
}
|
|
|
|
networkChart.updateSeries([{
|
|
name: 'Ingress',
|
|
data: data.map(function(item) {
|
|
return {
|
|
x: item.time * 1000,
|
|
y: item.netin ? item.netin : 0,
|
|
}
|
|
}),
|
|
}, {
|
|
name: 'Egress',
|
|
data: data.map(function(item) {
|
|
return {
|
|
x: item.time * 1000,
|
|
y: item.netout ? item.netout : 0,
|
|
}
|
|
})
|
|
}, {
|
|
name: '95th Percentile (' + formatBytes(p95Value) + ')',
|
|
data: data.map(function(item) {
|
|
return {
|
|
x: item.time * 1000,
|
|
y: p95Value,
|
|
}
|
|
})
|
|
}]);
|
|
|
|
networkChartHasData = true;
|
|
checkAllChartsLoaded();
|
|
} catch (e) {
|
|
console.error('Error updating Network chart:', e, eventData);
|
|
}
|
|
});
|
|
eventCleanupFunctions.push(networkCleanup);
|
|
|
|
var diskCleanup = Livewire.on('proxmox:stats:' + currentServerId + ':disk', function(eventData) {
|
|
if (!diskChart) {
|
|
console.warn('Disk chart not initialized');
|
|
return;
|
|
}
|
|
try {
|
|
var data = eventData;
|
|
if (Array.isArray(eventData) && eventData.length > 0) {
|
|
data = eventData[0];
|
|
}
|
|
if (!data || !Array.isArray(data) || data.length === 0) {
|
|
console.warn('No Disk data received', eventData);
|
|
return;
|
|
}
|
|
diskChart.updateSeries([{
|
|
name: 'Read B/s',
|
|
data: data.map(function(item) {
|
|
return {
|
|
x: item.time * 1000,
|
|
y: item.diskread ? item.diskread : 0,
|
|
}
|
|
}),
|
|
}, {
|
|
name: 'Write B/s',
|
|
data: data.map(function(item) {
|
|
return {
|
|
x: item.time * 1000,
|
|
y: item.diskwrite ? item.diskwrite : 0,
|
|
}
|
|
})
|
|
}]);
|
|
|
|
diskChartHasData = true;
|
|
checkAllChartsLoaded();
|
|
} catch (e) {
|
|
console.error('Error updating Disk chart:', e, eventData);
|
|
}
|
|
});
|
|
eventCleanupFunctions.push(diskCleanup);
|
|
}
|
|
|
|
// Set up event listeners
|
|
setupEventListeners();
|
|
|
|
// Initialize charts
|
|
initializeCharts();
|
|
|
|
// Load Network Analysis after charts are initialized (non-blocking)
|
|
function loadNetworkAnalysisData() {
|
|
// Start the timeout FIRST before loading, so it's guaranteed to start
|
|
startNetworkAnalysisTimeout();
|
|
|
|
setTimeout(function() {
|
|
try {
|
|
// Use a Livewire event instead of @this directly so that
|
|
// the initial load is reliable even on first-ever visits.
|
|
Livewire.dispatch('proxmox:network-analysis-load');
|
|
} catch (e) {
|
|
console.warn('Failed to dispatch Network Analysis load event:', e);
|
|
// Even if dispatch fails, timeout is already started
|
|
}
|
|
}, 500);
|
|
}
|
|
|
|
// Function to start the Network Analysis timeout if not already started
|
|
function startNetworkAnalysisTimeout() {
|
|
// Get server ID for unique session tracking
|
|
var serverId = {{ $server->id ?? 0 }};
|
|
var refreshKey = 'networkAnalysisRefreshed_' + serverId;
|
|
|
|
// Check if we've already refreshed once in this session for this server
|
|
var hasAlreadyRefreshed = sessionStorage.getItem(refreshKey);
|
|
if (hasAlreadyRefreshed === 'true') {
|
|
return;
|
|
}
|
|
|
|
// Clear any existing timeout
|
|
if (window.networkAnalysisTimeoutId) {
|
|
clearTimeout(window.networkAnalysisTimeoutId);
|
|
}
|
|
// Simple timeout: refresh after 30 seconds unless we can confirm it loaded
|
|
window.networkAnalysisTimeoutId = setTimeout(function() {
|
|
console.warn('=== Network Analysis timeout callback FIRED (30 seconds elapsed) ===');
|
|
|
|
// Get server ID for unique session tracking
|
|
var serverId = {{ $server->id ?? 0 }};
|
|
var refreshKey = 'networkAnalysisRefreshed_' + serverId;
|
|
|
|
// Double-check we haven't already refreshed (in case of race conditions)
|
|
var hasAlreadyRefreshedCheck = sessionStorage.getItem(refreshKey);
|
|
if (hasAlreadyRefreshedCheck === 'true') {
|
|
return;
|
|
}
|
|
|
|
var networkAnalysisCard = document.querySelector('[data-statistics-panel="net-insights"]');
|
|
if (!networkAnalysisCard) {
|
|
console.warn('Network Analysis card not found, refreshing page NOW (one-time) for server ' + serverId);
|
|
sessionStorage.setItem(refreshKey, 'true');
|
|
window.location.reload();
|
|
return;
|
|
}
|
|
|
|
// Check for loading spinner
|
|
var spinner = networkAnalysisCard.querySelector('.animate-spin');
|
|
var isLoadingVisible = spinner && spinner.offsetParent !== null;
|
|
|
|
// Check for "Fetching data" text
|
|
var loadingTextElements = Array.from(networkAnalysisCard.querySelectorAll('p'));
|
|
var hasLoadingText = loadingTextElements.some(function(p) {
|
|
return p && p.textContent && p.textContent.indexOf('Core Router') !== -1;
|
|
});
|
|
// If loading indicator is visible, definitely refresh (but only once)
|
|
if (isLoadingVisible || hasLoadingText) {
|
|
console.warn('Network Analysis timeout - loading indicator still visible, refreshing page NOW (one-time refresh) for server ' + serverId);
|
|
sessionStorage.setItem(refreshKey, 'true');
|
|
window.location.reload();
|
|
return;
|
|
}
|
|
|
|
// Check if there's definitive content that shows it loaded
|
|
var hasTable = networkAnalysisCard.querySelector('table');
|
|
var hasError = networkAnalysisCard.querySelector('[class*="border-warning"]');
|
|
var hasEmptyState = networkAnalysisCard.querySelector('.text-center.py-6');
|
|
var hasTabs = networkAnalysisCard.querySelector('[x-data*="tab"]');
|
|
// Only skip refresh if we have clear evidence it loaded (tabs + content)
|
|
if ((hasTabs && (hasTable || hasError || hasEmptyState)) || hasError) {
|
|
cancelNetworkAnalysisTimeout();
|
|
// Clear the refresh flag since we loaded successfully
|
|
sessionStorage.removeItem(refreshKey);
|
|
return;
|
|
}
|
|
|
|
// Default to refreshing if we can't definitively prove it loaded (but only once)
|
|
console.warn('Network Analysis timeout - cannot confirm data loaded, refreshing page NOW (one-time refresh) for server ' + serverId);
|
|
sessionStorage.setItem(refreshKey, 'true');
|
|
window.location.reload();
|
|
}, 30000); // 30 seconds
|
|
}
|
|
|
|
// Function to cancel the Network Analysis timeout
|
|
function cancelNetworkAnalysisTimeout() {
|
|
if (window.networkAnalysisTimeoutId) {
|
|
clearTimeout(window.networkAnalysisTimeoutId);
|
|
window.networkAnalysisTimeoutId = null;
|
|
}
|
|
// Clear the refresh flag since data loaded successfully (server-specific)
|
|
var serverId = {{ $server->id ?? 0 }};
|
|
var refreshKey = 'networkAnalysisRefreshed_' + serverId;
|
|
sessionStorage.removeItem(refreshKey);
|
|
}
|
|
|
|
// Check if Livewire is already initialized, otherwise wait for it
|
|
if (typeof Livewire !== 'undefined' && Livewire.initialized) {
|
|
loadNetworkAnalysisData();
|
|
} else {
|
|
document.addEventListener('livewire:initialized', loadNetworkAnalysisData, { once: true });
|
|
}
|
|
|
|
// Monitor for Network Analysis completion and cancel timeout when loaded
|
|
function checkNetworkAnalysisLoaded() {
|
|
var networkAnalysisCard = document.querySelector('[data-statistics-panel="net-insights"]');
|
|
if (!networkAnalysisCard) {
|
|
return false;
|
|
}
|
|
|
|
// Check if loading spinner is gone
|
|
var spinner = networkAnalysisCard.querySelector('.animate-spin');
|
|
var hasSpinner = spinner && spinner.offsetParent !== null;
|
|
|
|
// Check if content is present (tables, error messages, or empty state messages)
|
|
var hasContent = networkAnalysisCard.querySelector('[x-data*="pxStatTableKit"]') ||
|
|
networkAnalysisCard.querySelector('.text-center') ||
|
|
networkAnalysisCard.querySelector('[class*="border-warning"]');
|
|
|
|
// If spinner is gone and content exists, it's loaded
|
|
return !hasSpinner && hasContent;
|
|
}
|
|
|
|
// Listen for Livewire updates to clear timeout if Network Analysis loads successfully
|
|
var _livewireUpdateHandler = function() {
|
|
if (window.networkAnalysisTimeoutId && checkNetworkAnalysisLoaded()) {
|
|
cancelNetworkAnalysisTimeout();
|
|
}
|
|
};
|
|
document.addEventListener('livewire:update', _livewireUpdateHandler);
|
|
eventCleanupFunctions.push(function() { document.removeEventListener('livewire:update', _livewireUpdateHandler); });
|
|
|
|
// Also check periodically to catch cases where Livewire update event doesn't fire
|
|
var _networkAnalysisCheckIntervalId = setInterval(function() {
|
|
if (window.networkAnalysisTimeoutId && checkNetworkAnalysisLoaded()) {
|
|
cancelNetworkAnalysisTimeout();
|
|
}
|
|
}, 2000); // Check every 2 seconds
|
|
|
|
// Clear the refresh flag on successful page load (if Network Analysis loaded immediately)
|
|
// This allows the timeout to work again in future sessions (server-specific)
|
|
// Use setTimeout instead of DOMContentLoaded (which won't fire on SPA navigation)
|
|
setTimeout(function() {
|
|
setTimeout(function() {
|
|
if (checkNetworkAnalysisLoaded()) {
|
|
var serverId = {{ $server->id ?? 0 }};
|
|
var refreshKey = 'networkAnalysisRefreshed_' + serverId;
|
|
sessionStorage.removeItem(refreshKey);
|
|
}
|
|
}, 2000);
|
|
|
|
// Handle cross-reference from firewall logs
|
|
// If URL has network_search parameter and #network-analysis-card hash, scroll to it
|
|
var urlParams = new URLSearchParams(window.location.search);
|
|
var networkSearch = urlParams.get('network_search');
|
|
if (networkSearch && window.location.hash === '#px-stat-net-insights') {
|
|
// Wait a bit for page to fully render, then scroll to Network Analysis
|
|
setTimeout(function() {
|
|
var networkCard = document.querySelector('[data-statistics-panel="net-insights"]');
|
|
if (networkCard) {
|
|
networkCard.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
|
|
// Flash highlight the card briefly to draw attention
|
|
networkCard.style.transition = 'box-shadow 0.3s ease';
|
|
networkCard.style.boxShadow = '0 0 0 3px rgba(var(--color-primary), 0.5)';
|
|
setTimeout(function() {
|
|
networkCard.style.boxShadow = '';
|
|
}, 2000);
|
|
}
|
|
}, 500);
|
|
}
|
|
});
|
|
|
|
// Listen for 502 retry events from Core Router API
|
|
// Livewire dispatches events as custom browser events with the event name
|
|
var _502RetryHandler = function(event) {
|
|
var retryCount = (event.detail && event.detail.retryCount) ? event.detail.retryCount : 1;
|
|
var delay = Math.min(2000 * retryCount, 5000); // Exponential backoff, max 5 seconds
|
|
setTimeout(function() {
|
|
try {
|
|
// Dispatch the same event used for initial load so that
|
|
// retries are decoupled from any specific @this instance.
|
|
Livewire.dispatch('proxmox:network-analysis-load');
|
|
} catch (e) {
|
|
console.error('Failed to retry Network Analysis after 502 error:', e);
|
|
}
|
|
}, delay);
|
|
};
|
|
document.addEventListener('network-analysis-502-retry', _502RetryHandler);
|
|
eventCleanupFunctions.push(function() { document.removeEventListener('network-analysis-502-retry', _502RetryHandler); });
|
|
|
|
// Reinitialize on Livewire navigation
|
|
document.addEventListener('livewire:navigated', function() {
|
|
// Add a small delay to ensure DOM is fully updated after navigation
|
|
setTimeout(function() {
|
|
// Reset data tracking flags
|
|
cpuChartHasData = false;
|
|
memoryChartHasData = false;
|
|
networkChartHasData = false;
|
|
diskChartHasData = false;
|
|
chartRetryAttempts = { cpu: 0, memory: 0, network: 0, disk: 0 };
|
|
|
|
setupEventListeners();
|
|
initializeCharts();
|
|
|
|
// Dispatch stats events after charts are initialized
|
|
setTimeout(function() {
|
|
dispatchStatsWithRetry();
|
|
}, 300);
|
|
}, 100);
|
|
});
|
|
|
|
// Refresh stats periodically (every 2 minutes) using individual chart requests
|
|
var _statsRefreshIntervalId = setInterval(function() {
|
|
Livewire.dispatch('proxmox:stats:cpu');
|
|
Livewire.dispatch('proxmox:stats:memory');
|
|
Livewire.dispatch('proxmox:stats:network');
|
|
Livewire.dispatch('proxmox:stats:disk');
|
|
}, 120000);
|
|
|
|
// Clean up intervals, listeners, and charts when component is destroyed
|
|
try {
|
|
$cleanup(function() {
|
|
clearInterval(_networkAnalysisCheckIntervalId);
|
|
clearInterval(_statsRefreshIntervalId);
|
|
if (window.networkAnalysisTimeoutId) {
|
|
clearTimeout(window.networkAnalysisTimeoutId);
|
|
window.networkAnalysisTimeoutId = null;
|
|
}
|
|
eventCleanupFunctions.forEach(function(cleanup) {
|
|
if (typeof cleanup === 'function') cleanup();
|
|
});
|
|
eventCleanupFunctions = [];
|
|
// Destroy chart instances
|
|
[cpuChart, memoryChart, networkChart, diskChart].forEach(function(chart) {
|
|
if (chart) { try { chart.destroy(); } catch(e) {} }
|
|
});
|
|
cpuChart = null; memoryChart = null; networkChart = null; diskChart = null;
|
|
});
|
|
} catch(e) { /* $cleanup may not be available in this Livewire version */ }
|
|
|
|
window.downloadCSV = window.downloadCSV || function(csvContent, filename) {
|
|
var blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
|
var link = document.createElement('a');
|
|
var url = URL.createObjectURL(blob);
|
|
link.setAttribute('href', url);
|
|
link.setAttribute('download', filename);
|
|
link.style.visibility = 'hidden';
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
URL.revokeObjectURL(url);
|
|
};
|
|
|
|
window.pxExportRrdCpuCsv = function() {
|
|
if (!cpuChart) {
|
|
alert('Chart is not ready yet.');
|
|
return;
|
|
}
|
|
try {
|
|
var series = cpuChart.w.config.series;
|
|
if (!series || !series[0] || !series[0].data || series[0].data.length === 0) {
|
|
alert('Nothing to export for this range.');
|
|
return;
|
|
}
|
|
var csv = 'Timestamp,CPU percent\n';
|
|
var dataPoints = series[0].data;
|
|
dataPoints.forEach(function(point) {
|
|
var timestamp = new Date(point.x).toISOString();
|
|
var value = point.y !== undefined ? point.y.toFixed(2) : '0';
|
|
csv += timestamp + ',' + value + '\n';
|
|
});
|
|
window.downloadCSV(csv, 'rrd-cpu-' + new Date().toISOString().split('T')[0] + '.csv');
|
|
} catch (error) {
|
|
console.error('Export error:', error);
|
|
alert('Could not build CSV: ' + error.message);
|
|
}
|
|
};
|
|
|
|
window.pxExportRrdMemoryCsv = function() {
|
|
if (!memoryChart) {
|
|
alert('Chart is not ready yet.');
|
|
return;
|
|
}
|
|
try {
|
|
var series = memoryChart.w.config.series;
|
|
if (!series || !series[0] || !series[0].data || series[0].data.length === 0) {
|
|
alert('Nothing to export for this range.');
|
|
return;
|
|
}
|
|
var csv = 'Timestamp,RAM bytes\n';
|
|
var usageData = series[0].data;
|
|
for (var i = 0; i < usageData.length; i++) {
|
|
var timestamp = new Date(usageData[i].x).toISOString();
|
|
var memUsage = usageData[i].y !== undefined ? usageData[i].y : '0';
|
|
csv += timestamp + ',' + memUsage + '\n';
|
|
}
|
|
window.downloadCSV(csv, 'rrd-memory-' + new Date().toISOString().split('T')[0] + '.csv');
|
|
} catch (error) {
|
|
console.error('Export error:', error);
|
|
alert('Could not build CSV: ' + error.message);
|
|
}
|
|
};
|
|
|
|
window.pxExportRrdNetworkCsv = function() {
|
|
if (!networkChart) {
|
|
alert('Chart is not ready yet.');
|
|
return;
|
|
}
|
|
try {
|
|
var series = networkChart.w.config.series;
|
|
if (!series || !series[0] || !series[0].data || series[0].data.length === 0) {
|
|
alert('Nothing to export for this range.');
|
|
return;
|
|
}
|
|
var csv = 'Timestamp,Ingress bytes,Egress bytes,P95 bytes\n';
|
|
var inData = series[0].data;
|
|
var outData = series[1] ? series[1].data : [];
|
|
var p95Data = series[2] ? series[2].data : [];
|
|
var maxLength = Math.max(inData.length, outData.length, p95Data.length);
|
|
for (var i = 0; i < maxLength; i++) {
|
|
var timestamp = inData[i] ? new Date(inData[i].x).toISOString() :
|
|
(outData[i] ? new Date(outData[i].x).toISOString() : '');
|
|
var netIn = inData[i] && inData[i].y !== undefined ? inData[i].y : '0';
|
|
var netOut = outData[i] && outData[i].y !== undefined ? outData[i].y : '0';
|
|
var p95 = p95Data[i] && p95Data[i].y !== undefined ? p95Data[i].y : '0';
|
|
csv += timestamp + ',' + netIn + ',' + netOut + ',' + p95 + '\n';
|
|
}
|
|
window.downloadCSV(csv, 'rrd-network-' + new Date().toISOString().split('T')[0] + '.csv');
|
|
} catch (error) {
|
|
console.error('Export error:', error);
|
|
alert('Could not build CSV: ' + error.message);
|
|
}
|
|
};
|
|
|
|
window.pxExportRrdDiskCsv = function() {
|
|
if (!diskChart) {
|
|
alert('Chart is not ready yet.');
|
|
return;
|
|
}
|
|
try {
|
|
var series = diskChart.w.config.series;
|
|
if (!series || !series[0] || !series[0].data || series[0].data.length === 0) {
|
|
alert('Nothing to export for this range.');
|
|
return;
|
|
}
|
|
var csv = 'Timestamp,Read bytes,Write bytes\n';
|
|
var readData = series[0].data;
|
|
var writeData = series[1] ? series[1].data : [];
|
|
var maxLength = Math.max(readData.length, writeData.length);
|
|
for (var i = 0; i < maxLength; i++) {
|
|
var timestamp = readData[i] ? new Date(readData[i].x).toISOString() :
|
|
(writeData[i] ? new Date(writeData[i].x).toISOString() : '');
|
|
var diskRead = readData[i] && readData[i].y !== undefined ? readData[i].y : '0';
|
|
var diskWrite = writeData[i] && writeData[i].y !== undefined ? writeData[i].y : '0';
|
|
csv += timestamp + ',' + diskRead + ',' + diskWrite + '\n';
|
|
}
|
|
window.downloadCSV(csv, 'rrd-disk-' + new Date().toISOString().split('T')[0] + '.csv');
|
|
} catch (error) {
|
|
console.error('Export error:', error);
|
|
alert('Could not build CSV: ' + error.message);
|
|
}
|
|
};
|
|
|
|
function sanitizeCellText(value) {
|
|
return (value || '').toString().replace(/\s+/g, ' ').trim();
|
|
}
|
|
|
|
function csvEscape(value) {
|
|
var normalized = sanitizeCellText((value != null) ? value : '');
|
|
if (/[",\n]/.test(normalized)) {
|
|
return '"' + normalized.replace(/"/g, '""') + '"';
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
function dispatchNoDataEvent(eventName, message) {
|
|
window.dispatchEvent(new CustomEvent(eventName, { detail: { message: message } }));
|
|
}
|
|
|
|
function getStatisticsExportData() {
|
|
return Array.isArray(window.statisticsHistoryExportData)
|
|
? window.statisticsHistoryExportData
|
|
: [];
|
|
}
|
|
|
|
function getTaskExportData() {
|
|
return Array.isArray(window.taskHistoryExportData)
|
|
? window.taskHistoryExportData
|
|
: [];
|
|
}
|
|
|
|
window.pxExportSamplesArchiveCsv = function() {
|
|
var data = getStatisticsExportData();
|
|
if (!data.length) {
|
|
dispatchNoDataEvent('statistics-export:no-data', 'There are no archived samples to download yet.');
|
|
return;
|
|
}
|
|
var csv = 'Captured at,Power state,CPU %,RAM %,Disk %,Net in,Net out,Disk IO,Uptime\n';
|
|
data.forEach(function(row) {
|
|
csv += [
|
|
csvEscape(row.date_time),
|
|
csvEscape(row.status),
|
|
csvEscape(row.cpu),
|
|
csvEscape(row.memory),
|
|
csvEscape(row.disk),
|
|
csvEscape(row.network_in),
|
|
csvEscape(row.network_out),
|
|
csvEscape(row.disk_io),
|
|
csvEscape(row.uptime),
|
|
].join(',') + '\n';
|
|
});
|
|
window.downloadCSV(csv, 'samples-archive-' + new Date().toISOString().split('T')[0] + '.csv');
|
|
};
|
|
|
|
window.pxExportJobLogCsv = function() {
|
|
var data = getTaskExportData();
|
|
if (!data.length) {
|
|
dispatchNoDataEvent('task-export:no-data', 'Job log is empty for export.');
|
|
return;
|
|
}
|
|
var csv = 'Kind,State,Started,Finished,Elapsed\n';
|
|
data.forEach(function(row) {
|
|
csv += [
|
|
csvEscape(row.type),
|
|
csvEscape(row.status),
|
|
csvEscape(row.start_time),
|
|
csvEscape(row.end_time),
|
|
csvEscape(row.duration),
|
|
].join(',') + '\n';
|
|
});
|
|
window.downloadCSV(csv, 'job-log-' + new Date().toISOString().split('T')[0] + '.csv');
|
|
};
|
|
|
|
function getLifecycleEventsExportData() {
|
|
return Array.isArray(window.lifecycleEventsExportData)
|
|
? window.lifecycleEventsExportData
|
|
: [];
|
|
}
|
|
|
|
window.pxExportLifecycleCsv = function() {
|
|
var data = getLifecycleEventsExportData();
|
|
if (!data.length) {
|
|
dispatchNoDataEvent('lifecycle-export:no-data', 'No lifecycle rows to export.');
|
|
return;
|
|
}
|
|
var csv = 'Kind,State,Started,Finished,Elapsed\n';
|
|
data.forEach(function(row) {
|
|
csv += [
|
|
csvEscape(row.type),
|
|
csvEscape(row.status),
|
|
csvEscape(row.start_time),
|
|
csvEscape(row.end_time),
|
|
csvEscape(row.duration),
|
|
].join(',') + '\n';
|
|
});
|
|
window.downloadCSV(csv, 'lifecycle-audit-' + new Date().toISOString().split('T')[0] + '.csv');
|
|
};
|
|
|
|
function getHistoricalComparisonExportData() {
|
|
var template = document.querySelector('[data-statistics-json="period-compare-export"]');
|
|
if (!template) {
|
|
console.warn('Period compare export node missing');
|
|
return [];
|
|
}
|
|
|
|
if (!template.textContent || !template.textContent.trim()) {
|
|
console.warn('Period compare export payload empty');
|
|
return [];
|
|
}
|
|
|
|
try {
|
|
var data = JSON.parse(template.textContent.trim());
|
|
if (!Array.isArray(data)) {
|
|
console.warn('Historical comparison export data is not an array:', data);
|
|
return [];
|
|
}
|
|
return data;
|
|
} catch (e) {
|
|
console.error('Failed to parse historical comparison export data:', e, 'Content:', template.textContent);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
window.pxExportPeriodCompareCsv = function() {
|
|
var data = getHistoricalComparisonExportData();
|
|
if (!data || !Array.isArray(data) || data.length === 0) {
|
|
dispatchNoDataEvent('historical-comparison-export:no-data', 'Run a comparison above before exporting.');
|
|
return;
|
|
}
|
|
var csv = 'Signal,Baseline,Current,Delta,% shift\n';
|
|
data.forEach(function(row) {
|
|
if (row && row.metric) {
|
|
csv += [
|
|
csvEscape(row.metric),
|
|
csvEscape(row.historical_value || ''),
|
|
csvEscape(row.current_value || ''),
|
|
csvEscape(row.difference || ''),
|
|
csvEscape(row.percent_change || ''),
|
|
].join(',') + '\n';
|
|
}
|
|
});
|
|
window.downloadCSV(csv, 'period-compare-' + new Date().toISOString().split('T')[0] + '.csv');
|
|
};
|
|
</script>
|
|
@endscript
|