Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions examples/example_standard.log
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
r b swpd free buff cache si so bi bo in cs us sy id wa st
3 0 0 523 64 995 0 0 4 1 71 111 0 0 99 0 0 2025-08-01 10:06:32
1 0 0 523 64 995 0 0 0 0 1815 3452 4 1 96 0 0 2025-08-01 10:06:33
1 0 0 523 64 995 0 0 0 16 2530 4711 4 3 93 0 0 2025-08-01 10:06:34
1 0 0 523 64 995 0 0 0 0 2061 3776 4 2 94 0 0 2025-08-01 10:06:35
1 0 0 523 64 995 0 0 0 0 2181 4079 4 3 94 0 0 2025-08-01 10:06:36
2 0 0 523 64 995 0 0 0 16 1906 3617 4 1 95 0 0 2025-08-01 10:06:37
1 0 0 523 64 995 0 0 0 0 2108 3965 2 2 95 0 0 2025-08-01 10:06:38
1 0 0 523 64 995 0 0 0 0 2670 4944 5 3 92 0 0 2025-08-01 10:06:39
1 0 0 523 64 995 0 0 0 176 2093 3917 4 3 94 0 0 2025-08-01 10:06:40
1 0 0 523 64 995 0 0 0 0 2359 4400 7 2 92 0 0 2025-08-01 10:06:41
5 changes: 3 additions & 2 deletions src/vmstat_visualizer/cli/visualizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,11 +64,12 @@ def visualize(
"free", "si", "so",
"us", "sy",
"id", "wa", "st",
"inact", "active"], case_sensitive=False),
"inact", "active",
"buff", "cache"], case_sensitive=False),
multiple=True,
help="""Specific column(s) to visualize. Can be specified multiple times. For each metric, the relevant columns are:\n
- cpu: us, sy, id, wa, st\n
- memory: swpd, free, inact, active\n
- memory: swpd, free, inact, active (vmstat -a) or buff, cache (standard vmstat)\n
- system_load: r, b, si, so\n
Example: -c us -c sy -c id
"""
Expand Down
115 changes: 82 additions & 33 deletions src/vmstat_visualizer/parser/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,15 @@
import time
import vmstat_visualizer.parser.timeseries as ts
import matplotlib.pyplot as plt
from vmstat_visualizer.checks.check import check_vmstat_columns
from matplotlib.dates import DateFormatter


KNOWN_VMSTAT_COLUMNS = {
'r', 'b', 'swpd', 'free', 'inact', 'active', 'buff', 'cache',
'si', 'so', 'bi', 'bo', 'in', 'cs', 'us', 'sy', 'id', 'wa', 'st', 'gu',
}


class Parser:
"""
Parser class for reading and processing data from a specified file.
Expand All @@ -32,45 +37,58 @@ def __init__(self, filename):
self.timeseries = []
self.figure_size = (10, 4)
self.enabled_columns = []
self.detected_headers = None
self.vmstat_format = None

@staticmethod
def _detect_headers(line):
"""Detect vmstat column headers from a header line.

