diff --git a/src/vmstat_visualizer/cli/visualizer.py b/src/vmstat_visualizer/cli/visualizer.py index 5beb14b..e5add94 100644 --- a/src/vmstat_visualizer/cli/visualizer.py +++ b/src/vmstat_visualizer/cli/visualizer.py @@ -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 @@ -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) diff --git a/src/vmstat_visualizer/plotly_renderer.py b/src/vmstat_visualizer/plotly_renderer.py new file mode 100644 index 0000000..dbe994a --- /dev/null +++ b/src/vmstat_visualizer/plotly_renderer.py @@ -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 diff --git a/tests/test_cli.py b/tests/test_cli.py index 073361e..2898a11 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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) @@ -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 + diff --git a/tests/test_plotly_renderer.py b/tests/test_plotly_renderer.py new file mode 100644 index 0000000..dfe421c --- /dev/null +++ b/tests/test_plotly_renderer.py @@ -0,0 +1,130 @@ +"""Unit tests for HTML / Plotly rendering (plotly_renderer).""" + +from __future__ import annotations + +import builtins +import datetime +import types +from unittest.mock import MagicMock, patch + +import pytest + +from vmstat_visualizer import plotly_renderer as pr +from vmstat_visualizer.parser.parser import Parser + + +@pytest.fixture +def parsed_active_parser(active_log): + with patch( + "vmstat_visualizer.parser.parser.check_vmstat_columns", + return_value=(True, True), + ): + parser = Parser(active_log) + parser.parse() + return parser + + +def test_check_plotly_true(monkeypatch): + stub = MagicMock() + real_import = builtins.__import__ + + def fake_import(name, globals=None, locals=None, fromlist=(), level=0): + if name == "plotly": + return stub + return real_import(name, globals, locals, fromlist, level) + + monkeypatch.setattr(builtins, "__import__", fake_import) + assert pr._check_plotly() is True + + +def test_check_plotly_false(monkeypatch): + real_import = builtins.__import__ + + def fake_import(name, globals=None, locals=None, fromlist=(), level=0): + if name == "plotly": + raise ImportError("No module named 'plotly'") + return real_import(name, globals, locals, fromlist, level) + + monkeypatch.setattr(builtins, "__import__", fake_import) + assert pr._check_plotly() is False + + +def test_render_html_raises_when_plotly_missing(): + parser = MagicMock() + parser.timeseries = [] + with patch.object(pr, "_check_plotly", return_value=False): + with pytest.raises( + ImportError, match="Plotly is required for HTML output" + ): + pr.render_html(parser) + + +def test_render_html_message_mentions_plotly_install_hint(): + with patch.object(pr, "_check_plotly", return_value=False): + with pytest.raises(ImportError, match=r"pip install plotly"): + pr.render_html(MagicMock()) + + +def _fake_plotly_modules(fake_fig): + make_subplots = MagicMock(return_value=fake_fig) + subplot_mod = types.ModuleType("plotly.subplots") + subplot_mod.make_subplots = make_subplots + + go_mod = types.ModuleType("plotly.graph_objects") + go_mod.Scatter = MagicMock(side_effect=lambda **kwargs: MagicMock(kwargs=kwargs)) + + plot_pkg = types.ModuleType("plotly") + + return { + "plotly": plot_pkg, + "plotly.subplots": subplot_mod, + "plotly.graph_objects": go_mod, + }, make_subplots, go_mod + + +def test_render_html_five_panel_subplots_traces_and_filename( + parsed_active_parser, + monkeypatch, + tmp_path, +): + """Verify 5 subplot rows, subplot titles for each panel, Scatter usage, HTML path.""" + + fake_fig = MagicMock() + modules, make_subplots, go_mod = _fake_plotly_modules(fake_fig) + monkeypatch.chdir(tmp_path) + monkeypatch.setitem(__import__("sys").modules, "plotly", modules["plotly"]) + monkeypatch.setitem( + __import__("sys").modules, "plotly.subplots", modules["plotly.subplots"], + ) + monkeypatch.setitem( + __import__("sys").modules, + "plotly.graph_objects", + modules["plotly.graph_objects"], + ) + + fixed_now = datetime.datetime(2026, 5, 16, 12, 34, 56) + with patch.object(pr.datetime, "datetime") as dt_proxy: + dt_proxy.now.return_value = fixed_now + with patch.object(pr.time, "mktime", return_value=1_738_934_940.0): + out_name = pr.render_html( + parsed_active_parser, output_file_prefix="vmstatviz", + ) + + make_subplots.assert_called_once() + ms_kwargs = make_subplots.call_args.kwargs + assert ms_kwargs["rows"] == 5 + assert ms_kwargs["cols"] == 1 + titles = ms_kwargs["subplot_titles"] + assert titles[0] == "CPU Usage (%)" + assert titles[1] == "Memory Usage (KB)" + assert titles[2] == "System Load" + assert titles[3] == "Swap Usage (KB)" + assert titles[4] == "Block I/O" + + assert fake_fig.add_trace.call_count == go_mod.Scatter.call_count + + unix_rounded = int(1_738_934_940.0) + expected_base = tmp_path / f"vmstatviz_{unix_rounded}.html" + assert out_name.endswith(".html") + assert "vmstatviz" in out_name + fake_fig.write_html.assert_called_once_with(str(expected_base.name))