fsmon/fsmon.py

757 lines
27 KiB
Python
Executable File

#!/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 <forza@tnonline.net>"
__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) + 5 # 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()