Returns a list of column names if the line is a header, else None.
"""
parts = line.strip().split()
known_hits = sum(1 for p in parts if p in KNOWN_VMSTAT_COLUMNS)
if known_hits >= 5:
return parts
return None

def parse(self):
has_st, has_gu = check_vmstat_columns()
if has_st and has_gu:
print("System supports 'st' and 'gu' columns.")
else:
print(f"Missing columns: st={not has_st}, gu={not has_gu}")
time_regex = re.compile(r'\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$')
with open(self.filename, 'r') as file:
data = file.readlines()
for line in data:
timeseries_entry = ts.Timeseries()
# Regex to match a timestamp like '2025-07-31 23:52:52'
time_regex = re.compile(r'\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$')
if ("procs" in line or "memory" in line or "cpu" in line) or ("free" in line or "inact" in line or "active" in line):
continue
if not line.strip():
continue
continue

detected = self._detect_headers(line)
if detected is not None:
self.detected_headers = detected
if 'buff' in detected and 'cache' in detected:
self.vmstat_format = 'standard'
elif 'inact' in detected and 'active' in detected:
self.vmstat_format = 'active'
continue

if "procs" in line or "memory" in line or "---" in line:
continue

parts = line.strip().split()
if len(parts) < 3:
continue
# Skip lines where any part is not a digit
# if any(not part.isdigit() for part in parts):
# continue
# isDataLine = True
# for part in parts:
# if not part.replace('.', '', 1).replace('-', '', 1).isdigit():
# isDataLine = False
# break
# if not isDataLine:
# continue

data_points = []
if len(parts) >= 2 and time_regex.match(" ".join(parts[-2:])):
time_column = " ".join(parts[-2:])
data_points = [item.strip() for item in parts[:-2]] + [time_column]
else:
time_column = " ".join(parts[:2])
data_points = [time_column] + [item.strip() for item in parts[2:]]

headers_with_time = list(self.detected_headers) + ['time'] if self.detected_headers else None

timeseries_entry = ts.Timeseries()
timeseries_entry.add_data_point(data_points)
timeseries_entry.commit_raw_data()
timeseries_entry.commit_raw_data(headers=headers_with_time)
self.timeseries.append(timeseries_entry)

def plot(self, output_file_prefix='vmstat', output_format='png'):
Expand All @@ -90,10 +108,13 @@ def plot(self, output_file_prefix='vmstat', output_format='png'):
# 2. Memory Usage
self.plot_metric('memory', inactive_memory_kb=plot_metrics.inactive_memory_kb,
active_memory_kb=plot_metrics.active_memory_kb,
buff_memory_kb=plot_metrics.buff_memory_kb,
cache_memory_kb=plot_metrics.cache_memory_kb,
swapped_memory_kb=plot_metrics.swapped_memory_kb,
free_memory_kb=plot_metrics.free_memory_kb, t=plot_metrics.t,
tstart=tstart, output_file_prefix=output_file_prefix,
output_format=output_format, now_unix=now_unix)
output_format=output_format, now_unix=now_unix,
has_standard_memory=plot_metrics.has_standard_memory)
# 3. CPU Usage
self.plot_metric('cpu', user_cpu_percent=plot_metrics.user_cpu_percent,
system_cpu_percent=plot_metrics.system_cpu_percent,
Expand Down Expand Up @@ -223,7 +244,10 @@ def plot_metric(self, metric, **kwargs):
self._plot_memory(
kwargs.get('inactive_memory_kb'), kwargs.get('active_memory_kb'),
kwargs.get('swapped_memory_kb'), kwargs.get('free_memory_kb'), kwargs.get('t'),
kwargs.get('tstart'), kwargs.get('output_file_prefix'), kwargs.get('output_format'), kwargs.get('now_unix')
kwargs.get('tstart'), kwargs.get('output_file_prefix'), kwargs.get('output_format'), kwargs.get('now_unix'),
buff_memory_kb=kwargs.get('buff_memory_kb'),
cache_memory_kb=kwargs.get('cache_memory_kb'),
has_standard_memory=kwargs.get('has_standard_memory', False),
)
elif metric == 'system_load':
self._plot_system_load(
Expand Down Expand Up @@ -300,23 +324,34 @@ def _plot_system_load(self, run_queue, blocked_processes, t, tstart, output_file
plt.close()

def _plot_memory(self, inactive_memory_kb, active_memory_kb, swapped_memory_kb, free_memory_kb,
t, tstart, output_file_prefix, output_format, now_unix, comparison=False, filenames=None):
t, tstart, output_file_prefix, output_format, now_unix, comparison=False, filenames=None,
buff_memory_kb=None, cache_memory_kb=None, has_standard_memory=False):
import matplotlib.pyplot as plt
plt.figure(figsize=self.figure_size)

if not comparison:
# Convert memory values to integers for plotting
inactive_memory_kb_int = [int(x) for x in inactive_memory_kb]
active_memory_kb_int = [int(x) for x in active_memory_kb]
all_memory = []
swapped_memory_kb_int = [int(x) for x in swapped_memory_kb]
free_memory_kb_int = [int(x) for x in free_memory_kb]
plt.plot(t, inactive_memory_kb_int, label='Inactive Memory (KB)')
plt.plot(t, active_memory_kb_int, label='Active Memory (KB)')
plt.plot(t, swapped_memory_kb_int, label='Swapped Memory (KB)')
plt.plot(t, free_memory_kb_int, label='Free Memory (KB)')
all_memory += swapped_memory_kb_int + free_memory_kb_int

if has_standard_memory and buff_memory_kb and any(v is not None for v in buff_memory_kb):
buff_int = [int(x) for x in buff_memory_kb]
cache_int = [int(x) for x in cache_memory_kb]
plt.plot(t, buff_int, label='Buffers (KB)')
plt.plot(t, cache_int, label='Cache (KB)')
all_memory += buff_int + cache_int
else:
inactive_memory_kb_int = [int(x) for x in inactive_memory_kb]
active_memory_kb_int = [int(x) for x in active_memory_kb]
plt.plot(t, inactive_memory_kb_int, label='Inactive Memory (KB)')
plt.plot(t, active_memory_kb_int, label='Active Memory (KB)')
all_memory += inactive_memory_kb_int + active_memory_kb_int

plt.title('Memory Usage')
plt.xlabel('Seconds')
all_memory = inactive_memory_kb_int + active_memory_kb_int + swapped_memory_kb_int + free_memory_kb_int
else:
# Get filenames for labels
file1_label = filenames[0] if filenames else 'File 1'
Expand Down Expand Up @@ -569,6 +604,8 @@ def __init__(self, timeseries, relative_time=False):
self.free_memory_kb = []
self.inactive_memory_kb = []
self.active_memory_kb = []
self.buff_memory_kb = []
self.cache_memory_kb = []
self.swapped_memory_kb = []
self.user_cpu_percent = []
self.system_cpu_percent = []
Expand All @@ -585,13 +622,15 @@ def __init__(self, timeseries, relative_time=False):
t_dt = datetime.datetime.strptime(ts_entry.time, '%Y-%m-%d %H:%M:%S')
if not relative_time:
self.t.append(t_dt.strftime('%M:%S'))
else:
else:
self.t.append(next(self._counter))
self.run_queue.append(ts_entry.run_queue)
self.blocked_processes.append(ts_entry.blocked_processes)
self.free_memory_kb.append(ts_entry.free_memory_kb)
self.inactive_memory_kb.append(ts_entry.inactive_memory_kb)
self.active_memory_kb.append(ts_entry.active_memory_kb)
self.buff_memory_kb.append(ts_entry.buff_memory_kb)
self.cache_memory_kb.append(ts_entry.cache_memory_kb)
self.swapped_memory_kb.append(ts_entry.swapped_memory_kb)
self.user_cpu_percent.append(ts_entry.user_cpu_percent)
self.system_cpu_percent.append(ts_entry.system_cpu_percent)
Expand All @@ -604,6 +643,16 @@ def __init__(self, timeseries, relative_time=False):
self.blocks_in.append(ts_entry.blocks_in)
self.blocks_out.append(ts_entry.blocks_out)

@property
def has_standard_memory(self):
"""True when buff/cache columns are present (standard vmstat)."""
return any(v is not None for v in self.buff_memory_kb)

@property
def has_active_memory(self):
"""True when inact/active columns are present (vmstat -a)."""
return any(v is not None for v in self.inactive_memory_kb)

@staticmethod
def _counter_gen():
n = 0
Expand Down
94 changes: 46 additions & 48 deletions src/vmstat_visualizer/parser/timeseries.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,35 @@
successive measurements made over a time interval.
"""


