diff --git a/src/vmstat_visualizer/cli/visualizer.py b/src/vmstat_visualizer/cli/visualizer.py index 5beb14b..58bce0f 100644 --- a/src/vmstat_visualizer/cli/visualizer.py +++ b/src/vmstat_visualizer/cli/visualizer.py @@ -4,6 +4,7 @@ """ import click from vmstat_visualizer.parser.parser import Parser +from vmstat_visualizer.summary import compute_summary, format_summary_text, format_summary_json @click.command("visualize", no_args_is_help=True) @click.argument("file", default="vmstat.log", type=click.Path(exists=True)) @@ -19,8 +20,20 @@ default="png", help="set the output extension for created files.", ) +@click.option( + "--summary", + is_flag=True, + default=False, + help="Print summary statistics (min/max/mean/p95/p99) to stdout.", +) +@click.option( + "--summary-json", + is_flag=True, + default=False, + help="Print summary statistics as JSON to stdout.", +) def visualize( - file, output_prefix, output_extension + file, output_prefix, output_extension, summary, summary_json ): """ Visualize a file or files in a directory @@ -29,10 +42,20 @@ 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 summary or summary_json: + plot_metrics = parser.PlotMetrics(parser.timeseries) + stats = compute_summary(plot_metrics) + if summary_json: + print(format_summary_json(stats)) + else: + print(format_summary_text(stats)) + + if not summary_json: + parser.plot( + output_file_prefix=f"{output_prefix}", + output_format=output_extension + ) @click.command("compare", no_args_is_help=True) @@ -64,11 +87,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 """ diff --git a/src/vmstat_visualizer/summary.py b/src/vmstat_visualizer/summary.py new file mode 100644 index 0000000..e296775 --- /dev/null +++ b/src/vmstat_visualizer/summary.py @@ -0,0 +1,139 @@ +"""Summary statistics for parsed vmstat data.""" + +import json +import math + + +def _safe_ints(values): + """Convert a list of string values to integers, skipping None.""" + return [int(v) for v in values if v is not None] + + +def _percentile(sorted_data, p): + """Calculate the p-th percentile from pre-sorted data.""" + if not sorted_data: + return 0 + k = (len(sorted_data) - 1) * (p / 100) + f = math.floor(k) + c = math.ceil(k) + if f == c: + return sorted_data[int(k)] + return sorted_data[f] * (c - k) + sorted_data[c] * (k - f) + + +def _stats_for(values): + """Return min/max/mean/p95/p99 dict for a list of string values.""" + nums = _safe_ints(values) + if not nums: + return None + s = sorted(nums) + return { + "min": s[0], + "max": s[-1], + "mean": round(sum(s) / len(s), 2), + "p95": round(_percentile(s, 95), 2), + "p99": round(_percentile(s, 99), 2), + } + + +def _duration_above(values, threshold): + """Count how many consecutive seconds values stayed above threshold.""" + nums = _safe_ints(values) + return sum(1 for v in nums if v > threshold) + + +def compute_summary(plot_metrics): + """Build a summary dict from a PlotMetrics instance.""" + summary = {} + + cpu_metrics = { + "user_cpu_percent": plot_metrics.user_cpu_percent, + "system_cpu_percent": plot_metrics.system_cpu_percent, + "idle_cpu_percent": plot_metrics.idle_cpu_percent, + "wait_cpu_percent": plot_metrics.wait_cpu_percent, + "steal_cpu_percent": plot_metrics.steal_cpu_percent, + } + summary["cpu"] = {k: _stats_for(v) for k, v in cpu_metrics.items() if _stats_for(v)} + + mem_metrics = { + "free_memory_kb": plot_metrics.free_memory_kb, + "swapped_memory_kb": plot_metrics.swapped_memory_kb, + "inactive_memory_kb": plot_metrics.inactive_memory_kb, + "active_memory_kb": plot_metrics.active_memory_kb, + } + summary["memory"] = {k: _stats_for(v) for k, v in mem_metrics.items() if _stats_for(v)} + + io_metrics = { + "blocks_in": plot_metrics.blocks_in, + "blocks_out": plot_metrics.blocks_out, + } + summary["io"] = {k: _stats_for(v) for k, v in io_metrics.items() if _stats_for(v)} + + swap_metrics = { + "swap_in_kb": plot_metrics.swap_in_kb, + "swap_out_kb": plot_metrics.swap_out_kb, + } + summary["swap"] = {k: _stats_for(v) for k, v in swap_metrics.items() if _stats_for(v)} + + load_metrics = { + "run_queue": plot_metrics.run_queue, + "blocked_processes": plot_metrics.blocked_processes, + } + summary["system_load"] = {k: _stats_for(v) for k, v in load_metrics.items() if _stats_for(v)} + + total_cpu = _safe_ints(plot_metrics.user_cpu_percent) + _safe_ints(plot_metrics.system_cpu_percent) + user_vals = _safe_ints(plot_metrics.user_cpu_percent) + sys_vals = _safe_ints(plot_metrics.system_cpu_percent) + if user_vals and sys_vals: + combined = [u + s for u, s in zip(user_vals, sys_vals)] + summary["alerts"] = { + "high_cpu_seconds": sum(1 for v in combined if v > 80), + "high_wait_seconds": _duration_above(plot_metrics.wait_cpu_percent, 20), + "high_swap_seconds": _duration_above(plot_metrics.swapped_memory_kb, 0), + } + + summary["total_samples"] = len(plot_metrics.t) + + return summary + + +def format_summary_text(summary): + """Format summary dict as a human-readable text table.""" + lines = [] + lines.append(f"{'='*60}") + lines.append(f" vmstat Summary ({summary['total_samples']} samples)") + lines.append(f"{'='*60}") + + for section in ["cpu", "memory", "io", "swap", "system_load"]: + data = summary.get(section, {}) + if not data: + continue + lines.append(f"\n {section.upper()}") + lines.append(f" {'Metric':<25} {'Min':>7} {'Max':>7} {'Mean':>7} {'P95':>7} {'P99':>7}") + lines.append(f" {'-'*55}") + for metric, stats in data.items(): + lines.append( + f" {metric:<25} {stats['min']:>7} {stats['max']:>7} " + f"{stats['mean']:>7} {stats['p95']:>7} {stats['p99']:>7}" + ) + + alerts = summary.get("alerts", {}) + if alerts: + lines.append(f"\n ALERTS") + lines.append(f" {'-'*55}") + if alerts.get("high_cpu_seconds", 0) > 0: + lines.append(f" CPU > 80% (user+sys): {alerts['high_cpu_seconds']}s") + if alerts.get("high_wait_seconds", 0) > 0: + lines.append(f" IO Wait > 20%: {alerts['high_wait_seconds']}s") + if alerts.get("high_swap_seconds", 0) > 0: + lines.append(f" Swap in use: {alerts['high_swap_seconds']}s") + if all(v == 0 for v in alerts.values()): + lines.append(f" No alert thresholds breached.") + + lines.append(f"\n{'='*60}") + return "\n".join(lines) + + +def format_summary_json(summary): + """Format summary dict as JSON.""" + return json.dumps(summary, indent=2) diff --git a/tests/test_cli.py b/tests/test_cli.py index 073361e..373dd8a 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,8 +1,10 @@ import os import glob +import json import pytest from unittest.mock import patch from click.testing import CliRunner + from vmstat_visualizer.cli.__main__ import cli import vmstat_visualizer.cli.visualizer as viz @@ -17,24 +19,26 @@ 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 "--summary" in result.output + assert "--summary-json" in result.output 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: @@ -43,11 +47,9 @@ class TestVisualizeCommand: 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 @@ -56,19 +58,50 @@ def test_visualize_active_log(self, mock_check, active_log, tmp_path): 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.parser.parser.check_vmstat_columns", + return_value=(True, True)) + def test_visualize_summary_text(self, mock_check, active_log, tmp_path): + runner = CliRunner() + prefix = str(tmp_path / "summ") + result = runner.invoke( + cli, + ["visualize", active_log, "--summary", "-o", prefix, "-e", "png"], + ) + assert result.exit_code == 0 + assert "vmstat Summary" in result.output + assert "user_cpu_percent" in result.output or "CPU" in result.output + + @patch("vmstat_visualizer.parser.parser.check_vmstat_columns", + return_value=(True, True)) + def test_visualize_summary_json_stdout(self, mock_check, active_log, tmp_path): + runner = CliRunner() + prefix = str(tmp_path / "sj") + result = runner.invoke(cli, ["visualize", active_log, "--summary-json", "-o", prefix]) + assert result.exit_code == 0 + start = result.output.find("{") + assert start >= 0 + payload = json.loads(result.output[start:]) + assert "total_samples" in payload + + @patch("vmstat_visualizer.parser.parser.check_vmstat_columns", + return_value=(True, True)) + def test_visualize_summary_json_skips_plot_files(self, mock_check, active_log, tmp_path): + runner = CliRunner() + prefix = str(tmp_path / "no_plot") + result = runner.invoke(cli, ["visualize", active_log, "--summary-json", "-o", prefix]) + assert result.exit_code == 0 + pngs = glob.glob(str(tmp_path / "no_plot_*.png")) + assert pngs == [] + class TestCompareCommand: @patch("vmstat_visualizer.parser.parser.check_vmstat_columns", @@ -76,22 +109,22 @@ class TestCompareCommand: 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)) 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", @@ -99,16 +132,56 @@ def test_compare_all(self, mock_check, active_log, tmp_path): 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 + + @patch("vmstat_visualizer.parser.parser.check_vmstat_columns", + return_value=(True, True)) + def test_compare_memory_with_buff_and_cache_columns( + self, mock_check, standard_log, tmp_path + ): + runner = CliRunner() + prefix = str(tmp_path / "bc") + result = runner.invoke( + cli, + [ + "compare", + standard_log, + standard_log, + "-m", + "memory", + "-c", + "buff", + "-c", + "cache", + "-o", + prefix, + ], + ) assert result.exit_code == 0 - assert 'Filtering columns: us, sy' in result.output + assert "buff" in result.output and "cache" in result.output + pngs = glob.glob(str(tmp_path / "bc_memory_comparison*.png")) + assert len(pngs) >= 1 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_summary.py b/tests/test_summary.py new file mode 100644 index 0000000..da1093b --- /dev/null +++ b/tests/test_summary.py @@ -0,0 +1,184 @@ +"""Unit tests for summary statistics helpers.""" + +import json +from types import SimpleNamespace + +import pytest + +from vmstat_visualizer.summary import ( + compute_summary, + format_summary_json, + format_summary_text, + _duration_above, + _percentile, + _safe_ints, + _stats_for, +) + + +class TestSafeInts: + def test_normal_values(self): + assert _safe_ints(["1", "2", "3"]) == [1, 2, 3] + + def test_skips_none(self): + assert _safe_ints([None, "5", None, "10"]) == [5, 10] + + def test_empty_list(self): + assert _safe_ints([]) == [] + + +class TestPercentile: + def test_empty_returns_zero(self): + assert _percentile([], 50) == 0 + + def test_single_element(self): + assert _percentile([42], 50) == 42 + assert _percentile([42], 99) == 42 + + def test_p50_interpolated_odd_length(self): + s = sorted([1, 2, 3, 4]) + assert _percentile(s, 50) == 2.5 + + def test_p95_known_sorted(self): + s = sorted([10, 20, 30, 40, 50]) + assert _percentile(s, 95) == pytest.approx(48.0) + + def test_p99_known_sorted(self): + s = sorted(list(range(1, 101))) + assert _percentile(s, 99) == pytest.approx(99.01) + + +class TestStatsFor: + def test_normal_list(self): + out = _stats_for(["3", None, "1", "4"]) + assert out is not None + assert out["min"] == 1 + assert out["max"] == 4 + assert out["mean"] == pytest.approx(2.67, rel=1e-2) + + def test_all_none_returns_none(self): + assert _stats_for([None, None]) is None + + def test_single_value(self): + out = _stats_for(["99"]) + assert out == {"min": 99, "max": 99, "mean": 99.0, "p95": 99.0, "p99": 99.0} + + +class TestDurationAbove: + def test_counts_above_threshold(self): + vals = ["0", None, "5", "10", None, "21"] + assert _duration_above(vals, 5) == 2 + + def test_zero_results(self): + assert _duration_above(["0", None, "-1"], 0) == 0 + + +class TestComputeSummary: + def test_known_plot_metrics_like_object(self): + pm = SimpleNamespace( + t=[0, 1, 2], + user_cpu_percent=["50", "10", "90"], + system_cpu_percent=["35", "10", "15"], + idle_cpu_percent=["15", None, "70"], + wait_cpu_percent=["5", "25", "10"], + steal_cpu_percent=["0", "0", "0"], + free_memory_kb=["1000", "2000", "3000"], + swapped_memory_kb=["0", "0", "10"], + inactive_memory_kb=["100", "200", "300"], + active_memory_kb=["500", "500", "600"], + blocks_in=["1", "2", "3"], + blocks_out=["4", "5", "6"], + swap_in_kb=["0", "1", "0"], + swap_out_kb=["0", "0", "2"], + run_queue=["0", "1", "2"], + blocked_processes=["0", "0", "1"], + ) + summary = compute_summary(pm) + + assert summary["total_samples"] == 3 + + cpu = summary["cpu"] + for key in ( + "user_cpu_percent", + "system_cpu_percent", + "idle_cpu_percent", + "wait_cpu_percent", + "steal_cpu_percent", + ): + assert key in cpu + stat = cpu[key] + for field in ("min", "max", "mean", "p95", "p99"): + assert field in stat + + assert cpu["user_cpu_percent"]["min"] == 10 + assert cpu["user_cpu_percent"]["max"] == 90 + + mem = summary["memory"] + for key in ("free_memory_kb", "swapped_memory_kb", + "inactive_memory_kb", "active_memory_kb"): + assert key in mem + + assert "blocks_in" in summary["io"] and "blocks_out" in summary["io"] + assert "swap_in_kb" in summary["swap"] and "swap_out_kb" in summary["swap"] + load = summary["system_load"] + assert "run_queue" in load and "blocked_processes" in load + + alerts = summary["alerts"] + assert alerts["high_cpu_seconds"] == 2 + assert alerts["high_wait_seconds"] == 1 + assert alerts["high_swap_seconds"] == 1 + + +class TestFormatSummaryText: + def test_contains_headers_and_metric_names(self): + pm = SimpleNamespace( + t=[0], + user_cpu_percent=["5"], + system_cpu_percent=["5"], + idle_cpu_percent=["90"], + wait_cpu_percent=["0"], + steal_cpu_percent=["0"], + free_memory_kb=["1000"], + swapped_memory_kb=["0"], + inactive_memory_kb=["0"], + active_memory_kb=["500"], + blocks_in=["1"], + blocks_out=["1"], + swap_in_kb=["0"], + swap_out_kb=["0"], + run_queue=["0"], + blocked_processes=["0"], + ) + summary = compute_summary(pm) + text = format_summary_text(summary) + assert "vmstat Summary" in text + assert "CPU" in text or "MEMORY" in text + assert "user_cpu_percent" in text + + +class TestFormatSummaryJson: + def test_valid_json_round_trip(self): + summary = compute_summary( + SimpleNamespace( + t=[0, 1], + user_cpu_percent=["1", "2"], + system_cpu_percent=["3", "4"], + idle_cpu_percent=["96", "94"], + wait_cpu_percent=["0", "0"], + steal_cpu_percent=["0", "0"], + free_memory_kb=["100", "101"], + swapped_memory_kb=["0", "0"], + inactive_memory_kb=["10", "11"], + active_memory_kb=["20", "21"], + blocks_in=["0", "0"], + blocks_out=["0", "0"], + swap_in_kb=["0", "0"], + swap_out_kb=["0", "0"], + run_queue=["0", "1"], + blocked_processes=["0", "0"], + ) + ) + dumped = format_summary_json(summary) + restored = json.loads(dumped) + assert restored["total_samples"] == summary["total_samples"] + assert restored["cpu"] == summary["cpu"]