diff --git a/BCC-Examples/container-monitor/tui.py b/BCC-Examples/container-monitor/tui.py index f006137..d76c924 100644 --- a/BCC-Examples/container-monitor/tui.py +++ b/BCC-Examples/container-monitor/tui.py @@ -2,8 +2,287 @@ import time import curses +import threading from typing import Optional, List from data_collection import ContainerDataCollector +from web_dashboard import WebDashboard + + +def _safe_addstr(stdscr, y: int, x: int, text: str, *args): + """Safely add string to screen with bounds checking.""" + try: + height, width = stdscr.getmaxyx() + if 0 <= y < height and 0 <= x < width: + # Truncate text to fit + max_len = width - x - 1 + if max_len > 0: + stdscr.addstr(y, x, text[:max_len], *args) + except curses.error: + pass + + +def _draw_fancy_header(stdscr, title: str, subtitle: str): + """Draw a fancy header with title and subtitle.""" + height, width = stdscr.getmaxyx() + + # Top border + _safe_addstr(stdscr, 0, 0, "═" * width, curses.color_pair(6) | curses.A_BOLD) + + # Title + _safe_addstr( + stdscr, + 0, + max(0, (width - len(title)) // 2), + f" {title} ", + curses.color_pair(6) | curses.A_BOLD, + ) + + # Subtitle + _safe_addstr( + stdscr, + 1, + max(0, (width - len(subtitle)) // 2), + subtitle, + curses.color_pair(1), + ) + + # Bottom border + _safe_addstr(stdscr, 2, 0, "═" * width, curses.color_pair(6)) + + +def _draw_metric_box( + stdscr, + y: int, + x: int, + width: int, + label: str, + value: str, + detail: str, + color_pair: int, +): + """Draw a fancy box for displaying a metric.""" + height, _ = stdscr.getmaxyx() + + if y + 4 >= height: + return + + # Top border + _safe_addstr( + stdscr, y, x, "┌" + "─" * (width - 2) + "┐", color_pair | curses.A_BOLD + ) + + # Label + _safe_addstr(stdscr, y + 1, x, "│", color_pair | curses.A_BOLD) + _safe_addstr(stdscr, y + 1, x + 2, label, color_pair | curses.A_BOLD) + _safe_addstr(stdscr, y + 1, x + width - 1, "│", color_pair | curses.A_BOLD) + + # Value + _safe_addstr(stdscr, y + 2, x, "│", color_pair | curses.A_BOLD) + _safe_addstr(stdscr, y + 2, x + 4, value, curses.color_pair(2) | curses.A_BOLD) + _safe_addstr( + stdscr, + y + 2, + min(x + width - len(detail) - 3, x + width - 2), + detail, + color_pair | curses.A_BOLD, + ) + _safe_addstr(stdscr, y + 2, x + width - 1, "│", color_pair | curses.A_BOLD) + + # Bottom border + _safe_addstr( + stdscr, y + 3, x, "└" + "─" * (width - 2) + "┘", color_pair | curses.A_BOLD + ) + + +def _draw_section_header(stdscr, y: int, title: str, color_pair: int): + """Draw a section header.""" + height, width = stdscr.getmaxyx() + + if y >= height: + return + + _safe_addstr(stdscr, y, 2, title, curses.color_pair(color_pair) | curses.A_BOLD) + _safe_addstr( + stdscr, + y, + len(title) + 3, + "─" * (width - len(title) - 5), + curses.color_pair(color_pair) | curses.A_BOLD, + ) + + +def _calculate_rates(history: List) -> dict: + """Calculate per-second rates from history.""" + if len(history) < 2: + return { + "syscalls_per_sec": 0.0, + "rx_bytes_per_sec": 0.0, + "tx_bytes_per_sec": 0.0, + "rx_pkts_per_sec": 0.0, + "tx_pkts_per_sec": 0.0, + "read_bytes_per_sec": 0.0, + "write_bytes_per_sec": 0.0, + "read_ops_per_sec": 0.0, + "write_ops_per_sec": 0.0, + } + + # Calculate delta between last two samples + recent = history[-1] + previous = history[-2] + time_delta = recent.timestamp - previous.timestamp + + if time_delta <= 0: + time_delta = 1.0 + + return { + "syscalls_per_sec": (recent.syscall_count - previous.syscall_count) + / time_delta, + "rx_bytes_per_sec": (recent.rx_bytes - previous.rx_bytes) / time_delta, + "tx_bytes_per_sec": (recent.tx_bytes - previous.tx_bytes) / time_delta, + "rx_pkts_per_sec": (recent.rx_packets - previous.rx_packets) / time_delta, + "tx_pkts_per_sec": (recent.tx_packets - previous.tx_packets) / time_delta, + "read_bytes_per_sec": (recent.read_bytes - previous.read_bytes) / time_delta, + "write_bytes_per_sec": (recent.write_bytes - previous.write_bytes) / time_delta, + "read_ops_per_sec": (recent.read_ops - previous.read_ops) / time_delta, + "write_ops_per_sec": (recent.write_ops - previous.write_ops) / time_delta, + } + + +def _format_bytes(bytes_val: float) -> str: + """Format bytes into human-readable string.""" + if bytes_val < 0: + bytes_val = 0 + for unit in ["B", "KB", "MB", "GB", "TB"]: + if bytes_val < 1024.0: + return f"{bytes_val:.1f}{unit}" + bytes_val /= 1024.0 + return f"{bytes_val:.1f}PB" + + +def _draw_bar_graph_enhanced( + stdscr, + y: int, + x: int, + width: int, + height: int, + data: List[float], + color_pair: int, +): + """Draw an enhanced bar graph with axis and scale.""" + screen_height, screen_width = stdscr.getmaxyx() + + if not data or width < 2 or y + height >= screen_height: + return + + # Calculate statistics + max_val = max(data) if max(data) > 0 else 1 + min_val = min(data) + avg_val = sum(data) / len(data) + + # Take last 'width - 12' data points (leave room for Y-axis) + graph_width = max(1, width - 12) + recent_data = data[-graph_width:] if len(data) > graph_width else data + + # Draw Y-axis labels (with bounds checking) + if y < screen_height: + _safe_addstr( + stdscr, y, x, f"│{_format_bytes(max_val):>9}", curses.color_pair(7) + ) + if y + height // 2 < screen_height: + _safe_addstr( + stdscr, + y + height // 2, + x, + f"│{_format_bytes(avg_val):>9}", + curses.color_pair(7), + ) + if y + height - 1 < screen_height: + _safe_addstr( + stdscr, + y + height - 1, + x, + f"│{_format_bytes(min_val):>9}", + curses.color_pair(7), + ) + + # Draw bars + for row in range(height): + if y + row >= screen_height: + break + + threshold = (height - row) / height + bar_line = "" + + for val in recent_data: + normalized = val / max_val if max_val > 0 else 0 + if normalized >= threshold: + bar_line += "█" + elif normalized >= threshold - 0.15: + bar_line += "▓" + elif normalized >= threshold - 0.35: + bar_line += "▒" + elif normalized >= threshold - 0.5: + bar_line += "░" + else: + bar_line += " " + + _safe_addstr(stdscr, y + row, x + 11, bar_line, color_pair) + + # Draw X-axis + if y + height < screen_height: + _safe_addstr( + stdscr, + y + height, + x + 10, + "├" + "─" * len(recent_data), + curses.color_pair(7), + ) + _safe_addstr( + stdscr, + y + height, + x + 10 + len(recent_data), + "→ time", + curses.color_pair(7), + ) + + +def _draw_labeled_graph( + stdscr, + y: int, + x: int, + width: int, + height: int, + label: str, + rate: str, + detail: str, + data: List[float], + color_pair: int, + description: str, +): + """Draw a graph with labels and legend.""" + screen_height, screen_width = stdscr.getmaxyx() + + if y >= screen_height or y + height + 2 >= screen_height: + return + + # Header with metrics + _safe_addstr(stdscr, y, x, label, curses.color_pair(1) | curses.A_BOLD) + _safe_addstr(stdscr, y, x + len(label) + 2, rate, curses.color_pair(2)) + _safe_addstr( + stdscr, y, x + len(label) + len(rate) + 4, detail, curses.color_pair(7) + ) + + # Draw the graph + if len(data) > 1: + _draw_bar_graph_enhanced(stdscr, y + 1, x, width, height, data, color_pair) + else: + _safe_addstr(stdscr, y + 2, x + 2, "Collecting data...", curses.color_pair(7)) + + # Graph legend + if y + height + 1 < screen_height: + _safe_addstr( + stdscr, y + height + 1, x, f"└─ {description}", curses.color_pair(7) + ) class ContainerMonitorTUI: @@ -15,6 +294,8 @@ def __init__(self, collector: ContainerDataCollector): self.current_screen = "selection" # "selection" or "monitoring" self.selected_index = 0 self.scroll_offset = 0 + self.web_dashboard = None + self.web_thread = None def run(self): """Run the TUI application.""" @@ -42,6 +323,22 @@ def _main_loop(self, stdscr): stdscr.clear() try: + height, width = stdscr.getmaxyx() + + # Check minimum terminal size + if height < 25 or width < 80: + msg = "Terminal too small! Minimum: 80x25" + stdscr.attron(curses.color_pair(4) | curses.A_BOLD) + stdscr.addstr( + height // 2, max(0, (width - len(msg)) // 2), msg[: width - 1] + ) + stdscr.attroff(curses.color_pair(4) | curses.A_BOLD) + stdscr.refresh() + key = stdscr.getch() + if key == ord("q") or key == ord("Q"): + break + continue + if self.current_screen == "selection": self._draw_selection_screen(stdscr) elif self.current_screen == "monitoring": @@ -52,40 +349,53 @@ def _main_loop(self, stdscr): # Handle input key = stdscr.getch() if key != -1: - if not self._handle_input(key): + if not self._handle_input(key, stdscr): break # Exit requested except KeyboardInterrupt: break + except curses.error: + # Curses error - likely terminal too small, just continue + pass except Exception as e: - # Show error - stdscr.addstr(0, 0, f"Error: {str(e)}") + # Show error briefly + height, width = stdscr.getmaxyx() + error_msg = f"Error: {str(e)[: width - 10]}" + stdscr.addstr(0, 0, error_msg[: width - 1]) stdscr.refresh() - time.sleep(2) + time.sleep(1) def _draw_selection_screen(self, stdscr): """Draw the cgroup selection screen.""" height, width = stdscr.getmaxyx() # Draw fancy header box - self._draw_fancy_header( - stdscr, "🐳 CONTAINER MONITOR", "Select a Cgroup to Monitor" - ) + _draw_fancy_header(stdscr, "🐳 CONTAINER MONITOR", "Select a Cgroup to Monitor") # Instructions - instructions = "↑↓: Navigate | ENTER: Select | q: Quit | r: Refresh" - stdscr.attron(curses.color_pair(3)) - stdscr.addstr(3, (width - len(instructions)) // 2, instructions) - stdscr.attroff(curses.color_pair(3)) + instructions = ( + "↑↓: Navigate | ENTER: Select | w: Web Mode | q: Quit | r: Refresh" + ) + _safe_addstr( + stdscr, + 3, + max(0, (width - len(instructions)) // 2), + instructions, + curses.color_pair(3), + ) # Get cgroups cgroups = self.collector.get_all_cgroups() if not cgroups: - msg = "No cgroups found. Waiting for activity..." - stdscr.attron(curses.color_pair(4)) - stdscr.addstr(height // 2, (width - len(msg)) // 2, msg) - stdscr.attroff(curses.color_pair(4)) + msg = "No cgroups found. Waiting for activity..." + _safe_addstr( + stdscr, + height // 2, + max(0, (width - len(msg)) // 2), + msg, + curses.color_pair(4), + ) return # Sort cgroups by name @@ -98,57 +408,82 @@ def _draw_selection_screen(self, stdscr): self.selected_index = 0 # Calculate visible range - list_height = height - 8 + list_height = max(1, height - 8) if self.selected_index < self.scroll_offset: self.scroll_offset = self.selected_index elif self.selected_index >= self.scroll_offset + list_height: self.scroll_offset = self.selected_index - list_height + 1 + # Calculate max name length and ID width for alignment + max_name_len = min(50, max(len(cg.name) for cg in cgroups)) + max_id_len = max(len(str(cg.id)) for cg in cgroups) + # Draw cgroup list with fancy borders start_y = 5 - stdscr.attron(curses.color_pair(1)) - stdscr.addstr(start_y, 2, "╔" + "═" * (width - 6) + "╗") - stdscr.attroff(curses.color_pair(1)) + _safe_addstr( + stdscr, start_y, 2, "╔" + "═" * (width - 6) + "╗", curses.color_pair(1) + ) + + # Header row + header = f" {'CGROUP NAME':<{max_name_len}} │ {'ID':>{max_id_len}} " + _safe_addstr(stdscr, start_y + 1, 2, "║", curses.color_pair(1)) + _safe_addstr( + stdscr, start_y + 1, 3, header, curses.color_pair(1) | curses.A_BOLD + ) + _safe_addstr(stdscr, start_y + 1, width - 3, "║", curses.color_pair(1)) + + # Separator + _safe_addstr( + stdscr, start_y + 2, 2, "╟" + "─" * (width - 6) + "╢", curses.color_pair(1) + ) for i in range(list_height): idx = self.scroll_offset + i - y = start_y + 1 + i + y = start_y + 3 + i - stdscr.attron(curses.color_pair(1)) - stdscr.addstr(y, 2, "║") - stdscr.addstr(y, width - 3, "║") - stdscr.attroff(curses.color_pair(1)) + if y >= height - 2: + break + + _safe_addstr(stdscr, y, 2, "║", curses.color_pair(1)) + _safe_addstr(stdscr, y, width - 3, "║", curses.color_pair(1)) if idx >= len(cgroups): continue cgroup = cgroups[idx] + # Truncate name if too long + display_name = ( + cgroup.name + if len(cgroup.name) <= max_name_len + else cgroup.name[: max_name_len - 3] + "..." + ) + if idx == self.selected_index: - # Highlight selected with better styling - stdscr.attron(curses.color_pair(8) | curses.A_BOLD) - line = f" ► {cgroup.name:<35} │ ID: {cgroup.id} " - stdscr.addstr(y, 3, line[: width - 6]) - stdscr.attroff(curses.color_pair(8) | curses.A_BOLD) + # Highlight selected with proper alignment + line = f" ► {display_name:<{max_name_len}} │ {cgroup.id:>{max_id_len}} " + _safe_addstr(stdscr, y, 3, line, curses.color_pair(8) | curses.A_BOLD) else: - stdscr.attron(curses.color_pair(7)) - line = f" {cgroup.name:<35} │ ID: {cgroup.id} " - stdscr.addstr(y, 3, line[: width - 6]) - stdscr.attroff(curses.color_pair(7)) + line = f" {display_name:<{max_name_len}} │ {cgroup.id:>{max_id_len}} " + _safe_addstr(stdscr, y, 3, line, curses.color_pair(7)) # Bottom border - bottom_y = start_y + 1 + list_height - stdscr.attron(curses.color_pair(1)) - stdscr.addstr(bottom_y, 2, "╚" + "═" * (width - 6) + "╝") - stdscr.attroff(curses.color_pair(1)) + bottom_y = min(start_y + 3 + list_height, height - 3) + _safe_addstr( + stdscr, bottom_y, 2, "╚" + "═" * (width - 6) + "╝", curses.color_pair(1) + ) - # Footer with count and scroll indicator + # Footer footer = f"Total: {len(cgroups)} cgroups" if len(cgroups) > list_height: footer += f" │ Showing {self.scroll_offset + 1}-{min(self.scroll_offset + list_height, len(cgroups))}" - stdscr.attron(curses.color_pair(1)) - stdscr.addstr(height - 2, (width - len(footer)) // 2, footer) - stdscr.attroff(curses.color_pair(1)) + _safe_addstr( + stdscr, + height - 2, + max(0, (width - len(footer)) // 2), + footer, + curses.color_pair(1), + ) def _draw_monitoring_screen(self, stdscr): """Draw the monitoring screen for selected cgroup.""" @@ -162,346 +497,236 @@ def _draw_monitoring_screen(self, stdscr): history = self.collector.get_history(self.selected_cgroup) # Draw fancy header - self._draw_fancy_header( - stdscr, f"📊 {stats.cgroup_name}", "Live Performance Metrics" + _draw_fancy_header( + stdscr, f"📊 {stats.cgroup_name[:40]}", "Live Performance Metrics" ) # Instructions - instructions = "ESC/b: Back to List | q: Quit" - stdscr.attron(curses.color_pair(3)) - stdscr.addstr(3, (width - len(instructions)) // 2, instructions) - stdscr.attroff(curses.color_pair(3)) + instructions = "ESC/b: Back to List | w: Web Mode | q: Quit" + _safe_addstr( + stdscr, + 3, + max(0, (width - len(instructions)) // 2), + instructions, + curses.color_pair(3), + ) # Calculate metrics for rate display - rates = self._calculate_rates(history) + rates = _calculate_rates(history) y = 5 # Syscall count in a fancy box - self._draw_metric_box( - stdscr, - y, - 2, - width - 4, - "⚡ SYSTEM CALLS", - f"{stats.syscall_count:,}", - f"Rate: {rates['syscalls_per_sec']:.1f}/sec", - curses.color_pair(5), - ) - + if y + 4 < height: + _draw_metric_box( + stdscr, + y, + 2, + min(width - 4, 80), + "⚡ SYSTEM CALLS", + f"{stats.syscall_count:,}", + f"Rate: {rates['syscalls_per_sec']:.1f}/sec", + curses.color_pair(5), + ) y += 4 # Network I/O Section - self._draw_section_header(stdscr, y, "🌐 NETWORK I/O", 1) - y += 1 - - # RX graph with legend - rx_label = f"RX: {self._format_bytes(stats.rx_bytes)}" - rx_rate = f"{self._format_bytes(rates['rx_bytes_per_sec'])}/s" - rx_pkts = f"{stats.rx_packets:,} pkts ({rates['rx_pkts_per_sec']:.1f}/s)" + if y + 8 < height: + _draw_section_header(stdscr, y, "🌐 NETWORK I/O", 1) + y += 1 + + # RX graph + rx_label = f"RX: {_format_bytes(stats.rx_bytes)}" + rx_rate = f"{_format_bytes(rates['rx_bytes_per_sec'])}/s" + rx_pkts = f"{stats.rx_packets:,} pkts ({rates['rx_pkts_per_sec']:.1f}/s)" + + _draw_labeled_graph( + stdscr, + y, + 2, + width - 4, + 4, + rx_label, + rx_rate, + rx_pkts, + [s.rx_bytes for s in history], + curses.color_pair(2), + "Received Traffic (last 100 samples)", + ) + y += 6 + + # TX graph + if y + 8 < height: + tx_label = f"TX: {_format_bytes(stats.tx_bytes)}" + tx_rate = f"{_format_bytes(rates['tx_bytes_per_sec'])}/s" + tx_pkts = f"{stats.tx_packets:,} pkts ({rates['tx_pkts_per_sec']:.1f}/s)" + + _draw_labeled_graph( + stdscr, + y, + 2, + width - 4, + 4, + tx_label, + tx_rate, + tx_pkts, + [s.tx_bytes for s in history], + curses.color_pair(3), + "Transmitted Traffic (last 100 samples)", + ) + y += 6 - self._draw_labeled_graph( - stdscr, - y, - 2, - width - 4, - 4, - rx_label, - rx_rate, - rx_pkts, - [s.rx_bytes for s in history], - curses.color_pair(2), - "Received Traffic (last 100 samples)", - ) + # File I/O Section + if y + 8 < height: + _draw_section_header(stdscr, y, "💾 FILE I/O", 1) + y += 1 + + # Read graph + read_label = f"READ: {_format_bytes(stats.read_bytes)}" + read_rate = f"{_format_bytes(rates['read_bytes_per_sec'])}/s" + read_ops = f"{stats.read_ops:,} ops ({rates['read_ops_per_sec']:.1f}/s)" + + _draw_labeled_graph( + stdscr, + y, + 2, + width - 4, + 4, + read_label, + read_rate, + read_ops, + [s.read_bytes for s in history], + curses.color_pair(4), + "Read Operations (last 100 samples)", + ) + y += 6 + + # Write graph + if y + 8 < height: + write_label = f"WRITE: {_format_bytes(stats.write_bytes)}" + write_rate = f"{_format_bytes(rates['write_bytes_per_sec'])}/s" + write_ops = f"{stats.write_ops:,} ops ({rates['write_ops_per_sec']:.1f}/s)" + + _draw_labeled_graph( + stdscr, + y, + 2, + width - 4, + 4, + write_label, + write_rate, + write_ops, + [s.write_bytes for s in history], + curses.color_pair(5), + "Write Operations (last 100 samples)", + ) - y += 6 + def _launch_web_mode(self, stdscr): + """Launch web dashboard mode.""" + height, width = stdscr.getmaxyx() - # TX graph with legend - tx_label = f"TX: {self._format_bytes(stats.tx_bytes)}" - tx_rate = f"{self._format_bytes(rates['tx_bytes_per_sec'])}/s" - tx_pkts = f"{stats.tx_packets:,} pkts ({rates['tx_pkts_per_sec']:.1f}/s)" + # Show transition message + stdscr.clear() - self._draw_labeled_graph( + msg1 = "🌐 LAUNCHING WEB DASHBOARD" + _safe_addstr( stdscr, - y, - 2, - width - 4, - 4, - tx_label, - tx_rate, - tx_pkts, - [s.tx_bytes for s in history], - curses.color_pair(3), - "Transmitted Traffic (last 100 samples)", + height // 2 - 2, + max(0, (width - len(msg1)) // 2), + msg1, + curses.color_pair(6) | curses.A_BOLD, ) - y += 6 - - # File I/O Section - self._draw_section_header(stdscr, y, "💾 FILE I/O", 1) - y += 1 - - # Read graph with legend - read_label = f"READ: {self._format_bytes(stats.read_bytes)}" - read_rate = f"{self._format_bytes(rates['read_bytes_per_sec'])}/s" - read_ops = f"{stats.read_ops:,} ops ({rates['read_ops_per_sec']:.1f}/s)" - - self._draw_labeled_graph( + msg2 = "Server starting at http://localhost:8050" + _safe_addstr( stdscr, - y, - 2, - width - 4, - 4, - read_label, - read_rate, - read_ops, - [s.read_bytes for s in history], - curses.color_pair(4), - "Read Operations (last 100 samples)", + height // 2, + max(0, (width - len(msg2)) // 2), + msg2, + curses.color_pair(2), ) - y += 6 - - # Write graph with legend - write_label = f"WRITE: {self._format_bytes(stats.write_bytes)}" - write_rate = f"{self._format_bytes(rates['write_bytes_per_sec'])}/s" - write_ops = f"{stats.write_ops:,} ops ({rates['write_ops_per_sec']:.1f}/s)" - - self._draw_labeled_graph( + msg3 = "Press 'q' to stop web server and return to TUI" + _safe_addstr( stdscr, - y, - 2, - width - 4, - 4, - write_label, - write_rate, - write_ops, - [s.write_bytes for s in history], - curses.color_pair(5), - "Write Operations (last 100 samples)", + height // 2 + 2, + max(0, (width - len(msg3)) // 2), + msg3, + curses.color_pair(3), ) - def _draw_fancy_header(self, stdscr, title: str, subtitle: str): - """Draw a fancy header with title and subtitle.""" - height, width = stdscr.getmaxyx() - - # Top border - stdscr.attron(curses.color_pair(6) | curses.A_BOLD) - stdscr.addstr(0, 0, "═" * width) - - # Title - stdscr.addstr(0, (width - len(title)) // 2, f" {title} ") - stdscr.attroff(curses.color_pair(6) | curses.A_BOLD) - - # Subtitle - stdscr.attron(curses.color_pair(1)) - stdscr.addstr(1, (width - len(subtitle)) // 2, subtitle) - stdscr.attroff(curses.color_pair(1)) - - # Bottom border - stdscr.attron(curses.color_pair(6)) - stdscr.addstr(2, 0, "═" * width) - stdscr.attroff(curses.color_pair(6)) - - def _draw_metric_box( - self, - stdscr, - y: int, - x: int, - width: int, - label: str, - value: str, - detail: str, - color_pair: int, - ): - """Draw a fancy box for displaying a metric.""" - # Top border - stdscr.attron(color_pair | curses.A_BOLD) - stdscr.addstr(y, x, "┌" + "─" * (width - 2) + "┐") - - # Label - stdscr.addstr(y + 1, x, "│") - stdscr.addstr(y + 1, x + 2, label) - stdscr.addstr(y + 1, x + width - 1, "│") - - # Value (large) - stdscr.addstr(y + 2, x, "│") - stdscr.attroff(color_pair | curses.A_BOLD) - stdscr.attron(curses.color_pair(2) | curses.A_BOLD) - stdscr.addstr(y + 2, x + 4, value) - stdscr.attroff(curses.color_pair(2) | curses.A_BOLD) - stdscr.attron(color_pair | curses.A_BOLD) - stdscr.addstr(y + 2, x + width - 1, "│") - - # Detail - stdscr.addstr(y + 2, x + width - len(detail) - 3, detail) - - # Bottom border - stdscr.addstr(y + 3, x, "└" + "─" * (width - 2) + "┘") - stdscr.attroff(color_pair | curses.A_BOLD) + stdscr.refresh() + time.sleep(1) - def _draw_section_header(self, stdscr, y: int, title: str, color_pair: int): - """Draw a section header.""" - height, width = stdscr.getmaxyx() - stdscr.attron(curses.color_pair(color_pair) | curses.A_BOLD) - stdscr.addstr(y, 2, title) - stdscr.addstr(y, len(title) + 3, "─" * (width - len(title) - 5)) - stdscr.attroff(curses.color_pair(color_pair) | curses.A_BOLD) + try: + # Create and start web dashboard + self.web_dashboard = WebDashboard( + self.collector, selected_cgroup=self.selected_cgroup + ) - def _draw_labeled_graph( - self, - stdscr, - y: int, - x: int, - width: int, - height: int, - label: str, - rate: str, - detail: str, - data: List[float], - color_pair: int, - description: str, - ): - """Draw a graph with labels and legend.""" - # Header with metrics - stdscr.attron(curses.color_pair(1) | curses.A_BOLD) - stdscr.addstr(y, x, label) - stdscr.attroff(curses.color_pair(1) | curses.A_BOLD) - - stdscr.attron(curses.color_pair(2)) - stdscr.addstr(y, x + len(label) + 2, rate) - stdscr.attroff(curses.color_pair(2)) - - stdscr.attron(curses.color_pair(7)) - stdscr.addstr(y, x + len(label) + len(rate) + 4, detail) - stdscr.attroff(curses.color_pair(7)) - - # Draw the graph - if len(data) > 1: - self._draw_bar_graph_enhanced( - stdscr, y + 1, x, width, height, data, color_pair + # Start in background thread + self.web_thread = threading.Thread( + target=self.web_dashboard.run, daemon=True ) - else: - stdscr.attron(curses.color_pair(7)) - stdscr.addstr(y + 2, x + 2, "Collecting data...") - stdscr.attroff(curses.color_pair(7)) - - # Graph legend at bottom - stdscr.attron(curses.color_pair(7)) - stdscr.addstr(y + height + 1, x, f"└─ {description}") - stdscr.attroff(curses.color_pair(7)) - - def _draw_bar_graph_enhanced( - self, - stdscr, - y: int, - x: int, - width: int, - height: int, - data: List[float], - color_pair: int, - ): - """Draw an enhanced bar graph with axis and scale.""" - if not data or width < 2: - return + self.web_thread.start() + + time.sleep(2) # Give server time to start + + # Wait for user to press 'q' to return + msg4 = "Web dashboard running at http://localhost:8050" + msg5 = "Press 'q' to return to TUI" + _safe_addstr( + stdscr, + height // 2 + 4, + max(0, (width - len(msg4)) // 2), + msg4, + curses.color_pair(1) | curses.A_BOLD, + ) + _safe_addstr( + stdscr, + height // 2 + 5, + max(0, (width - len(msg5)) // 2), + msg5, + curses.color_pair(3) | curses.A_BOLD, + ) + stdscr.refresh() - # Calculate statistics - max_val = max(data) if max(data) > 0 else 1 - min_val = min(data) - avg_val = sum(data) / len(data) - - # Take last 'width - 10' data points (leave room for Y-axis) - graph_width = width - 12 - recent_data = data[-graph_width:] if len(data) > graph_width else data - - # Draw Y-axis labels - stdscr.attron(curses.color_pair(7)) - stdscr.addstr(y, x, f"│{self._format_bytes(max_val):>9}") - stdscr.addstr(y + height // 2, x, f"│{self._format_bytes(avg_val):>9}") - stdscr.addstr(y + height - 1, x, f"│{self._format_bytes(min_val):>9}") - stdscr.attroff(curses.color_pair(7)) - - # Draw bars - for row in range(height): - threshold = (height - row) / height - bar_line = "" - - for val in recent_data: - normalized = val / max_val if max_val > 0 else 0 - if normalized >= threshold: - bar_line += "█" - elif normalized >= threshold - 0.15: - bar_line += "▓" - elif normalized >= threshold - 0.35: - bar_line += "▒" - elif normalized >= threshold - 0.5: - bar_line += "░" - else: - bar_line += " " - - stdscr.attron(color_pair) - stdscr.addstr(y + row, x + 11, bar_line) - stdscr.attroff(color_pair) - - # Draw X-axis - stdscr.attron(curses.color_pair(7)) - stdscr.addstr(y + height, x + 10, "├" + "─" * len(recent_data)) - stdscr.addstr(y + height, x + 10 + len(recent_data), "→ time") - stdscr.attroff(curses.color_pair(7)) - - def _calculate_rates(self, history: List) -> dict: - """Calculate per-second rates from history.""" - if len(history) < 2: - return { - "syscalls_per_sec": 0.0, - "rx_bytes_per_sec": 0.0, - "tx_bytes_per_sec": 0.0, - "rx_pkts_per_sec": 0.0, - "tx_pkts_per_sec": 0.0, - "read_bytes_per_sec": 0.0, - "write_bytes_per_sec": 0.0, - "read_ops_per_sec": 0.0, - "write_ops_per_sec": 0.0, - } - - # Calculate delta between last two samples - recent = history[-1] - previous = history[-2] - time_delta = recent.timestamp - previous.timestamp - - if time_delta <= 0: - time_delta = 1.0 + stdscr.nodelay(False) # Blocking mode + while True: + key = stdscr.getch() + if key == ord("q") or key == ord("Q"): + break + + # Stop web server + if self.web_dashboard: + self.web_dashboard.stop() + + except Exception as e: + error_msg = f"Error starting web dashboard: {str(e)}" + _safe_addstr( + stdscr, + height // 2 + 4, + max(0, (width - len(error_msg)) // 2), + error_msg, + curses.color_pair(4), + ) + stdscr.refresh() + time.sleep(3) - return { - "syscalls_per_sec": (recent.syscall_count - previous.syscall_count) - / time_delta, - "rx_bytes_per_sec": (recent.rx_bytes - previous.rx_bytes) / time_delta, - "tx_bytes_per_sec": (recent.tx_bytes - previous.tx_bytes) / time_delta, - "rx_pkts_per_sec": (recent.rx_packets - previous.rx_packets) / time_delta, - "tx_pkts_per_sec": (recent.tx_packets - previous.tx_packets) / time_delta, - "read_bytes_per_sec": (recent.read_bytes - previous.read_bytes) - / time_delta, - "write_bytes_per_sec": (recent.write_bytes - previous.write_bytes) - / time_delta, - "read_ops_per_sec": (recent.read_ops - previous.read_ops) / time_delta, - "write_ops_per_sec": (recent.write_ops - previous.write_ops) / time_delta, - } + # Restore TUI settings + stdscr.nodelay(True) + stdscr.timeout(100) - def _format_bytes(self, bytes_val: float) -> str: - """Format bytes into human-readable string.""" - if bytes_val < 0: - bytes_val = 0 - for unit in ["B", "KB", "MB", "GB", "TB"]: - if bytes_val < 1024.0: - return f"{bytes_val:.2f}{unit}" - bytes_val /= 1024.0 - return f"{bytes_val:.2f}PB" - - def _handle_input(self, key: int) -> bool: + def _handle_input(self, key: int, stdscr) -> bool: """Handle keyboard input. Returns False to exit.""" if key == ord("q") or key == ord("Q"): return False # Exit + if key == ord("w") or key == ord("W"): + # Launch web mode + self._launch_web_mode(stdscr) + return True + if self.current_screen == "selection": if key == curses.KEY_UP: self.selected_index = max(0, self.selected_index - 1) diff --git a/BCC-Examples/container-monitor/web_dashboard.py b/BCC-Examples/container-monitor/web_dashboard.py new file mode 100644 index 0000000..9363827 --- /dev/null +++ b/BCC-Examples/container-monitor/web_dashboard.py @@ -0,0 +1,826 @@ +"""Beautiful web dashboard for container monitoring using Plotly Dash.""" + +import dash +from dash import dcc, html +from dash.dependencies import Input, Output +import plotly.graph_objects as go +from plotly.subplots import make_subplots +from typing import Optional +from data_collection import ContainerDataCollector + + +class WebDashboard: + """Beautiful web dashboard for container monitoring.""" + + def __init__( + self, + collector: ContainerDataCollector, + selected_cgroup: Optional[int] = None, + host: str = "0.0.0.0", + port: int = 8050, + ): + self.collector = collector + self.selected_cgroup = selected_cgroup + self.host = host + self.port = port + + # Suppress Dash dev tools and debug output + self.app = dash.Dash( + __name__, + title="pythonBPF Container Monitor", + suppress_callback_exceptions=True, + ) + + self._setup_layout() + self._setup_callbacks() + self._running = False + + def _setup_layout(self): + """Create the dashboard layout.""" + self.app.layout = html.Div( + [ + # Futuristic Header with pythonBPF branding + html.Div( + [ + html.Div( + [ + html.Div( + [ + html.Span( + "python", + style={ + "fontSize": "52px", + "fontWeight": "300", + "color": "#00ff88", + "fontFamily": "'Courier New', monospace", + "textShadow": "0 0 20px rgba(0,255,136,0.5)", + }, + ), + html.Span( + "BPF", + style={ + "fontSize": "52px", + "fontWeight": "900", + "color": "#00d4ff", + "fontFamily": "'Courier New', monospace", + "textShadow": "0 0 20px rgba(0,212,255,0.5)", + }, + ), + ], + style={"marginBottom": "5px"}, + ), + html.Div( + "CONTAINER PERFORMANCE MONITOR", + style={ + "fontSize": "16px", + "letterSpacing": "8px", + "color": "#8899ff", + "fontWeight": "300", + "fontFamily": "'Courier New', monospace", + }, + ), + ], + style={ + "textAlign": "center", + }, + ), + html.Div( + id="cgroup-name", + style={ + "textAlign": "center", + "color": "#00ff88", + "fontSize": "20px", + "marginTop": "15px", + "fontFamily": "'Courier New', monospace", + "fontWeight": "bold", + "textShadow": "0 0 10px rgba(0,255,136,0.3)", + }, + ), + ], + style={ + "background": "linear-gradient(135deg, #0a0e27 0%, #1a1f3a 50%, #0a0e27 100%)", + "padding": "40px 20px", + "borderRadius": "0", + "marginBottom": "0", + "boxShadow": "0 10px 40px rgba(0,212,255,0.2)", + "border": "1px solid rgba(0,212,255,0.3)", + "borderTop": "3px solid #00d4ff", + "borderBottom": "3px solid #00ff88", + "position": "relative", + "overflow": "hidden", + }, + ), + # Cgroup selector (if no cgroup selected) + html.Div( + [ + html.Label( + "SELECT CGROUP:", + style={ + "fontSize": "14px", + "fontWeight": "bold", + "color": "#00d4ff", + "marginRight": "15px", + "fontFamily": "'Courier New', monospace", + "letterSpacing": "2px", + }, + ), + dcc.Dropdown( + id="cgroup-selector", + style={ + "width": "600px", + "display": "inline-block", + "background": "#1a1f3a", + "border": "1px solid #00d4ff", + }, + ), + ], + id="selector-container", + style={ + "textAlign": "center", + "marginTop": "30px", + "marginBottom": "30px", + "padding": "20px", + "background": "rgba(26,31,58,0.5)", + "borderRadius": "10px", + "border": "1px solid rgba(0,212,255,0.2)", + "display": "block" if self.selected_cgroup is None else "none", + }, + ), + # Stats cards row + html.Div( + [ + self._create_stat_card( + "syscall-card", "⚡ SYSCALLS", "#00ff88" + ), + self._create_stat_card("network-card", "🌐 NETWORK", "#00d4ff"), + self._create_stat_card("file-card", "💾 FILE I/O", "#ff0088"), + ], + style={ + "display": "flex", + "justifyContent": "space-around", + "marginBottom": "30px", + "marginTop": "30px", + "gap": "25px", + "flexWrap": "wrap", + "padding": "0 20px", + }, + ), + # Graphs container + html.Div( + [ + # Network graphs + html.Div( + [ + html.Div( + [ + html.Span("🌐 ", style={"fontSize": "24px"}), + html.Span( + "NETWORK", + style={ + "fontFamily": "'Courier New', monospace", + "letterSpacing": "3px", + "fontWeight": "bold", + }, + ), + html.Span( + " I/O", + style={ + "fontFamily": "'Courier New', monospace", + "letterSpacing": "3px", + "color": "#00d4ff", + }, + ), + ], + style={ + "color": "#ffffff", + "fontSize": "20px", + "borderBottom": "2px solid #00d4ff", + "paddingBottom": "15px", + "marginBottom": "25px", + "textShadow": "0 0 10px rgba(0,212,255,0.3)", + }, + ), + dcc.Graph( + id="network-graph", style={"height": "400px"} + ), + ], + style={ + "background": "linear-gradient(135deg, #0a0e27 0%, #1a1f3a 100%)", + "padding": "30px", + "borderRadius": "15px", + "boxShadow": "0 8px 32px rgba(0,212,255,0.15)", + "marginBottom": "30px", + "border": "1px solid rgba(0,212,255,0.2)", + }, + ), + # File I/O graphs + html.Div( + [ + html.Div( + [ + html.Span("💾 ", style={"fontSize": "24px"}), + html.Span( + "FILE", + style={ + "fontFamily": "'Courier New', monospace", + "letterSpacing": "3px", + "fontWeight": "bold", + }, + ), + html.Span( + " I/O", + style={ + "fontFamily": "'Courier New', monospace", + "letterSpacing": "3px", + "color": "#ff0088", + }, + ), + ], + style={ + "color": "#ffffff", + "fontSize": "20px", + "borderBottom": "2px solid #ff0088", + "paddingBottom": "15px", + "marginBottom": "25px", + "textShadow": "0 0 10px rgba(255,0,136,0.3)", + }, + ), + dcc.Graph( + id="file-io-graph", style={"height": "400px"} + ), + ], + style={ + "background": "linear-gradient(135deg, #0a0e27 0%, #1a1f3a 100%)", + "padding": "30px", + "borderRadius": "15px", + "boxShadow": "0 8px 32px rgba(255,0,136,0.15)", + "marginBottom": "30px", + "border": "1px solid rgba(255,0,136,0.2)", + }, + ), + # Combined time series + html.Div( + [ + html.Div( + [ + html.Span("📈 ", style={"fontSize": "24px"}), + html.Span( + "REAL-TIME", + style={ + "fontFamily": "'Courier New', monospace", + "letterSpacing": "3px", + "fontWeight": "bold", + }, + ), + html.Span( + " METRICS", + style={ + "fontFamily": "'Courier New', monospace", + "letterSpacing": "3px", + "color": "#00ff88", + }, + ), + ], + style={ + "color": "#ffffff", + "fontSize": "20px", + "borderBottom": "2px solid #00ff88", + "paddingBottom": "15px", + "marginBottom": "25px", + "textShadow": "0 0 10px rgba(0,255,136,0.3)", + }, + ), + dcc.Graph( + id="timeseries-graph", style={"height": "500px"} + ), + ], + style={ + "background": "linear-gradient(135deg, #0a0e27 0%, #1a1f3a 100%)", + "padding": "30px", + "borderRadius": "15px", + "boxShadow": "0 8px 32px rgba(0,255,136,0.15)", + "border": "1px solid rgba(0,255,136,0.2)", + }, + ), + ], + style={"padding": "0 20px"}, + ), + # Footer with pythonBPF branding + html.Div( + [ + html.Div( + [ + html.Span( + "Powered by ", + style={"color": "#8899ff", "fontSize": "12px"}, + ), + html.Span( + "pythonBPF", + style={ + "color": "#00d4ff", + "fontSize": "14px", + "fontWeight": "bold", + "fontFamily": "'Courier New', monospace", + }, + ), + html.Span( + " | eBPF Container Monitoring", + style={ + "color": "#8899ff", + "fontSize": "12px", + "marginLeft": "10px", + }, + ), + ] + ) + ], + style={ + "textAlign": "center", + "padding": "20px", + "marginTop": "40px", + "background": "linear-gradient(135deg, #0a0e27 0%, #1a1f3a 100%)", + "borderTop": "1px solid rgba(0,212,255,0.2)", + }, + ), + # Auto-update interval + dcc.Interval(id="interval-component", interval=1000, n_intervals=0), + ], + style={ + "padding": "0", + "fontFamily": "'Segoe UI', 'Courier New', monospace", + "background": "linear-gradient(to bottom, #050813 0%, #0a0e27 100%)", + "minHeight": "100vh", + "margin": "0", + }, + ) + + def _create_stat_card(self, card_id: str, title: str, color: str): + """Create a statistics card with futuristic styling.""" + return html.Div( + [ + html.H3( + title, + style={ + "color": color, + "fontSize": "16px", + "marginBottom": "20px", + "fontWeight": "bold", + "fontFamily": "'Courier New', monospace", + "letterSpacing": "2px", + "textShadow": f"0 0 10px {color}50", + }, + ), + html.Div( + [ + html.Div( + id=f"{card_id}-value", + style={ + "fontSize": "42px", + "fontWeight": "bold", + "color": "#ffffff", + "marginBottom": "10px", + "fontFamily": "'Courier New', monospace", + "textShadow": f"0 0 20px {color}40", + }, + ), + html.Div( + id=f"{card_id}-rate", + style={ + "fontSize": "14px", + "color": "#8899ff", + "fontFamily": "'Courier New', monospace", + }, + ), + ] + ), + ], + style={ + "flex": "1", + "minWidth": "280px", + "background": "linear-gradient(135deg, #0a0e27 0%, #1a1f3a 100%)", + "padding": "30px", + "borderRadius": "15px", + "boxShadow": f"0 8px 32px {color}20", + "border": f"1px solid {color}40", + "borderLeft": f"4px solid {color}", + "transition": "transform 0.3s, box-shadow 0.3s", + "position": "relative", + "overflow": "hidden", + }, + ) + + def _setup_callbacks(self): + """Setup dashboard callbacks.""" + + @self.app.callback( + [Output("cgroup-selector", "options"), Output("cgroup-selector", "value")], + [Input("interval-component", "n_intervals")], + ) + def update_cgroup_selector(n): + if self.selected_cgroup is not None: + return [], self.selected_cgroup + + cgroups = self.collector.get_all_cgroups() + options = [ + {"label": f"{cg.name} (ID: {cg.id})", "value": cg.id} + for cg in sorted(cgroups, key=lambda c: c.name) + ] + value = options[0]["value"] if options else None + + if value and self.selected_cgroup is None: + self.selected_cgroup = value + + return options, self.selected_cgroup + + @self.app.callback( + Output("cgroup-selector", "value", allow_duplicate=True), + [Input("cgroup-selector", "value")], + prevent_initial_call=True, + ) + def select_cgroup(value): + if value: + self.selected_cgroup = value + return value + + @self.app.callback( + [ + Output("cgroup-name", "children"), + Output("syscall-card-value", "children"), + Output("syscall-card-rate", "children"), + Output("network-card-value", "children"), + Output("network-card-rate", "children"), + Output("file-card-value", "children"), + Output("file-card-rate", "children"), + Output("network-graph", "figure"), + Output("file-io-graph", "figure"), + Output("timeseries-graph", "figure"), + ], + [Input("interval-component", "n_intervals")], + ) + def update_dashboard(n): + if self.selected_cgroup is None: + empty_fig = self._create_empty_figure( + "Select a cgroup to begin monitoring" + ) + return ( + "SELECT A CGROUP TO START", + "0", + "", + "0 B", + "", + "0 B", + "", + empty_fig, + empty_fig, + empty_fig, + ) + + try: + stats = self.collector.get_stats_for_cgroup(self.selected_cgroup) + history = self.collector.get_history(self.selected_cgroup) + rates = self._calculate_rates(history) + + return ( + f"► {stats.cgroup_name}", + f"{stats.syscall_count:,}", + f"{rates['syscalls_per_sec']:.1f} calls/sec", + f"{self._format_bytes(stats.rx_bytes + stats.tx_bytes)}", + f"↓ {self._format_bytes(rates['rx_bytes_per_sec'])}/s ↑ {self._format_bytes(rates['tx_bytes_per_sec'])}/s", + f"{self._format_bytes(stats.read_bytes + stats.write_bytes)}", + f"R: {self._format_bytes(rates['read_bytes_per_sec'])}/s W: {self._format_bytes(rates['write_bytes_per_sec'])}/s", + self._create_network_graph(history), + self._create_file_io_graph(history), + self._create_timeseries_graph(history), + ) + except Exception as e: + empty_fig = self._create_empty_figure(f"Error: {str(e)}") + return ( + "ERROR", + "0", + str(e), + "0 B", + "", + "0 B", + "", + empty_fig, + empty_fig, + empty_fig, + ) + + def _create_empty_figure(self, message: str): + """Create an empty figure with a message.""" + fig = go.Figure() + fig.update_layout( + title=message, + template="plotly_dark", + paper_bgcolor="#0a0e27", + plot_bgcolor="#0a0e27", + font=dict(color="#8899ff", family="Courier New, monospace"), + ) + return fig + + def _create_network_graph(self, history): + """Create network I/O graph with futuristic styling.""" + if len(history) < 2: + return self._create_empty_figure("Collecting data...") + + times = [i for i in range(len(history))] + rx_bytes = [s.rx_bytes for s in history] + tx_bytes = [s.tx_bytes for s in history] + + fig = make_subplots( + rows=2, + cols=1, + subplot_titles=("RECEIVED (RX)", "TRANSMITTED (TX)"), + vertical_spacing=0.15, + ) + + fig.add_trace( + go.Scatter( + x=times, + y=rx_bytes, + mode="lines", + name="RX", + fill="tozeroy", + line=dict(color="#00d4ff", width=3, shape="spline"), + fillcolor="rgba(0, 212, 255, 0.2)", + ), + row=1, + col=1, + ) + + fig.add_trace( + go.Scatter( + x=times, + y=tx_bytes, + mode="lines", + name="TX", + fill="tozeroy", + line=dict(color="#00ff88", width=3, shape="spline"), + fillcolor="rgba(0, 255, 136, 0.2)", + ), + row=2, + col=1, + ) + + fig.update_xaxes(title_text="Time (samples)", row=2, col=1, color="#8899ff") + fig.update_yaxes(title_text="Bytes", row=1, col=1, color="#8899ff") + fig.update_yaxes(title_text="Bytes", row=2, col=1, color="#8899ff") + + fig.update_layout( + height=400, + template="plotly_dark", + paper_bgcolor="rgba(0,0,0,0)", + plot_bgcolor="#0a0e27", + showlegend=False, + hovermode="x unified", + font=dict(family="Courier New, monospace", color="#8899ff"), + ) + + return fig + + def _create_file_io_graph(self, history): + """Create file I/O graph with futuristic styling.""" + if len(history) < 2: + return self._create_empty_figure("Collecting data...") + + times = [i for i in range(len(history))] + read_bytes = [s.read_bytes for s in history] + write_bytes = [s.write_bytes for s in history] + + fig = make_subplots( + rows=2, + cols=1, + subplot_titles=("READ OPERATIONS", "WRITE OPERATIONS"), + vertical_spacing=0.15, + ) + + fig.add_trace( + go.Scatter( + x=times, + y=read_bytes, + mode="lines", + name="Read", + fill="tozeroy", + line=dict(color="#ff0088", width=3, shape="spline"), + fillcolor="rgba(255, 0, 136, 0.2)", + ), + row=1, + col=1, + ) + + fig.add_trace( + go.Scatter( + x=times, + y=write_bytes, + mode="lines", + name="Write", + fill="tozeroy", + line=dict(color="#8844ff", width=3, shape="spline"), + fillcolor="rgba(136, 68, 255, 0.2)", + ), + row=2, + col=1, + ) + + fig.update_xaxes(title_text="Time (samples)", row=2, col=1, color="#8899ff") + fig.update_yaxes(title_text="Bytes", row=1, col=1, color="#8899ff") + fig.update_yaxes(title_text="Bytes", row=2, col=1, color="#8899ff") + + fig.update_layout( + height=400, + template="plotly_dark", + paper_bgcolor="rgba(0,0,0,0)", + plot_bgcolor="#0a0e27", + showlegend=False, + hovermode="x unified", + font=dict(family="Courier New, monospace", color="#8899ff"), + ) + + return fig + + def _create_timeseries_graph(self, history): + """Create combined time series graph with futuristic styling.""" + if len(history) < 2: + return self._create_empty_figure("Collecting data...") + + times = [i for i in range(len(history))] + + fig = make_subplots( + rows=3, + cols=1, + subplot_titles=( + "SYSTEM CALLS", + "NETWORK TRAFFIC (Bytes)", + "FILE I/O (Bytes)", + ), + vertical_spacing=0.1, + specs=[ + [{"secondary_y": False}], + [{"secondary_y": True}], + [{"secondary_y": True}], + ], + ) + + # Syscalls + fig.add_trace( + go.Scatter( + x=times, + y=[s.syscall_count for s in history], + mode="lines", + name="Syscalls", + line=dict(color="#00ff88", width=3, shape="spline"), + ), + row=1, + col=1, + ) + + # Network + fig.add_trace( + go.Scatter( + x=times, + y=[s.rx_bytes for s in history], + mode="lines", + name="RX", + line=dict(color="#00d4ff", width=2, shape="spline"), + ), + row=2, + col=1, + secondary_y=False, + ) + + fig.add_trace( + go.Scatter( + x=times, + y=[s.tx_bytes for s in history], + mode="lines", + name="TX", + line=dict(color="#00ff88", width=2, shape="spline", dash="dot"), + ), + row=2, + col=1, + secondary_y=True, + ) + + # File I/O + fig.add_trace( + go.Scatter( + x=times, + y=[s.read_bytes for s in history], + mode="lines", + name="Read", + line=dict(color="#ff0088", width=2, shape="spline"), + ), + row=3, + col=1, + secondary_y=False, + ) + + fig.add_trace( + go.Scatter( + x=times, + y=[s.write_bytes for s in history], + mode="lines", + name="Write", + line=dict(color="#8844ff", width=2, shape="spline", dash="dot"), + ), + row=3, + col=1, + secondary_y=True, + ) + + fig.update_xaxes(title_text="Time (samples)", row=3, col=1, color="#8899ff") + fig.update_yaxes(title_text="Count", row=1, col=1, color="#8899ff") + fig.update_yaxes( + title_text="RX Bytes", row=2, col=1, secondary_y=False, color="#00d4ff" + ) + fig.update_yaxes( + title_text="TX Bytes", row=2, col=1, secondary_y=True, color="#00ff88" + ) + fig.update_yaxes( + title_text="Read Bytes", row=3, col=1, secondary_y=False, color="#ff0088" + ) + fig.update_yaxes( + title_text="Write Bytes", row=3, col=1, secondary_y=True, color="#8844ff" + ) + + fig.update_layout( + height=500, + template="plotly_dark", + paper_bgcolor="rgba(0,0,0,0)", + plot_bgcolor="#0a0e27", + hovermode="x unified", + showlegend=True, + legend=dict( + orientation="h", + yanchor="bottom", + y=1.02, + xanchor="right", + x=1, + font=dict(color="#8899ff"), + ), + font=dict(family="Courier New, monospace", color="#8899ff"), + ) + + return fig + + def _calculate_rates(self, history): + """Calculate rates from history.""" + if len(history) < 2: + return { + "syscalls_per_sec": 0.0, + "rx_bytes_per_sec": 0.0, + "tx_bytes_per_sec": 0.0, + "read_bytes_per_sec": 0.0, + "write_bytes_per_sec": 0.0, + } + + recent = history[-1] + previous = history[-2] + time_delta = recent.timestamp - previous.timestamp + + if time_delta <= 0: + time_delta = 1.0 + + return { + "syscalls_per_sec": max( + 0, (recent.syscall_count - previous.syscall_count) / time_delta + ), + "rx_bytes_per_sec": max( + 0, (recent.rx_bytes - previous.rx_bytes) / time_delta + ), + "tx_bytes_per_sec": max( + 0, (recent.tx_bytes - previous.tx_bytes) / time_delta + ), + "read_bytes_per_sec": max( + 0, (recent.read_bytes - previous.read_bytes) / time_delta + ), + "write_bytes_per_sec": max( + 0, (recent.write_bytes - previous.write_bytes) / time_delta + ), + } + + def _format_bytes(self, bytes_val: float) -> str: + """Format bytes into human-readable string.""" + if bytes_val < 0: + bytes_val = 0 + for unit in ["B", "KB", "MB", "GB", "TB"]: + if bytes_val < 1024.0: + return f"{bytes_val:.2f} {unit}" + bytes_val /= 1024.0 + return f"{bytes_val:.2f} PB" + + def run(self): + """Run the web dashboard.""" + self._running = True + # Suppress Werkzeug logging + import logging + + log = logging.getLogger("werkzeug") + log.setLevel(logging.ERROR) + + self.app.run(debug=False, host=self.host, port=self.port, use_reloader=False) + + def stop(self): + """Stop the web dashboard.""" + self._running = False