diff --git a/fsmon.py b/fsmon.py new file mode 100755 index 0000000..85d6630 --- /dev/null +++ b/fsmon.py @@ -0,0 +1,756 @@ +#!/usr/bin/env python3 + +# SPDX-License-Identifier: GPL-3.0-or-later + +""" +fsmon: A Real-Time Btrfs I/O Monitor +------------------------------------ + +`fsmon` monitors the I/O activity of Btrfs filesystems in real time, +displaying bandwidth and IOPS statistics for detected filesystems +and their devices. + +It combines I/O statistics from all member devices of each Btrfs +filesystem, providing a unified view of the filesystem's overall +activity. + +Features: +--------- +- Real-time monitoring of read/write bandwidth and IOPS. +- Visual charts for I/O statistics. +- Dynamic terminal size handling. +- Lightweight and efficient, leveraging sysfs for minimal overhead. + +Requirements: +------------- +- Python 3.6 or higher. +- Btrfs filesystems mounted on the system. +- Sufficient permissions to access `/sys/fs/btrfs` and related + devices in `/sys/block`. + +Usage: +------ + `-h` or `--help`: Display usage information. + `-v` or `--version`: Display version. + +License: +-------- +This program is licensed under the GNU General Public License v3.0 +or later. +""" + +__description__ = "A real-time Btrfs I/O monitor for tracking filesystem activity." +__author__ = "Forza " +__license__ = "GPL-3.0-or-later" +__version__ = "0.1.0" + +import argparse +import curses +import os +import sys +import time + +from collections import defaultdict + + +####### Configuration Options ######## + +# Colors +USE_TERM_COLORS = True # Use terminal's default colour +COLOR_CHART_BW_READ = 3 # Green +COLOR_CHART_BW_WRITE = 2 # Red +COLOR_CHART_IOPS_READ = 7 # Cyan +COLOR_CHART_IOPS_WRITE = 6 # Magenta +COLOR_SELECTED = 7 # Cyan +COLOR_COL_HEADER = 4 # Yellow +COLOR_HEADER = 8 # Terminal's default colour +COLOR_FOOTER = 8 # Terminal's default colour +COLOR_HLINE = 4 # Yellow + +# Column widths (label, read, write, iops) +COL_LABEL = 12 +COL_READ_BW = 10 +COL_WRITE_BW = 10 +COL_IOPS = 11 + +# Minimum allowed terminal size +MIN_WIDTH = COL_LABEL + COL_READ_BW + COL_WRITE_BW + COL_IOPS + +# Chart size +CHART_HEIGHT = 11 +CHART_WIDTH = 61 + +# Btrfs labels and member devices are read from sysfs +SYSFS_PATH = "/sys/fs/btrfs" + +####### Configuration End ######## + +def init_colors(): + """ + Initialise colors + """ + curses.start_color() + + if USE_TERM_COLORS: + curses.use_default_colors() + default_bg = -1 # Terminal's default background + else: + default_bg = curses.COLOR_BLACK + + # Initialize all 8 default colour pairs + curses.init_pair(1, curses.COLOR_BLACK, default_bg) # Black + curses.init_pair(2, curses.COLOR_RED, default_bg) # Red + curses.init_pair(3, curses.COLOR_GREEN, default_bg) # Green + curses.init_pair(4, curses.COLOR_YELLOW, default_bg) # Yellow + curses.init_pair(5, curses.COLOR_BLUE, default_bg) # Blue + curses.init_pair(6, curses.COLOR_MAGENTA, default_bg) # Magenta + curses.init_pair(7, curses.COLOR_CYAN, default_bg) # Cyan + curses.init_pair(8, curses.COLOR_WHITE, default_bg) # White + + +def parse_arguments(): + """ + Parse command-line arguments for fsmon. + """ + parser = argparse.ArgumentParser( + description=__description__ + ) + parser.add_argument( + "-v", "--version", action="version", version=f"fsmon {__version__}", + help="Show program version and exit." + ) + return parser.parse_args() + + +def get_btrfs_filesystems(): + """ + Fetch all Btrfs filesystems and their devices. + """ + btrfs_fs = {} # Dictionary: UUID -> list of device paths + labels = {} # Dictionary: UUID -> label (or UUID if no label) + + # UUIDs are directory entries in SYSFS_PATH + try: + uuids = os.listdir(SYSFS_PATH) + except FileNotFoundError: + print(f"Error: '{SYSFS_PATH}' does not exist. Ensure sysfs is mounted.", file=sys.stderr) + exit(1) + except PermissionError: + print(f"Error: Permission denied when accessing '{SYSFS_PATH}'", file=sys.stderr) + exit(1) + except OSError as e: + print(f"Error: Unable to access '{SYSFS_PATH}': {e}", file=sys.stderr) + exit(1) + + # Process each UUID + for uuid in uuids: + devices_path = os.path.join(SYSFS_PATH, uuid, "devices") + label_path = os.path.join(SYSFS_PATH, uuid, "label") + + # Get filesystem label and member devices + if os.path.isdir(devices_path) and os.path.exists(label_path): + # Read the devices directory + try: + devices = [ + os.path.join(devices_path, d, "stat") + for d in os.listdir(devices_path) + ] + btrfs_fs[uuid] = devices + except PermissionError: + print(f"Error: Permission denied when accessing '{devices_path}'", file=sys.stderr) + continue + except OSError as e: + print(f"Error: Unable to read devices from '{devices_path}': {e}", file=sys.stderr) + continue + + # Get filesystem label + label = "" + try: + with open(label_path, "r") as file: + label = file.read().strip() + # Decode label as UTF-8 with error handling + label = label.encode("utf-8").decode("utf-8", errors="replace") + except PermissionError: + print(f"Error: Permission denied when reading '{label_path}'", file=sys.stderr) + continue + except OSError as e: + print(f"Error: Unable to read '{label_path}': {e}", file=sys.stderr) + continue + + # Use UUID if no label is available + labels[uuid] = label if label else uuid + + # Check if any filesystems were found + if not btrfs_fs: + print(f"Error: No Btrfs filesystems found in '{SYSFS_PATH}'") + exit(1) + + # Return the list of found filesystems + return btrfs_fs, labels + +def get_device_stats(btrfs_fs): + """ + Calculate aggregated statistics for each Btrfs filesystem. + """ + fs_stats = {} # Dictionary: UUID -> aggregated statistics + sector_size = 512 + # Linux device/stat file always use 512-byte sector size. + # Reference: https://www.kernel.org/doc/Documentation/block/stat.txt + + # Iterate through each UUID and its associated devices + for uuid, devices in btrfs_fs.items(): + # Initialise counters for aggregated statistics + read_bytes = 0 + write_bytes = 0 + read_ops = 0 + write_ops = 0 + + # Iterate through each device stat file + for dev_stat_path in devices: + try: + # Read the stat file and parse its fields + with open(dev_stat_path, "r") as file: + stats = file.read().split() + # Ensure the stat file contains the expected fields + if len(stats) < 7: + print(f"Warning: Stat file '{dev_stat_path}' is malformed or incomplete.", file=sys.stderr) + continue + # Update operation counts and byte counters + read_ops += int(stats[0]) # Reads completed + write_ops += int(stats[4]) # Writes completed + read_sectors = int(stats[2]) # Sectors read + write_sectors = int(stats[6]) # Sectors written + read_bytes += read_sectors * sector_size + write_bytes += write_sectors * sector_size + except FileNotFoundError: + # Skip devices where the stat file is missing + print(f"Warning: Stat file not found for device at '{dev_stat_path}'", file=sys.stderr) + continue + except PermissionError: + # Skip devices we cannot read + print(f"Error: Permission denied when reading '{dev_stat_path}'", file=sys.stderr) + continue + except ValueError: + # Skip devices where the stat file contains invalid data + print(f"Warning: Invalid data in stat file '{dev_stat_path}'", file=sys.stderr) + continue + except OSError as e: + # Abort on other errors + print(f"Error: Unable to read '{dev_stat_path}': {e}", file=sys.stderr) + exit(1) + + # Store aggregated statistics for the current UUID + fs_stats[uuid] = { + "read_bytes": read_bytes, + "write_bytes": write_bytes, + "read_ops": read_ops, + "write_ops": write_ops + } + return fs_stats + + +def format_iec(value): + """ + Convert numbers to IEC units: B, KiB, MiB, GiB... + """ + units = ["B", "KiB", "MiB", "GiB", "TiB"] + for unit in units: + if value < 1024: + return f"{value:.1f} {unit}" + value /= 1024 + + return f"{value:.1f} PiB" + + +def format_base10(value): + """ + Convert numbers to base-10 units: k, M, G... + """ + prefixes = { + 'P': 1e15, # peta + 'T': 1e12, # tera + 'G': 1e9, # giga + 'M': 1e6, # mega + 'k': 1e3 # kilo + } + + v = float(value) + for suffix, threshold in prefixes.items(): + if v >= threshold: + return f"{v / threshold:.1f}{suffix}" + + return f"{v:.1f}" + + +def display_chart(stdscr, title, data_list, row_start, col_start, CHART_WIDTH, color, use_iops=False): + """ + Renders a single chart. + """ + # Store the max value + max_val = max(max(data_list), 1) + + # Print chart title + stdscr.addstr(row_start, col_start + 9 + (CHART_WIDTH // 2) - (len(title) //2), title) + + # Number of diagram rows + y_axis_rows = CHART_HEIGHT - 2 + for i in range(y_axis_rows-1): + # Set row max value + threshold = max_val * ((y_axis_rows-1) - i) / (y_axis_rows-1) + # Construct a complete row + chart_row = "".join( + "|" if val > threshold else " " if i == (y_axis_rows-2) else "." + for val in data_list[-CHART_WIDTH:] + ).ljust(CHART_WIDTH-1) + + if use_iops: + y_axis_label = format_base10(threshold) + else: + y_axis_label = format_iec(threshold) + + # Draw Y-axis label + stdscr.addstr(row_start + 1 + i, col_start, f"{y_axis_label:>10} ") + + # Draw chart rows + if i == (y_axis_rows-2): + # Underline the bottom row + stdscr.addstr(row_start + 1 + i, col_start + 11, chart_row, curses.A_UNDERLINE | curses.color_pair(color) ) + else: + stdscr.addstr(row_start + 1 + i, col_start + 11, chart_row, curses.color_pair(color)) + # Draw X-axis scale + x_axis = " ".join(f"{x:>4}" for x in range(CHART_WIDTH-1, -1, -5)) + stdscr.addstr(row_start + y_axis_rows, col_start + 8, x_axis) + + +def display_ui(stdscr, btrfs_fs, fs_labels): + """ + Main UI. + """ + curses.curs_set(0) + stdscr.nodelay(1) + init_colors() + + selected_idx = 0 + use_labels = True # Show labels as default + show_iops_charts = False # Toggles display of iops charts + + prev_stats = {} + first_skipped = set() + + history = defaultdict( + lambda: { + "read_bw": [0] * CHART_WIDTH, + "write_bw": [0] * CHART_WIDTH, + "read_iops": [0] * CHART_WIDTH, + "write_iops": [0] * CHART_WIDTH, + } + ) + +### +# Begin main UI loop +### + try: + # Main UI loop + while True: + # Check for keyboard input + key = stdscr.getch() + if key == curses.KEY_UP: + selected_idx = max(0, selected_idx - 1) + elif key == curses.KEY_DOWN: + selected_idx += 1 + deltas = {} + elif key in [ord("q"), ord("Q")]: + break + elif key in [ord("l"), ord("L")]: + use_labels = not use_labels + elif key in [ord("i"), ord("I")]: + show_iops_charts = not show_iops_charts + + # Get the current statistics for all Btrfs filesystems + current_stats = get_device_stats(btrfs_fs) + + # Calculate deltas + deltas = {} + for uuid, stats in current_stats.items(): + # Get the previous stats for this UUID; default to 0 for all fields if not found + prev = prev_stats.get( + uuid, + {"read_bytes": 0, "write_bytes": 0, "read_ops": 0, "write_ops": 0}, + ) + # Skip the first iteration for this UUID to avoid incorrect deltas + # This ensures we have a baseline before calculating differences + if uuid not in first_skipped: + first_skipped.add(uuid) + prev_stats[uuid] = stats + continue + + # Calculate the delta between current and previous stats + deltas[uuid] = { + "read_bw": stats["read_bytes"] - prev["read_bytes"], + "write_bw": stats["write_bytes"] - prev["write_bytes"], + "read_iops": stats["read_ops"] - prev["read_ops"], + "write_iops": stats["write_ops"] - prev["write_ops"], + } + # Update the previous stats to the current stats for the next iteration + prev_stats = current_stats + + # List of UUIDs + uuids = list(btrfs_fs.keys()) + + # Clear screen + stdscr.erase() + + # Prevent drawing UI if terminal is too small + height, width = stdscr.getmaxyx() + MIN_HEIGHT = len(uuids) + 4 # Enough to show list without charts + if (height < MIN_HEIGHT) or (width < MIN_WIDTH): + stdscr.addstr( + 0, 0, f"Terminal too small (need ≥ {MIN_HEIGHT}x{MIN_WIDTH})." + ) + time.sleep(1) + continue + + # Print header and footer + stdscr.attron(curses.color_pair(COLOR_HEADER)) + stdscr.addstr(0, 0, "Btrfs Filesystem I/O Monitor") + stdscr.addstr(0, width - 6 - len(__version__), f"fsmon {__version__}") + stdscr.attroff(curses.color_pair(COLOR_HEADER)) + stdscr.attron(curses.color_pair(COLOR_FOOTER)) + stdscr.addstr(height - 1, 0, "Keys: q=quit, L=labels, i=iops") + stdscr.addstr(height - 1, width - len(f"{width}x{height}") - 1, f"{width}x{height}") + stdscr.attroff(curses.color_pair(COLOR_FOOTER)) + + # Determine the maximum label length + max_label_len = max( + len(fs_labels[u]) + for u in uuids + ) + + # Length of fixed columns combined + fixed_cols = COL_READ_BW + COL_WRITE_BW + COL_IOPS + + # Calculate label column length and set + # to COL_LABEL length if label is too short + label_col = min( + max_label_len, + max( + COL_LABEL, + width - fixed_cols - 2 + ) + ) + # Define header row text + col_header = ( + f"{'Filesystem':<{label_col}}" + f"{'Read/s':>{COL_READ_BW}}" + f"{'Write/s':>{COL_WRITE_BW}}" + f"{'IOPS(R/W)':>{COL_IOPS}}" + ) + # Write out header text + stdscr.attron(curses.color_pair(COLOR_COL_HEADER)) + stdscr.addstr(2, 1, col_header) + stdscr.attroff(curses.color_pair(COLOR_COL_HEADER)) + + # Number of items in the filsystem list + #list_uuids = uuids[:max_list_area] + list_uuids = uuids + + # clamp selection + if selected_idx >= len(list_uuids): + selected_idx = max(0, len(list_uuids) - 1) + + # Loop through each filesystem and print out their stats in columns + for idx, uuid in enumerate(list_uuids): + label = fs_labels[uuid] if use_labels else uuid + if len(label) > label_col: + label_display = label[: label_col - 3] + "..." + else: + label_display = label + + if uuid in deltas: + read_bw = deltas[uuid]["read_bw"] + write_bw = deltas[uuid]["write_bw"] + read_iops = deltas[uuid]["read_iops"] + write_iops = deltas[uuid]["write_iops"] + # No stats available, initialise to 0 + else: + read_bw = write_bw = read_iops = write_iops = 0 + + # Update history for all filesystems: + h = history[uuid] + h["read_bw"].append(read_bw) + h["write_bw"].append(write_bw) + h["read_iops"].append(read_iops) + h["write_iops"].append(write_iops) + for k in ["read_bw", "write_bw", "read_iops", "write_iops"]: + h[k] = h[k][-CHART_WIDTH:] + + iops_str = f"{read_iops}/{write_iops}" + line = ( + f"{label_display:<{label_col}}" + f"{format_iec(read_bw):>{COL_READ_BW}}" + f"{format_iec(write_bw):>{COL_WRITE_BW}}" + f"{iops_str:>{COL_IOPS}}" + ) + fs_list_ypos = 3 + idx # Start filesystem list at 3rd row + if idx == selected_idx: + stdscr.attron(curses.color_pair(COLOR_SELECTED)) + stdscr.addstr(fs_list_ypos, 0, ">" + line) + stdscr.attroff(curses.color_pair(COLOR_SELECTED)) + else: + stdscr.addstr(fs_list_ypos, 0, " " + line) + + # Draw a horizontal line + stdscr.attron(curses.color_pair(COLOR_HLINE)) + stdscr.addstr(3 + len(list_uuids), 0, "—" * width) + stdscr.attroff(curses.color_pair(COLOR_HLINE)) + + # Chart details + chart_start = 4 + len(list_uuids) + available_for_charts_w = width - 1 + available_for_charts_h = height - 1 - chart_start + + # Selected filesystem's stats should be rendered as charts + selected_fs = list_uuids[selected_idx] + + # -- Display logic: + # 1) If IOPS is *not* toggled + # 2) If IOPS is toggled + if not show_iops_charts: + # Show BW charts side-by-side + if available_for_charts_w >= ((CHART_WIDTH + 11) * 2) and available_for_charts_h >= CHART_HEIGHT: + display_chart( + stdscr, + "Read bytes/sec", + history[selected_fs]["read_bw"], + chart_start, + 0, + CHART_WIDTH, + COLOR_CHART_BW_READ, + use_iops=False, + ) + display_chart( + stdscr, + "Write bytes/sec", + history[selected_fs]["write_bw"], + chart_start, + CHART_WIDTH + 12, + CHART_WIDTH, + COLOR_CHART_BW_WRITE, + use_iops=False, + ) + # Show BW charts stacked + elif available_for_charts_w >= (CHART_WIDTH + 11) and available_for_charts_h >= (CHART_HEIGHT * 2): + display_chart( + stdscr, + "Read bytes/sec", + history[selected_fs]["read_bw"], + chart_start, + 0, + CHART_WIDTH, + COLOR_CHART_BW_READ, + use_iops=False, + ) + display_chart( + stdscr, + "Write bytes/sec", + history[selected_fs]["write_bw"], + chart_start + CHART_HEIGHT, + 0, + CHART_WIDTH, + COLOR_CHART_BW_WRITE, + use_iops=False, + ) + # Show BW and/or IOPS charts + else: + # Show BW + IOPS charts side-by-side + if available_for_charts_w >= ((CHART_WIDTH + 11) * 4): + display_chart( + stdscr, + "Read bytes/sec", + history[selected_fs]["read_bw"], + chart_start, + 0, + CHART_WIDTH, + COLOR_CHART_BW_READ, + use_iops=False, + ) + display_chart( + stdscr, + "Write bytes/sec", + history[selected_fs]["write_bw"], + chart_start, + (CHART_WIDTH + 12), + CHART_WIDTH, + COLOR_CHART_BW_WRITE, + use_iops=False, + ) + display_chart( + stdscr, + "Read IOPS", + history[selected_fs]["read_iops"], + chart_start, + (CHART_WIDTH + 12) * 2, + CHART_WIDTH, + COLOR_CHART_IOPS_READ, + use_iops=True, + ) + display_chart( + stdscr, + "Write IOPS", + history[selected_fs]["write_iops"], + chart_start, + (CHART_WIDTH + 12) *3, + CHART_WIDTH, + COLOR_CHART_IOPS_WRITE, + use_iops=True, + ) + # Show 2x2 side-by-side + elif available_for_charts_w >= ((CHART_WIDTH + 11) * 2) and available_for_charts_h >= (CHART_HEIGHT * 2): + display_chart( + stdscr, + "Read bytes/sec", + history[selected_fs]["read_bw"], + chart_start, + 0, + CHART_WIDTH, + COLOR_CHART_BW_READ, + use_iops=False, + ) + display_chart( + stdscr, + "Write bytes/sec", + history[selected_fs]["write_bw"], + chart_start + CHART_HEIGHT, + 0, + CHART_WIDTH, + COLOR_CHART_BW_WRITE, + use_iops=False, + ) + display_chart( + stdscr, + "Read IOPS", + history[selected_fs]["read_iops"], + chart_start, + CHART_WIDTH + 12, + CHART_WIDTH, + COLOR_CHART_IOPS_READ, + use_iops=True, + ) + display_chart( + stdscr, + "Write IOPS", + history[selected_fs]["write_iops"], + chart_start + CHART_HEIGHT, + CHART_WIDTH + 12, + CHART_WIDTH, + COLOR_CHART_IOPS_WRITE, + use_iops=True, + ) + # Show 4x1 charts stacked. + elif available_for_charts_w >= (CHART_WIDTH + 11) and available_for_charts_h >= (CHART_HEIGHT * 4): + display_chart( + stdscr, + "Read bytes/sec", + history[selected_fs]["read_bw"], + chart_start, + 0, + CHART_WIDTH, + COLOR_CHART_BW_READ, + use_iops=False, + ) + display_chart( + stdscr, + "Write bytes/sec", + history[selected_fs]["write_bw"], + chart_start + CHART_HEIGHT, + 0, + CHART_WIDTH, + COLOR_CHART_BW_WRITE, + use_iops=False, + ) + display_chart( + stdscr, + "Read IOPS", + history[selected_fs]["read_iops"], + chart_start + CHART_HEIGHT * 2, + 0, + CHART_WIDTH, + COLOR_CHART_IOPS_READ, + use_iops=True, + ) + display_chart( + stdscr, + "Write IOPS", + history[selected_fs]["write_iops"], + chart_start + CHART_HEIGHT * 3, + 0, + CHART_WIDTH, + COLOR_CHART_IOPS_WRITE, + use_iops=True, + ) + # Show 2x1 charts stacked + elif available_for_charts_w >= (CHART_WIDTH + 11) and available_for_charts_h >= (CHART_HEIGHT * 2): + display_chart( + stdscr, + "Read IOPS", + history[selected_fs]["read_iops"], + chart_start, + 0, + CHART_WIDTH, + COLOR_CHART_IOPS_READ, + use_iops=True, + ) + display_chart( + stdscr, + "Write IOPS", + history[selected_fs]["write_iops"], + chart_start + CHART_HEIGHT, + 0, + CHART_WIDTH, + COLOR_CHART_IOPS_WRITE, + use_iops=True, + ) + # Show 1x2 charts side-by-side + elif available_for_charts_h >= CHART_HEIGHT and available_for_charts_w >= (CHART_WIDTH * 2): + display_chart( + stdscr, + "Read IOPS", + history[selected_fs]["read_iops"], + chart_start, + 0, + CHART_WIDTH, + COLOR_CHART_IOPS_READ, + use_iops=True, + ) + display_chart( + stdscr, + "Write IOPS", + history[selected_fs]["write_iops"], + chart_start, + CHART_WIDTH + 11, + CHART_WIDTH, + COLOR_CHART_IOPS_WRITE, + use_iops=True, + ) + stdscr.refresh() + time.sleep(1) + except KeyboardInterrupt: + # Clear screen and exit on Ctrl-C + stdscr.clear() +### +# End main UI loop +### + +def main(): + # Parse CLI arguments + args = parse_arguments() + + # Get a list of Btrfs filesystems + btrfs_fs, fs_labels = get_btrfs_filesystems() + + # Display main UI + curses.wrapper(lambda stdscr: display_ui(stdscr, btrfs_fs, fs_labels)) + + +if __name__ == "__main__": + main()