COLUMN_TO_ATTR = {
'r': 'run_queue',
'b': 'blocked_processes',
'swpd': 'swapped_memory_kb',
'free': 'free_memory_kb',
'inact': 'inactive_memory_kb',
'active': 'active_memory_kb',
'buff': 'buff_memory_kb',
'cache': 'cache_memory_kb',
'si': 'swap_in_kb',
'so': 'swap_out_kb',
'bi': 'blocks_in',
'bo': 'blocks_out',
'in': 'interrupts',
'cs': 'context_switches',
'us': 'user_cpu_percent',
'sy': 'system_cpu_percent',
'id': 'idle_cpu_percent',
'wa': 'wait_cpu_percent',
'st': 'steal_cpu_percent',
'gu': 'guest_cpu_percent',
'time': 'time',
}


class Timeseries:
"""
Timeseries class for handling time series data.
"""Single vmstat sample with named attributes for each metric column."""

Attributes:
data (list): A list to store the time series data points.
Methods:
__init__():
Initializes the Timeseries with an empty data list.
add_data_point(point):
Adds a data point to the time series.
get_data():
Returns the list of data points in the time series.
"""
def __init__(self):
self.time = None
self.run_queue = None
Expand All @@ -26,6 +41,8 @@ def __init__(self):
self.free_memory_kb = None
self.inactive_memory_kb = None
self.active_memory_kb = None
self.buff_memory_kb = None
self.cache_memory_kb = None
self.swap_in_kb = None
self.swap_out_kb = None
self.blocks_in = None
Expand All @@ -50,6 +67,8 @@ def __repr__(self):
f"free_memory_kb={self.free_memory_kb}, "
f"inactive_memory_kb={self.inactive_memory_kb}, "
f"active_memory_kb={self.active_memory_kb}, "
f"buff_memory_kb={self.buff_memory_kb}, "
f"cache_memory_kb={self.cache_memory_kb}, "
f"swap_in_kb={self.swap_in_kb}, "
f"swap_out_kb={self.swap_out_kb}, "
f"blocks_in={self.blocks_in}, "
Expand All @@ -70,40 +89,19 @@ def add_data_point(self, point):

