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
15 changes: 10 additions & 5 deletions src/vmstat_visualizer/cli/visualizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"-e",
"--output-extension",
default="png",
help="set the output extension for created files.",
help="set the output extension for created files. Use 'html' for interactive Plotly charts.",
)
def visualize(
file, output_prefix, output_extension
Expand All @@ -29,10 +29,15 @@ def visualize(
parser = Parser(file)
parser.parse()
print(f">>> Parsed {len(parser.timeseries)} time series entries.")
parser.plot(
output_file_prefix=f"{output_prefix}",
output_format=output_extension
)

if output_extension == "html":
from vmstat_visualizer.plotly_renderer import render_html
render_html(parser, output_file_prefix=output_prefix)
else:
parser.plot(
output_file_prefix=f"{output_prefix}",
output_format=output_extension
)


@click.command("compare", no_args_is_help=True)
Expand Down
126 changes: 126 additions & 0 deletions src/vmstat_visualizer/plotly_renderer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
"""Interactive HTML chart rendering using Plotly.

Requires: pip install plotly
(or: pip install vmstat-visualizer[interactive])
"""

import datetime
import time


def _check_plotly():
try:
import plotly # noqa: F401
return True
except ImportError:
return False


def render_html(parser, output_file_prefix='vmstat'):
"""Render all metric charts as a single interactive HTML file."""
if not _check_plotly():
raise ImportError(
"Plotly is required for HTML output. "
"Install it with: pip install plotly"
)

from plotly.subplots import make_subplots
import plotly.graph_objects as go

plot_metrics = parser.PlotMetrics(parser.timeseries)
t = plot_metrics.t

fig = make_subplots(
rows=5, cols=1,
subplot_titles=("CPU Usage (%)", "Memory Usage (KB)",
"System Load", "Swap Usage (KB)", "Block I/O"),
vertical_spacing=0.06,
shared_xaxes=True,
)

# CPU
cpu_series = [
("User", plot_metrics.user_cpu_percent),
("System", plot_metrics.system_cpu_percent),
("Idle", plot_metrics.idle_cpu_percent),
("Wait", plot_metrics.wait_cpu_percent),
("Steal", plot_metrics.steal_cpu_percent),
]
for name, data in cpu_series:
if any(v is not None for v in data):
fig.add_trace(go.Scatter(
x=t, y=[int(v) for v in data if v is not None],
mode='lines', name=f'CPU {name}',
legendgroup='cpu',
), row=1, col=1)

# Memory
mem_series = [
("Free", plot_metrics.free_memory_kb),
("Inactive", plot_metrics.inactive_memory_kb),
("Active", plot_metrics.active_memory_kb),
("Swapped", plot_metrics.swapped_memory_kb),
]
for name, data in mem_series:
if any(v is not None for v in data):
fig.add_trace(go.Scatter(
x=t, y=[int(v) for v in data if v is not None],
mode='lines', name=f'Mem {name}',
legendgroup='memory',
), row=2, col=1)

# System Load
load_series = [
("Run Queue", plot_metrics.run_queue),
("Blocked", plot_metrics.blocked_processes),
]
for name, data in load_series:
if any(v is not None for v in data):
fig.add_trace(go.Scatter(
x=t, y=[int(v) for v in data if v is not None],
mode='lines', name=name,
legendgroup='load',
), row=3, col=1)

# Swap
swap_series = [
("Swap In", plot_metrics.swap_in_kb),
("Swap Out", plot_metrics.swap_out_kb),
]
for name, data in swap_series:
if any(v is not None for v in data):
fig.add_trace(go.Scatter(
x=t, y=[int(v) for v in data if v is not None],
mode='lines', name=name,
legendgroup='swap',
), row=4, col=1)

# IO
io_series = [
("Blocks In", plot_metrics.blocks_in),
("Blocks Out", plot_metrics.blocks_out),
]
for name, data in io_series:
if any(v is not None for v in data):
fig.add_trace(go.Scatter(
x=t, y=[int(v) for v in data if v is not None],
mode='lines', name=name,
legendgroup='io',
), row=5, col=1)

tstart = parser.timeseries[0].time if parser.timeseries else 'N/A'
now_unix = int(time.mktime(
datetime.datetime.now().replace(second=0, microsecond=0).timetuple()
))

fig.update_layout(
height=1200,
title_text=f"vmstat — {parser.filename} (start: {tstart})",
showlegend=True,
hovermode='x unified',
)

filename = f"{output_file_prefix}_{now_unix}.html"
fig.write_html(filename)
print(f">>> Interactive HTML saved to {filename}")
return filename
149 changes: 99 additions & 50 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import os
import glob

import pytest
from unittest.mock import patch
from click.testing import CliRunner
from vmstat_visualizer.cli.__main__ import cli
from unittest.mock import patch

import vmstat_visualizer.cli.visualizer as viz
from vmstat_visualizer.cli.__main__ import cli


@pytest.fixture(autouse=True)
Expand All @@ -17,98 +18,146 @@ def _register_commands():
class TestCLIHelp:
def test_main_help(self):
runner = CliRunner()
result = runner.invoke(cli, ['--help'])
result = runner.invoke(cli, ["--help"])
assert result.exit_code == 0
assert 'visualize' in result.output
assert 'compare' in result.output
assert "visualize" in result.output
assert "compare" in result.output

def test_visualize_help(self):
runner = CliRunner()
result = runner.invoke(cli, ['visualize', '--help'])
result = runner.invoke(cli, ["visualize", "--help"])
assert result.exit_code == 0
assert '--output-prefix' in result.output
assert '--output-extension' in result.output
assert "--output-prefix" in result.output
assert "--output-extension" in result.output
assert "html" in result.output.lower()

def test_compare_help(self):
runner = CliRunner()
result = runner.invoke(cli, ['compare', '--help'])
result = runner.invoke(cli, ["compare", "--help"])
assert result.exit_code == 0
assert '--metric' in result.output
assert '--column' in result.output
assert "--metric" in result.output
assert "--column" in result.output


class TestVisualizeCommand:
@patch("vmstat_visualizer.parser.parser.check_vmstat_columns",
return_value=(True, True))
@patch(
"vmstat_visualizer.parser.parser.check_vmstat_columns",
return_value=(True, True),
)
def test_visualize_active_log(self, mock_check, active_log, tmp_path):
runner = CliRunner()
prefix = str(tmp_path / "viz")
result = runner.invoke(cli, [
'visualize', active_log, '-o', prefix, '-e', 'png'
])
result = runner.invoke(
cli, ["visualize", active_log, "-o", prefix, "-e", "png"]
)
assert result.exit_code == 0
assert '5 time series entries' in result.output
assert "5 time series entries" in result.output
pngs = glob.glob(str(tmp_path / "viz_*.png"))
assert len(pngs) == 5

@patch("vmstat_visualizer.parser.parser.check_vmstat_columns",
return_value=(True, True))
@patch(
"vmstat_visualizer.parser.parser.check_vmstat_columns",
return_value=(True, True),
)
def test_visualize_example_log(self, mock_check, example_log, tmp_path):
runner = CliRunner()
prefix = str(tmp_path / "ex")
result = runner.invoke(cli, [
'visualize', example_log, '-o', prefix
])
result = runner.invoke(cli, ["visualize", example_log, "-o", prefix])
assert result.exit_code == 0
assert '30 time series entries' in result.output
assert "30 time series entries" in result.output

def test_visualize_missing_file(self, tmp_path):
runner = CliRunner()
result = runner.invoke(cli, [
'visualize', str(tmp_path / 'nonexistent.log')
])
result = runner.invoke(
cli, ["visualize", str(tmp_path / "nonexistent.log")]
)
assert result.exit_code != 0

@patch("vmstat_visualizer.plotly_renderer.render_html")
@patch(
"vmstat_visualizer.parser.parser.check_vmstat_columns",
return_value=(True, True),
)
def test_visualize_html_calls_render_html(
self,
mock_check,
mock_render_html,
active_log,
tmp_path,
):
runner = CliRunner()
prefix = str(tmp_path / "htmlviz")
mock_render_html.return_value = str(tmp_path / "dummy_out.html")
result = runner.invoke(
cli,
["visualize", active_log, "-o", prefix, "-e", "html"],
)
assert result.exit_code == 0
mock_render_html.assert_called_once()
assert mock_render_html.call_args.args[0].filename == active_log
assert mock_render_html.call_args.kwargs.get(
"output_file_prefix",
) == prefix


class TestCompareCommand:
@patch("vmstat_visualizer.parser.parser.check_vmstat_columns",
return_value=(True, True))
@patch(
"vmstat_visualizer.parser.parser.check_vmstat_columns",
return_value=(True, True),
)
def test_compare_cpu(self, mock_check, active_log, tmp_path):
runner = CliRunner()
prefix = str(tmp_path / "cmp")
result = runner.invoke(cli, [
'compare', active_log, active_log,
'-m', 'cpu', '-o', prefix
])
result = runner.invoke(
cli,
["compare", active_log, active_log, "-m", "cpu", "-o", prefix],
)
assert result.exit_code == 0
assert 'Comparing' in result.output
assert "Comparing" in result.output

@patch("vmstat_visualizer.parser.parser.check_vmstat_columns",
return_value=(True, True))
@patch(
"vmstat_visualizer.parser.parser.check_vmstat_columns",
return_value=(True, True),
)
def test_compare_all(self, mock_check, active_log, tmp_path):
runner = CliRunner()
prefix = str(tmp_path / "all")
result = runner.invoke(cli, [
'compare', active_log, active_log,
'-m', 'all', '-o', prefix
])
result = runner.invoke(
cli,
["compare", active_log, active_log, "-m", "all", "-o", prefix],
)
assert result.exit_code == 0

@patch("vmstat_visualizer.parser.parser.check_vmstat_columns",
return_value=(True, True))
@patch(
"vmstat_visualizer.parser.parser.check_vmstat_columns",
return_value=(True, True),
)
def test_compare_with_column_filter(self, mock_check, active_log, tmp_path):
runner = CliRunner()
prefix = str(tmp_path / "filt")
result = runner.invoke(cli, [
'compare', active_log, active_log,
'-m', 'cpu', '-c', 'us', '-c', 'sy', '-o', prefix
])
result = runner.invoke(
cli,
[
"compare",
active_log,
active_log,
"-m",
"cpu",
"-c",
"us",
"-c",
"sy",
"-o",
prefix,
],
)
assert result.exit_code == 0
assert 'Filtering columns: us, sy' in result.output
assert "Filtering columns: us, sy" in result.output

def test_compare_invalid_metric(self, active_log):
runner = CliRunner()
result = runner.invoke(cli, [
'compare', active_log, active_log, '-m', 'bogus'
])
result = runner.invoke(
cli, ["compare", active_log, active_log, "-m", "bogus"]
)
assert result.exit_code != 0

Loading
Loading