From 76c982e15e0572b4cb9d47f0ae3f78a012fca81d Mon Sep 17 00:00:00 2001 From: varun-r-mallya Date: Fri, 28 Nov 2025 21:30:41 +0530 Subject: [PATCH 1/2] Add a web dashboard --- BCC-Examples/container-monitor/tui.py | 573 ++++++++++-------- .../container-monitor/web_dashboard.py | 531 ++++++++++++++++ 2 files changed, 847 insertions(+), 257 deletions(-) create mode 100644 BCC-Examples/container-monitor/web_dashboard.py diff --git a/BCC-Examples/container-monitor/tui.py b/BCC-Examples/container-monitor/tui.py index f006137..58985cd 100644 --- a/BCC-Examples/container-monitor/tui.py +++ b/BCC-Examples/container-monitor/tui.py @@ -2,8 +2,11 @@ import time import curses +import webbrowser +import threading from typing import Optional, List from data_collection import ContainerDataCollector +from web_dashboard import WebDashboard class ContainerMonitorTUI: @@ -15,6 +18,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 +47,20 @@ 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 +71,56 @@ 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 as e: + # Curses error - likely terminal too small, just continue + pass except Exception as e: - # Show error - stdscr.addstr(0, 0, f"Error: {str(e)}") - stdscr.refresh() - time.sleep(2) + # Show error briefly + try: + height, width = stdscr.getmaxyx() + error_msg = f"Error: {str(e)[:width-10]}" + stdscr.addstr(0, 0, error_msg[:width-1]) + stdscr.refresh() + time.sleep(1) + except: + pass + + def _safe_addstr(self, 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_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" - ) + self._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" + self._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..." + self._safe_addstr(stdscr, height // 2, max(0, (width - len(msg)) // 2), + msg, curses.color_pair(4)) return # Sort cgroups by name @@ -98,7 +133,7 @@ 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: @@ -106,18 +141,18 @@ def _draw_selection_screen(self, stdscr): # 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)) + self._safe_addstr(stdscr, start_y, 2, "╔" + "═" * (width - 6) + "╗", + curses.color_pair(1)) for i in range(list_height): idx = self.scroll_offset + i y = start_y + 1 + 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 + + self._safe_addstr(stdscr, y, 2, "║", curses.color_pair(1)) + self._safe_addstr(stdscr, y, width - 3, "║", curses.color_pair(1)) if idx >= len(cgroups): continue @@ -125,30 +160,25 @@ def _draw_selection_screen(self, stdscr): cgroup = cgroups[idx] if idx == self.selected_index: - # Highlight selected with better styling - stdscr.attron(curses.color_pair(8) | curses.A_BOLD) + # Highlight selected line = f" ► {cgroup.name:<35} │ ID: {cgroup.id} " - stdscr.addstr(y, 3, line[: width - 6]) - stdscr.attroff(curses.color_pair(8) | curses.A_BOLD) + self._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)) + self._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 + 1 + list_height, height - 3) + self._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)) + self._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,15 +192,12 @@ 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" - ) + self._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" + self._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) @@ -178,218 +205,171 @@ def _draw_monitoring_screen(self, stdscr): 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: + self._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)" - - 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)", - ) - - y += 6 - - # 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)" - - self._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 + if y + 8 < height: + self._draw_section_header(stdscr, y, "🌐 NETWORK I/O", 1) + y += 1 + + # RX graph + 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)" + + 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)" + ) + y += 6 + + # TX graph + if y + 8 < height: + 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)" + + self._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 # 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( - 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 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( - 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)", - ) + if y + 8 < height: + self._draw_section_header(stdscr, y, "💾 FILE I/O", 1) + y += 1 + + # Read graph + 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( + 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: {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( + 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)" + ) 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) + self._safe_addstr(stdscr, 0, 0, "═" * width, curses.color_pair(6) | curses.A_BOLD) # Title - stdscr.addstr(0, (width - len(title)) // 2, f" {title} ") - stdscr.attroff(curses.color_pair(6) | curses.A_BOLD) + self._safe_addstr(stdscr, 0, max(0, (width - len(title)) // 2), f" {title} ", + 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)) + self._safe_addstr(stdscr, 1, max(0, (width - len(subtitle)) // 2), subtitle, + curses.color_pair(1)) # Bottom border - stdscr.attron(curses.color_pair(6)) - stdscr.addstr(2, 0, "═" * width) - stdscr.attroff(curses.color_pair(6)) + self._safe_addstr(stdscr, 2, 0, "═" * width, 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, - ): + 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.""" + height, _ = stdscr.getmaxyx() + + if y + 4 >= height: + return + # Top border - stdscr.attron(color_pair | curses.A_BOLD) - stdscr.addstr(y, x, "┌" + "─" * (width - 2) + "┐") + self._safe_addstr(stdscr, y, x, "┌" + "─" * (width - 2) + "┐", + color_pair | curses.A_BOLD) # 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) + self._safe_addstr(stdscr, y + 1, x, "│", color_pair | curses.A_BOLD) + self._safe_addstr(stdscr, y + 1, x + 2, label, color_pair | curses.A_BOLD) + self._safe_addstr(stdscr, y + 1, x + width - 1, "│", color_pair | curses.A_BOLD) + + # Value + self._safe_addstr(stdscr, y + 2, x, "│", color_pair | curses.A_BOLD) + self._safe_addstr(stdscr, y + 2, x + 4, value, curses.color_pair(2) | curses.A_BOLD) + self._safe_addstr(stdscr, y + 2, min(x + width - len(detail) - 3, x + width - 2), detail, + color_pair | curses.A_BOLD) + self._safe_addstr(stdscr, y + 2, x + width - 1, "│", color_pair | curses.A_BOLD) # Bottom border - stdscr.addstr(y + 3, x, "└" + "─" * (width - 2) + "┘") - stdscr.attroff(color_pair | curses.A_BOLD) + self._safe_addstr(stdscr, y + 3, x, "└" + "─" * (width - 2) + "┘", + color_pair | curses.A_BOLD) 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) + + if y >= height: + return + + self._safe_addstr(stdscr, y, 2, title, curses.color_pair(color_pair) | curses.A_BOLD) + self._safe_addstr(stdscr, y, len(title) + 3, "─" * (width - len(title) - 5), + curses.color_pair(color_pair) | curses.A_BOLD) 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, + 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) + screen_height, screen_width = stdscr.getmaxyx() - stdscr.attron(curses.color_pair(2)) - stdscr.addstr(y, x + len(label) + 2, rate) - stdscr.attroff(curses.color_pair(2)) + if y >= screen_height or y + height + 2 >= screen_height: + return - stdscr.attron(curses.color_pair(7)) - stdscr.addstr(y, x + len(label) + len(rate) + 4, detail) - stdscr.attroff(curses.color_pair(7)) + # Header with metrics + self._safe_addstr(stdscr, y, x, label, curses.color_pair(1) | curses.A_BOLD) + self._safe_addstr(stdscr, y, x + len(label) + 2, rate, curses.color_pair(2)) + self._safe_addstr(stdscr, y, x + len(label) + len(rate) + 4, detail, + 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 + stdscr, y + 1, x, width, height, + data, color_pair ) else: - stdscr.attron(curses.color_pair(7)) - stdscr.addstr(y + 2, x + 2, "Collecting data...") - stdscr.attroff(curses.color_pair(7)) + self._safe_addstr(stdscr, y + 2, x + 2, "Collecting data...", + 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)) + # Graph legend + if y + height + 1 < screen_height: + self._safe_addstr(stdscr, y + height + 1, x, f"└─ {description}", + curses.color_pair(7)) def _draw_bar_graph_enhanced( self, @@ -402,7 +382,9 @@ def _draw_bar_graph_enhanced( color_pair: int, ): """Draw an enhanced bar graph with axis and scale.""" - if not data or width < 2: + screen_height, screen_width = stdscr.getmaxyx() + + if not data or width < 2 or y + height >= screen_height: return # Calculate statistics @@ -410,19 +392,26 @@ def _draw_bar_graph_enhanced( 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 + # 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 - 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 Y-axis labels (with bounds checking) + if y < screen_height: + self._safe_addstr(stdscr, y, x, f"│{self._format_bytes(max_val):>9}", + curses.color_pair(7)) + if y + height // 2 < screen_height: + self._safe_addstr(stdscr, y + height // 2, x, f"│{self._format_bytes(avg_val):>9}", + curses.color_pair(7)) + if y + height - 1 < screen_height: + self._safe_addstr(stdscr, y + height - 1, x, f"│{self._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 = "" @@ -439,29 +428,28 @@ def _draw_bar_graph_enhanced( else: bar_line += " " - stdscr.attron(color_pair) - stdscr.addstr(y + row, x + 11, bar_line) - stdscr.attroff(color_pair) + self._safe_addstr(stdscr, y + row, x + 11, bar_line, 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)) + if y + height < screen_height: + self._safe_addstr(stdscr, y + height, x + 10, "├" + "─" * len(recent_data), + curses.color_pair(7)) + self._safe_addstr(stdscr, y + height, x + 10 + len(recent_data), "→ time", + 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, + '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 @@ -473,18 +461,15 @@ def _calculate_rates(self, history: List) -> dict: 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, + '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(self, bytes_val: float) -> str: @@ -493,15 +478,89 @@ def _format_bytes(self, bytes_val: float) -> str: bytes_val = 0 for unit in ["B", "KB", "MB", "GB", "TB"]: if bytes_val < 1024.0: - return f"{bytes_val:.2f}{unit}" + return f"{bytes_val:.1f}{unit}" bytes_val /= 1024.0 - return f"{bytes_val:.2f}PB" + return f"{bytes_val:.1f}PB" + + def _launch_web_mode(self, stdscr): + """Launch web dashboard mode.""" + height, width = stdscr.getmaxyx() + + # Show transition message + stdscr.clear() + + msg1 = "🌐 LAUNCHING WEB DASHBOARD" + self._safe_addstr(stdscr, height // 2 - 2, max(0, (width - len(msg1)) // 2), msg1, + curses.color_pair(6) | curses.A_BOLD) + + msg2 = "Opening browser at http://localhost:8050" + self._safe_addstr(stdscr, height // 2, max(0, (width - len(msg2)) // 2), msg2, + curses.color_pair(2)) + + msg3 = "Press 'q' to stop web server and return to TUI" + self._safe_addstr(stdscr, height // 2 + 2, max(0, (width - len(msg3)) // 2), msg3, + curses.color_pair(3)) + + stdscr.refresh() + time.sleep(1) - def _handle_input(self, key: int) -> bool: + try: + # Create and start web dashboard + self.web_dashboard = WebDashboard(self.collector, selected_cgroup=self.selected_cgroup) + + # Start in background thread + self.web_thread = threading.Thread(target=self.web_dashboard.run, daemon=True) + self.web_thread.start() + + time.sleep(2) # Give server more time to start + + # Open browser + try: + webbrowser.open('http://localhost:8050') + except Exception as e: + error_msg = f"Could not open browser: {str(e)}" + self._safe_addstr(stdscr, height // 2 + 4, max(0, (width - len(error_msg)) // 2), + error_msg, curses.color_pair(4)) + stdscr.refresh() + time.sleep(2) + + # Wait for user to press 'q' to return + msg4 = "Web dashboard running! Press 'q' to return to TUI" + self._safe_addstr(stdscr, height // 2 + 4, max(0, (width - len(msg4)) // 2), msg4, + curses.color_pair(1) | curses.A_BOLD) + stdscr.refresh() + + 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)}" + self._safe_addstr(stdscr, height // 2 + 4, max(0, (width - len(error_msg)) // 2), + error_msg, curses.color_pair(4)) + stdscr.refresh() + time.sleep(3) + + # Restore TUI settings + stdscr.nodelay(True) + stdscr.timeout(100) + + 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..34690f5 --- /dev/null +++ b/BCC-Examples/container-monitor/web_dashboard.py @@ -0,0 +1,531 @@ +"""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 +import sys + + +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="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([ + # Header + html.Div([ + html.H1( + "🐳 Container Monitor Dashboard", + style={ + 'textAlign': 'center', + 'color': '#ffffff', + 'marginBottom': '10px', + 'fontSize': '48px', + 'fontWeight': 'bold', + 'textShadow': '2px 2px 4px rgba(0,0,0,0.3)' + } + ), + html.Div( + id='cgroup-name', + style={ + 'textAlign': 'center', + 'color': '#e0e0e0', + 'fontSize': '24px', + 'marginBottom': '20px' + } + ) + ], style={ + 'background': 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', + 'padding': '30px', + 'borderRadius': '10px', + 'marginBottom': '20px', + 'boxShadow': '0 10px 30px rgba(0,0,0,0.3)' + }), + + # Cgroup selector (if no cgroup selected) + html.Div([ + html.Label("Select Cgroup:", style={ + 'fontSize': '18px', + 'fontWeight': 'bold', + 'color': '#333', + 'marginRight': '10px' + }), + dcc.Dropdown( + id='cgroup-selector', + style={'width': '500px', 'display': 'inline-block'} + ) + ], id='selector-container', style={ + 'textAlign': 'center', + 'marginBottom': '30px', + 'display': 'block' if self.selected_cgroup is None else 'none' + }), + + # Stats cards row + html.Div([ + self._create_stat_card('syscall-card', '⚡ Syscalls', '#8b5cf6'), + self._create_stat_card('network-card', '🌐 Network Traffic', '#3b82f6'), + self._create_stat_card('file-card', '💾 File I/O', '#ef4444'), + ], style={ + 'display': 'flex', + 'justifyContent': 'space-around', + 'marginBottom': '30px', + 'gap': '20px', + 'flexWrap': 'wrap' + }), + + # Graphs container + html.Div([ + # Network graphs + html.Div([ + html.H2("🌐 Network I/O", style={ + 'color': '#3b82f6', + 'borderBottom': '3px solid #3b82f6', + 'paddingBottom': '10px', + 'marginBottom': '20px' + }), + dcc.Graph(id='network-graph', style={'height': '400px'}), + ], style={ + 'background': 'white', + 'padding': '25px', + 'borderRadius': '10px', + 'boxShadow': '0 4px 15px rgba(0,0,0,0.1)', + 'marginBottom': '30px' + }), + + # File I/O graphs + html.Div([ + html.H2("💾 File I/O", style={ + 'color': '#ef4444', + 'borderBottom': '3px solid #ef4444', + 'paddingBottom': '10px', + 'marginBottom': '20px' + }), + dcc.Graph(id='file-io-graph', style={'height': '400px'}), + ], style={ + 'background': 'white', + 'padding': '25px', + 'borderRadius': '10px', + 'boxShadow': '0 4px 15px rgba(0,0,0,0.1)', + 'marginBottom': '30px' + }), + + # Combined time series + html.Div([ + html.H2("📈 Real-time Metrics", style={ + 'color': '#10b981', + 'borderBottom': '3px solid #10b981', + 'paddingBottom': '10px', + 'marginBottom': '20px' + }), + dcc.Graph(id='timeseries-graph', style={'height': '500px'}), + ], style={ + 'background': 'white', + 'padding': '25px', + 'borderRadius': '10px', + 'boxShadow': '0 4px 15px rgba(0,0,0,0.1)' + }), + ]), + + # Auto-update interval + dcc.Interval(id='interval-component', interval=1000, n_intervals=0), + + ], style={ + 'padding': '20px', + 'fontFamily': "'Segoe UI', Tahoma, Geneva, Verdana, sans-serif", + 'background': '#f3f4f6', + 'minHeight': '100vh' + }) + + def _create_stat_card(self, card_id: str, title: str, color: str): + """Create a statistics card.""" + return html.Div([ + html.H3(title, style={ + 'color': color, + 'fontSize': '20px', + 'marginBottom': '15px', + 'fontWeight': 'bold' + }), + html.Div([ + html.Div(id=f'{card_id}-value', style={ + 'fontSize': '36px', + 'fontWeight': 'bold', + 'color': '#1f2937', + 'marginBottom': '5px' + }), + html.Div(id=f'{card_id}-rate', style={ + 'fontSize': '16px', + 'color': '#6b7280' + }) + ]) + ], style={ + 'flex': '1', + 'minWidth': '250px', + 'background': 'white', + 'padding': '25px', + 'borderRadius': '10px', + 'boxShadow': '0 4px 15px rgba(0,0,0,0.1)', + 'borderLeft': f'5px solid {color}', + 'transition': 'transform 0.2s' + }) + + 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 = go.Figure() + empty_fig.update_layout( + title="Select a cgroup to begin monitoring", + template="plotly_white" + ) + return ("Select a cgroup", "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"Monitoring: {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 = go.Figure() + empty_fig.update_layout(title=f"Error: {str(e)}", template="plotly_white") + return ("Error", "0", str(e), "0 B", "", "0 B", "", empty_fig, empty_fig, empty_fig) + + def _create_network_graph(self, history): + """Create network I/O graph.""" + if len(history) < 2: + fig = go.Figure() + fig.update_layout(title="Collecting data...", template="plotly_white") + return fig + + 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='#3b82f6', width=3), + fillcolor='rgba(59, 130, 246, 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='#fbbf24', width=3), + fillcolor='rgba(251, 191, 36, 0.2)' + ), + row=2, col=1 + ) + + fig.update_xaxes(title_text="Time (samples)", row=2, col=1) + fig.update_yaxes(title_text="Bytes", row=1, col=1) + fig.update_yaxes(title_text="Bytes", row=2, col=1) + + fig.update_layout( + height=400, + template="plotly_white", + showlegend=False, + hovermode='x unified' + ) + + return fig + + def _create_file_io_graph(self, history): + """Create file I/O graph.""" + if len(history) < 2: + fig = go.Figure() + fig.update_layout(title="Collecting data...", template="plotly_white") + return fig + + 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='#ef4444', width=3), + fillcolor='rgba(239, 68, 68, 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='#8b5cf6', width=3), + fillcolor='rgba(139, 92, 246, 0.2)' + ), + row=2, col=1 + ) + + fig.update_xaxes(title_text="Time (samples)", row=2, col=1) + fig.update_yaxes(title_text="Bytes", row=1, col=1) + fig.update_yaxes(title_text="Bytes", row=2, col=1) + + fig.update_layout( + height=400, + template="plotly_white", + showlegend=False, + hovermode='x unified' + ) + + return fig + + def _create_timeseries_graph(self, history): + """Create combined time series graph.""" + if len(history) < 2: + fig = go.Figure() + fig.update_layout(title="Collecting data...", template="plotly_white") + return fig + + 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='#8b5cf6', width=2) + ), + 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='#3b82f6', width=2) + ), + 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='#fbbf24', width=2) + ), + 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='#ef4444', width=2) + ), + 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='#8b5cf6', width=2) + ), + row=3, col=1, secondary_y=True + ) + + fig.update_xaxes(title_text="Time (samples)", row=3, col=1) + fig.update_yaxes(title_text="Count", row=1, col=1) + fig.update_yaxes(title_text="RX Bytes", row=2, col=1, secondary_y=False) + fig.update_yaxes(title_text="TX Bytes", row=2, col=1, secondary_y=True) + fig.update_yaxes(title_text="Read Bytes", row=3, col=1, secondary_y=False) + fig.update_yaxes(title_text="Write Bytes", row=3, col=1, secondary_y=True) + + fig.update_layout( + height=500, + template="plotly_white", + hovermode='x unified', + showlegend=True, + legend=dict( + orientation="h", + yanchor="bottom", + y=1.02, + xanchor="right", + x=1 + ) + ) + + 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 From c97efb2570319e9ced85b00958068cfce3425ab0 Mon Sep 17 00:00:00 2001 From: varun-r-mallya Date: Fri, 28 Nov 2025 22:11:41 +0530 Subject: [PATCH 2/2] change web version --- BCC-Examples/container-monitor/tui.py | 780 ++++++++++------ .../container-monitor/web_dashboard.py | 879 ++++++++++++------ 2 files changed, 1060 insertions(+), 599 deletions(-) diff --git a/BCC-Examples/container-monitor/tui.py b/BCC-Examples/container-monitor/tui.py index 58985cd..d76c924 100644 --- a/BCC-Examples/container-monitor/tui.py +++ b/BCC-Examples/container-monitor/tui.py @@ -2,13 +2,289 @@ import time import curses -import webbrowser 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: """TUI for container monitoring with cgroup selection and live graphs.""" @@ -51,13 +327,15 @@ def _main_loop(self, stdscr): # Check minimum terminal size if height < 25 or width < 80: - msg = "Terminal too small! Minimum: 80x25" + 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.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'): + if key == ord("q") or key == ord("Q"): break continue @@ -76,51 +354,48 @@ def _main_loop(self, stdscr): except KeyboardInterrupt: break - except curses.error as e: + except curses.error: # Curses error - likely terminal too small, just continue pass except Exception as e: # Show error briefly - try: - height, width = stdscr.getmaxyx() - error_msg = f"Error: {str(e)[:width-10]}" - stdscr.addstr(0, 0, error_msg[:width-1]) - stdscr.refresh() - time.sleep(1) - except: - pass - - def _safe_addstr(self, 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 + height, width = stdscr.getmaxyx() + error_msg = f"Error: {str(e)[: width - 10]}" + stdscr.addstr(0, 0, error_msg[: width - 1]) + stdscr.refresh() + 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 | w: Web Mode | q: Quit | r: Refresh" - self._safe_addstr(stdscr, 3, max(0, (width - len(instructions)) // 2), - instructions, 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..." - self._safe_addstr(stdscr, height // 2, max(0, (width - len(msg)) // 2), - msg, curses.color_pair(4)) + _safe_addstr( + stdscr, + height // 2, + max(0, (width - len(msg)) // 2), + msg, + curses.color_pair(4), + ) return # Sort cgroups by name @@ -139,46 +414,76 @@ def _draw_selection_screen(self, stdscr): 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 - self._safe_addstr(stdscr, start_y, 2, "╔" + "═" * (width - 6) + "╗", - 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 if y >= height - 2: break - self._safe_addstr(stdscr, y, 2, "║", curses.color_pair(1)) - self._safe_addstr(stdscr, y, width - 3, "║", curses.color_pair(1)) + _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 - line = f" ► {cgroup.name:<35} │ ID: {cgroup.id} " - self._safe_addstr(stdscr, y, 3, line, - 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: - line = f" {cgroup.name:<35} │ ID: {cgroup.id} " - self._safe_addstr(stdscr, y, 3, line, 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 = min(start_y + 1 + list_height, height - 3) - self._safe_addstr(stdscr, bottom_y, 2, "╚" + "═" * (width - 6) + "╝", - 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 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))}" - self._safe_addstr(stdscr, height - 2, max(0, (width - len(footer)) // 2), - footer, 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.""" @@ -192,295 +497,129 @@ 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[:40]}", "Live Performance Metrics") + _draw_fancy_header( + stdscr, f"📊 {stats.cgroup_name[:40]}", "Live Performance Metrics" + ) # Instructions instructions = "ESC/b: Back to List | w: Web Mode | q: Quit" - self._safe_addstr(stdscr, 3, max(0, (width - len(instructions)) // 2), - instructions, curses.color_pair(3)) + _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 if y + 4 < height: - self._draw_metric_box( - stdscr, y, 2, min(width - 4, 80), + _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) + curses.color_pair(5), ) y += 4 # Network I/O Section if y + 8 < height: - self._draw_section_header(stdscr, y, "🌐 NETWORK I/O", 1) + _draw_section_header(stdscr, y, "🌐 NETWORK I/O", 1) y += 1 # RX graph - rx_label = f"RX: {self._format_bytes(stats.rx_bytes)}" - rx_rate = f"{self._format_bytes(rates['rx_bytes_per_sec'])}/s" + 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)" - self._draw_labeled_graph( - stdscr, y, 2, width - 4, 4, - rx_label, rx_rate, rx_pkts, + _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)" + "Received Traffic (last 100 samples)", ) y += 6 # TX graph if y + 8 < height: - tx_label = f"TX: {self._format_bytes(stats.tx_bytes)}" - tx_rate = f"{self._format_bytes(rates['tx_bytes_per_sec'])}/s" + 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)" - self._draw_labeled_graph( - stdscr, y, 2, width - 4, 4, - tx_label, tx_rate, tx_pkts, + _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)" + "Transmitted Traffic (last 100 samples)", ) y += 6 # File I/O Section if y + 8 < height: - self._draw_section_header(stdscr, y, "💾 FILE I/O", 1) + _draw_section_header(stdscr, y, "💾 FILE I/O", 1) y += 1 # Read graph - read_label = f"READ: {self._format_bytes(stats.read_bytes)}" - read_rate = f"{self._format_bytes(rates['read_bytes_per_sec'])}/s" + 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)" - self._draw_labeled_graph( - stdscr, y, 2, width - 4, 4, - read_label, read_rate, read_ops, + _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)" + "Read Operations (last 100 samples)", ) y += 6 # Write graph if y + 8 < height: - write_label = f"WRITE: {self._format_bytes(stats.write_bytes)}" - write_rate = f"{self._format_bytes(rates['write_bytes_per_sec'])}/s" + 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)" - self._draw_labeled_graph( - stdscr, y, 2, width - 4, 4, - write_label, write_rate, write_ops, + _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)" - ) - - def _draw_fancy_header(self, stdscr, title: str, subtitle: str): - """Draw a fancy header with title and subtitle.""" - height, width = stdscr.getmaxyx() - - # Top border - self._safe_addstr(stdscr, 0, 0, "═" * width, curses.color_pair(6) | curses.A_BOLD) - - # Title - self._safe_addstr(stdscr, 0, max(0, (width - len(title)) // 2), f" {title} ", - curses.color_pair(6) | curses.A_BOLD) - - # Subtitle - self._safe_addstr(stdscr, 1, max(0, (width - len(subtitle)) // 2), subtitle, - curses.color_pair(1)) - - # Bottom border - self._safe_addstr(stdscr, 2, 0, "═" * width, 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.""" - height, _ = stdscr.getmaxyx() - - if y + 4 >= height: - return - - # Top border - self._safe_addstr(stdscr, y, x, "┌" + "─" * (width - 2) + "┐", - color_pair | curses.A_BOLD) - - # Label - self._safe_addstr(stdscr, y + 1, x, "│", color_pair | curses.A_BOLD) - self._safe_addstr(stdscr, y + 1, x + 2, label, color_pair | curses.A_BOLD) - self._safe_addstr(stdscr, y + 1, x + width - 1, "│", color_pair | curses.A_BOLD) - - # Value - self._safe_addstr(stdscr, y + 2, x, "│", color_pair | curses.A_BOLD) - self._safe_addstr(stdscr, y + 2, x + 4, value, curses.color_pair(2) | curses.A_BOLD) - self._safe_addstr(stdscr, y + 2, min(x + width - len(detail) - 3, x + width - 2), detail, - color_pair | curses.A_BOLD) - self._safe_addstr(stdscr, y + 2, x + width - 1, "│", color_pair | curses.A_BOLD) - - # Bottom border - self._safe_addstr(stdscr, y + 3, x, "└" + "─" * (width - 2) + "┘", - color_pair | curses.A_BOLD) - - def _draw_section_header(self, stdscr, y: int, title: str, color_pair: int): - """Draw a section header.""" - height, width = stdscr.getmaxyx() - - if y >= height: - return - - self._safe_addstr(stdscr, y, 2, title, curses.color_pair(color_pair) | curses.A_BOLD) - self._safe_addstr(stdscr, y, len(title) + 3, "─" * (width - len(title) - 5), - curses.color_pair(color_pair) | curses.A_BOLD) - - 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.""" - screen_height, screen_width = stdscr.getmaxyx() - - if y >= screen_height or y + height + 2 >= screen_height: - return - - # Header with metrics - self._safe_addstr(stdscr, y, x, label, curses.color_pair(1) | curses.A_BOLD) - self._safe_addstr(stdscr, y, x + len(label) + 2, rate, curses.color_pair(2)) - self._safe_addstr(stdscr, y, x + len(label) + len(rate) + 4, detail, - 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 + "Write Operations (last 100 samples)", ) - else: - self._safe_addstr(stdscr, y + 2, x + 2, "Collecting data...", - curses.color_pair(7)) - - # Graph legend - if y + height + 1 < screen_height: - self._safe_addstr(stdscr, y + height + 1, x, f"└─ {description}", - 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.""" - 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: - self._safe_addstr(stdscr, y, x, f"│{self._format_bytes(max_val):>9}", - curses.color_pair(7)) - if y + height // 2 < screen_height: - self._safe_addstr(stdscr, y + height // 2, x, f"│{self._format_bytes(avg_val):>9}", - curses.color_pair(7)) - if y + height - 1 < screen_height: - self._safe_addstr(stdscr, y + height - 1, x, f"│{self._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 += " " - - self._safe_addstr(stdscr, y + row, x + 11, bar_line, color_pair) - - # Draw X-axis - if y + height < screen_height: - self._safe_addstr(stdscr, y + height, x + 10, "├" + "─" * len(recent_data), - curses.color_pair(7)) - self._safe_addstr(stdscr, y + height, x + 10 + len(recent_data), "→ time", - 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 - - 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(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:.1f}{unit}" - bytes_val /= 1024.0 - return f"{bytes_val:.1f}PB" def _launch_web_mode(self, stdscr): """Launch web dashboard mode.""" @@ -490,50 +629,72 @@ def _launch_web_mode(self, stdscr): stdscr.clear() msg1 = "🌐 LAUNCHING WEB DASHBOARD" - self._safe_addstr(stdscr, height // 2 - 2, max(0, (width - len(msg1)) // 2), msg1, - curses.color_pair(6) | curses.A_BOLD) - - msg2 = "Opening browser at http://localhost:8050" - self._safe_addstr(stdscr, height // 2, max(0, (width - len(msg2)) // 2), msg2, - curses.color_pair(2)) + _safe_addstr( + stdscr, + height // 2 - 2, + max(0, (width - len(msg1)) // 2), + msg1, + curses.color_pair(6) | curses.A_BOLD, + ) + + msg2 = "Server starting at http://localhost:8050" + _safe_addstr( + stdscr, + height // 2, + max(0, (width - len(msg2)) // 2), + msg2, + curses.color_pair(2), + ) msg3 = "Press 'q' to stop web server and return to TUI" - self._safe_addstr(stdscr, height // 2 + 2, max(0, (width - len(msg3)) // 2), msg3, - curses.color_pair(3)) + _safe_addstr( + stdscr, + height // 2 + 2, + max(0, (width - len(msg3)) // 2), + msg3, + curses.color_pair(3), + ) stdscr.refresh() time.sleep(1) try: # Create and start web dashboard - self.web_dashboard = WebDashboard(self.collector, selected_cgroup=self.selected_cgroup) + self.web_dashboard = WebDashboard( + self.collector, selected_cgroup=self.selected_cgroup + ) # Start in background thread - self.web_thread = threading.Thread(target=self.web_dashboard.run, daemon=True) + self.web_thread = threading.Thread( + target=self.web_dashboard.run, daemon=True + ) self.web_thread.start() - time.sleep(2) # Give server more time to start - - # Open browser - try: - webbrowser.open('http://localhost:8050') - except Exception as e: - error_msg = f"Could not open browser: {str(e)}" - self._safe_addstr(stdscr, height // 2 + 4, max(0, (width - len(error_msg)) // 2), - error_msg, curses.color_pair(4)) - stdscr.refresh() - time.sleep(2) + time.sleep(2) # Give server time to start # Wait for user to press 'q' to return - msg4 = "Web dashboard running! Press 'q' to return to TUI" - self._safe_addstr(stdscr, height // 2 + 4, max(0, (width - len(msg4)) // 2), msg4, - curses.color_pair(1) | curses.A_BOLD) + 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() stdscr.nodelay(False) # Blocking mode while True: key = stdscr.getch() - if key == ord('q') or key == ord('Q'): + if key == ord("q") or key == ord("Q"): break # Stop web server @@ -542,8 +703,13 @@ def _launch_web_mode(self, stdscr): except Exception as e: error_msg = f"Error starting web dashboard: {str(e)}" - self._safe_addstr(stdscr, height // 2 + 4, max(0, (width - len(error_msg)) // 2), - error_msg, curses.color_pair(4)) + _safe_addstr( + stdscr, + height // 2 + 4, + max(0, (width - len(error_msg)) // 2), + error_msg, + curses.color_pair(4), + ) stdscr.refresh() time.sleep(3) diff --git a/BCC-Examples/container-monitor/web_dashboard.py b/BCC-Examples/container-monitor/web_dashboard.py index 34690f5..9363827 100644 --- a/BCC-Examples/container-monitor/web_dashboard.py +++ b/BCC-Examples/container-monitor/web_dashboard.py @@ -7,16 +7,18 @@ from plotly.subplots import make_subplots from typing import Optional from data_collection import ContainerDataCollector -import sys 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): + 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 @@ -25,8 +27,8 @@ def __init__(self, collector: ContainerDataCollector, # Suppress Dash dev tools and debug output self.app = dash.Dash( __name__, - title="Container Monitor", - suppress_callback_exceptions=True + title="pythonBPF Container Monitor", + suppress_callback_exceptions=True, ) self._setup_layout() @@ -35,179 +37,395 @@ def __init__(self, collector: ContainerDataCollector, def _setup_layout(self): """Create the dashboard layout.""" - self.app.layout = html.Div([ - # Header - html.Div([ - html.H1( - "🐳 Container Monitor Dashboard", + 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={ - 'textAlign': 'center', - 'color': '#ffffff', - 'marginBottom': '10px', - 'fontSize': '48px', - 'fontWeight': 'bold', - 'textShadow': '2px 2px 4px rgba(0,0,0,0.3)' - } + "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( - id='cgroup-name', + [ + 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', - 'color': '#e0e0e0', - 'fontSize': '24px', - 'marginBottom': '20px' - } - ) - ], style={ - 'background': 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', - 'padding': '30px', - 'borderRadius': '10px', - 'marginBottom': '20px', - 'boxShadow': '0 10px 30px rgba(0,0,0,0.3)' - }), - - # Cgroup selector (if no cgroup selected) - html.Div([ - html.Label("Select Cgroup:", style={ - 'fontSize': '18px', - 'fontWeight': 'bold', - 'color': '#333', - 'marginRight': '10px' - }), - dcc.Dropdown( - id='cgroup-selector', - style={'width': '500px', 'display': 'inline-block'} - ) - ], id='selector-container', style={ - 'textAlign': 'center', - 'marginBottom': '30px', - 'display': 'block' if self.selected_cgroup is None else 'none' - }), - - # Stats cards row - html.Div([ - self._create_stat_card('syscall-card', '⚡ Syscalls', '#8b5cf6'), - self._create_stat_card('network-card', '🌐 Network Traffic', '#3b82f6'), - self._create_stat_card('file-card', '💾 File I/O', '#ef4444'), - ], style={ - 'display': 'flex', - 'justifyContent': 'space-around', - 'marginBottom': '30px', - 'gap': '20px', - 'flexWrap': 'wrap' - }), - - # Graphs container - html.Div([ - # Network graphs - html.Div([ - html.H2("🌐 Network I/O", style={ - 'color': '#3b82f6', - 'borderBottom': '3px solid #3b82f6', - 'paddingBottom': '10px', - 'marginBottom': '20px' - }), - dcc.Graph(id='network-graph', style={'height': '400px'}), - ], style={ - 'background': 'white', - 'padding': '25px', - 'borderRadius': '10px', - 'boxShadow': '0 4px 15px rgba(0,0,0,0.1)', - 'marginBottom': '30px' - }), - - # File I/O graphs - html.Div([ - html.H2("💾 File I/O", style={ - 'color': '#ef4444', - 'borderBottom': '3px solid #ef4444', - 'paddingBottom': '10px', - 'marginBottom': '20px' - }), - dcc.Graph(id='file-io-graph', style={'height': '400px'}), - ], style={ - 'background': 'white', - 'padding': '25px', - 'borderRadius': '10px', - 'boxShadow': '0 4px 15px rgba(0,0,0,0.1)', - 'marginBottom': '30px' - }), - - # Combined time series - html.Div([ - html.H2("📈 Real-time Metrics", style={ - 'color': '#10b981', - 'borderBottom': '3px solid #10b981', - 'paddingBottom': '10px', - 'marginBottom': '20px' - }), - dcc.Graph(id='timeseries-graph', style={'height': '500px'}), - ], style={ - 'background': 'white', - 'padding': '25px', - 'borderRadius': '10px', - 'boxShadow': '0 4px 15px rgba(0,0,0,0.1)' - }), - ]), - - # Auto-update interval - dcc.Interval(id='interval-component', interval=1000, n_intervals=0), - - ], style={ - 'padding': '20px', - 'fontFamily': "'Segoe UI', Tahoma, Geneva, Verdana, sans-serif", - 'background': '#f3f4f6', - 'minHeight': '100vh' - }) + "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.""" - return html.Div([ - html.H3(title, style={ - 'color': color, - 'fontSize': '20px', - 'marginBottom': '15px', - 'fontWeight': 'bold' - }), - html.Div([ - html.Div(id=f'{card_id}-value', style={ - 'fontSize': '36px', - 'fontWeight': 'bold', - 'color': '#1f2937', - 'marginBottom': '5px' - }), - html.Div(id=f'{card_id}-rate', style={ - 'fontSize': '16px', - 'color': '#6b7280' - }) - ]) - ], style={ - 'flex': '1', - 'minWidth': '250px', - 'background': 'white', - 'padding': '25px', - 'borderRadius': '10px', - 'boxShadow': '0 4px 15px rgba(0,0,0,0.1)', - 'borderLeft': f'5px solid {color}', - 'transition': 'transform 0.2s' - }) + """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')] + [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 + 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 @@ -215,9 +433,9 @@ def update_cgroup_selector(n): return options, self.selected_cgroup @self.app.callback( - Output('cgroup-selector', 'value', allow_duplicate=True), - [Input('cgroup-selector', 'value')], - prevent_initial_call=True + Output("cgroup-selector", "value", allow_duplicate=True), + [Input("cgroup-selector", "value")], + prevent_initial_call=True, ) def select_cgroup(value): if value: @@ -226,27 +444,36 @@ def select_cgroup(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'), + 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')] + [Input("interval-component", "n_intervals")], ) def update_dashboard(n): if self.selected_cgroup is None: - empty_fig = go.Figure() - empty_fig.update_layout( - title="Select a cgroup to begin monitoring", - template="plotly_white" + 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, ) - return ("Select a cgroup", "0", "", "0 B", "", "0 B", "", empty_fig, empty_fig, empty_fig) try: stats = self.collector.get_stats_for_cgroup(self.selected_cgroup) @@ -254,7 +481,7 @@ def update_dashboard(n): rates = self._calculate_rates(history) return ( - f"Monitoring: {stats.cgroup_name}", + 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)}", @@ -266,132 +493,173 @@ def update_dashboard(n): self._create_timeseries_graph(history), ) except Exception as e: - empty_fig = go.Figure() - empty_fig.update_layout(title=f"Error: {str(e)}", template="plotly_white") - return ("Error", "0", str(e), "0 B", "", "0 B", "", empty_fig, empty_fig, empty_fig) + 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.""" + """Create network I/O graph with futuristic styling.""" if len(history) < 2: - fig = go.Figure() - fig.update_layout(title="Collecting data...", template="plotly_white") - return fig + 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 + 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='#3b82f6', width=3), - fillcolor='rgba(59, 130, 246, 0.2)' + 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 + row=1, + col=1, ) fig.add_trace( go.Scatter( - x=times, y=tx_bytes, - mode='lines', - name='TX', - fill='tozeroy', - line=dict(color='#fbbf24', width=3), - fillcolor='rgba(251, 191, 36, 0.2)' + 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 + row=2, + col=1, ) - fig.update_xaxes(title_text="Time (samples)", row=2, col=1) - fig.update_yaxes(title_text="Bytes", row=1, col=1) - fig.update_yaxes(title_text="Bytes", 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_white", + template="plotly_dark", + paper_bgcolor="rgba(0,0,0,0)", + plot_bgcolor="#0a0e27", showlegend=False, - hovermode='x unified' + 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.""" + """Create file I/O graph with futuristic styling.""" if len(history) < 2: - fig = go.Figure() - fig.update_layout(title="Collecting data...", template="plotly_white") - return fig + 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 + 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='#ef4444', width=3), - fillcolor='rgba(239, 68, 68, 0.2)' + 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 + row=1, + col=1, ) fig.add_trace( go.Scatter( - x=times, y=write_bytes, - mode='lines', - name='Write', - fill='tozeroy', - line=dict(color='#8b5cf6', width=3), - fillcolor='rgba(139, 92, 246, 0.2)' + 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 + row=2, + col=1, ) - fig.update_xaxes(title_text="Time (samples)", row=2, col=1) - fig.update_yaxes(title_text="Bytes", row=1, col=1) - fig.update_yaxes(title_text="Bytes", 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_white", + template="plotly_dark", + paper_bgcolor="rgba(0,0,0,0)", + plot_bgcolor="#0a0e27", showlegend=False, - hovermode='x unified' + hovermode="x unified", + font=dict(family="Courier New, monospace", color="#8899ff"), ) return fig def _create_timeseries_graph(self, history): - """Create combined time series graph.""" + """Create combined time series graph with futuristic styling.""" if len(history) < 2: - fig = go.Figure() - fig.update_layout(title="Collecting data...", template="plotly_white") - return fig + 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)"), + 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}]] + specs=[ + [{"secondary_y": False}], + [{"secondary_y": True}], + [{"secondary_y": True}], + ], ) # Syscalls @@ -399,11 +667,12 @@ def _create_timeseries_graph(self, history): go.Scatter( x=times, y=[s.syscall_count for s in history], - mode='lines', - name='Syscalls', - line=dict(color='#8b5cf6', width=2) + mode="lines", + name="Syscalls", + line=dict(color="#00ff88", width=3, shape="spline"), ), - row=1, col=1 + row=1, + col=1, ) # Network @@ -411,22 +680,26 @@ def _create_timeseries_graph(self, history): go.Scatter( x=times, y=[s.rx_bytes for s in history], - mode='lines', - name='RX', - line=dict(color='#3b82f6', width=2) + mode="lines", + name="RX", + line=dict(color="#00d4ff", width=2, shape="spline"), ), - row=2, col=1, secondary_y=False + 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='#fbbf24', width=2) + mode="lines", + name="TX", + line=dict(color="#00ff88", width=2, shape="spline", dash="dot"), ), - row=2, col=1, secondary_y=True + row=2, + col=1, + secondary_y=True, ) # File I/O @@ -434,43 +707,59 @@ def _create_timeseries_graph(self, history): go.Scatter( x=times, y=[s.read_bytes for s in history], - mode='lines', - name='Read', - line=dict(color='#ef4444', width=2) + mode="lines", + name="Read", + line=dict(color="#ff0088", width=2, shape="spline"), ), - row=3, col=1, secondary_y=False + 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='#8b5cf6', width=2) + mode="lines", + name="Write", + line=dict(color="#8844ff", width=2, shape="spline", dash="dot"), ), - row=3, col=1, secondary_y=True + row=3, + col=1, + secondary_y=True, ) - fig.update_xaxes(title_text="Time (samples)", row=3, col=1) - fig.update_yaxes(title_text="Count", row=1, col=1) - fig.update_yaxes(title_text="RX Bytes", row=2, col=1, secondary_y=False) - fig.update_yaxes(title_text="TX Bytes", row=2, col=1, secondary_y=True) - fig.update_yaxes(title_text="Read Bytes", row=3, col=1, secondary_y=False) - fig.update_yaxes(title_text="Write Bytes", 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_white", - hovermode='x unified', + 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 - ) + x=1, + font=dict(color="#8899ff"), + ), + font=dict(family="Courier New, monospace", color="#8899ff"), ) return fig @@ -479,11 +768,11 @@ 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, + "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] @@ -494,11 +783,21 @@ def _calculate_rates(self, history): 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), + "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: @@ -516,15 +815,11 @@ def run(self): self._running = True # Suppress Werkzeug logging import logging - log = logging.getLogger('werkzeug') + + log = logging.getLogger("werkzeug") log.setLevel(logging.ERROR) - self.app.run( - debug=False, - host=self.host, - port=self.port, - use_reloader=False - ) + self.app.run(debug=False, host=self.host, port=self.port, use_reloader=False) def stop(self): """Stop the web dashboard."""