def get_data(self):
"""Returns the list of data points in the time series."""
return self.data
return self.raw_data

def commit_raw_data(self):
"""
Commits the raw_data points to the actual attributes of
the Timeseries instance. Maps raw_data keys to attribute
names and appends values to corresponding lists.
Assumes raw_data is a list of dicts, each representing a data point.
"""
headers = [
'r', 'b', 'swpd', 'free', 'inact', 'active', 'si', 'so',
'bi', 'bo', 'in', 'cs', 'us', 'sy', 'id', 'wa', 'st', 'gu', "time"
]
key_map = {
'r': 'run_queue',
'b': 'blocked_processes',
'swpd': 'swapped_memory_kb',
'free': 'free_memory_kb',
'inact': 'inactive_memory_kb',
'active': 'active_memory_kb',
'si': 'swap_in_kb',
'so': 'swap_out_kb',
'bi': 'blocks_in',
'bo': 'blocks_out',
'in': 'interrupts',
'cs': 'context_switches',
'us': 'user_cpu_percent',
'sy': 'system_cpu_percent',
'id': 'idle_cpu_percent',
'wa': 'wait_cpu_percent',
'st': 'steal_cpu_percent',
'gu': 'guest_cpu_percent',
"time": "time"
}
for idx, row in enumerate(self.raw_data):
attr = key_map[headers[idx]]
setattr(self, attr, row)
def commit_raw_data(self, headers=None):
"""Map raw_data values to named attributes using the provided header list."""
if headers is None:
headers = [
'r', 'b', 'swpd', 'free', 'inact', 'active', 'si', 'so',
'bi', 'bo', 'in', 'cs', 'us', 'sy', 'id', 'wa', 'st', 'gu', 'time',
]
for idx, value in enumerate(self.raw_data):
if idx >= len(headers):
break
col = headers[idx]
attr = COLUMN_TO_ATTR.get(col)
if attr:
setattr(self, attr, value)
7 changes: 7 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,10 @@ def example_log():
return os.path.join(
os.path.dirname(__file__), "..", "examples", "example.log"
)


@pytest.fixture
def example_standard_log():
return os.path.join(
os.path.dirname(__file__), "..", "examples", "example_standard.log"
)
Loading
Loading