Skip to content

Commit

Permalink
Merge pull request #2550 from FooQoo/feature/user-editable-statics-co…
Browse files Browse the repository at this point in the history
…lumns

Customization Feature for Percentile Display on Statistics Page
  • Loading branch information
cyberw committed Jan 16, 2024
2 parents 4e60b41 + 5cd7d62 commit d5b3202
Show file tree
Hide file tree
Showing 22 changed files with 362 additions and 87 deletions.
1 change: 1 addition & 0 deletions .gitignore
Expand Up @@ -27,3 +27,4 @@ __pycache__
.sass-cache/
.env
yarn-error.log
.venv
7 changes: 5 additions & 2 deletions docs/configuration.rst
Expand Up @@ -199,8 +199,11 @@ The list of statistics parameters that can be modified is:
| CURRENT_RESPONSE_TIME_PERCENTILE_WINDOW | Window size/resolution - in seconds - when calculating the current response |
| | time percentile |
+-------------------------------------------+--------------------------------------------------------------------------------------+
| PERCENTILES_TO_REPORT | The list of response time percentiles to be calculated & reported |
| PERCENTILES_TO_REPORT | List of response time percentiles to be calculated & reported |
+-------------------------------------------+--------------------------------------------------------------------------------------+
| PERCENTILES_TO_CHART | The list of response time percentiles for response time chart |
| PERCENTILES_TO_CHART | List of response time percentiles in the screen of chart for Web UI |
+-------------------------------------------+--------------------------------------------------------------------------------------+
| PERCENTILES_TO_STATISTICS | List of response time percentiles in the screen of statistics for Web UI |
| | This parameter supports only modern UI |
+-------------------------------------------+--------------------------------------------------------------------------------------+

8 changes: 8 additions & 0 deletions locust/main.py
Expand Up @@ -137,6 +137,14 @@ def is_valid_percentile(parameter):
"stats.PERCENTILES_TO_CHART parameter need to be float and value between. 0 < percentile < 1 Eg 0.95\n"
)
sys.exit(1)

for percentile in stats.PERCENTILES_TO_STATISTICS:
if not is_valid_percentile(percentile):
logging.error(
"stats.PERCENTILES_TO_STATISTICS parameter need to be float and value between. 0 < percentile < 1 Eg 0.95\n"
)
sys.exit(1)

# parse all command line options
options = parse_options()

Expand Down
13 changes: 9 additions & 4 deletions locust/stats.py
Expand Up @@ -115,18 +115,17 @@ def resize_handler(signum: int, frame: FrameType | None):
CSV_STATS_INTERVAL_SEC = 1
CSV_STATS_FLUSH_INTERVAL_SEC = 10


"""
Default window size/resolution - in seconds - when calculating the current
response time percentile
"""
CURRENT_RESPONSE_TIME_PERCENTILE_WINDOW = 10


CachedResponseTimes = namedtuple("CachedResponseTimes", ["response_times", "num_requests"])

PERCENTILES_TO_REPORT = [0.50, 0.66, 0.75, 0.80, 0.90, 0.95, 0.98, 0.99, 0.999, 0.9999, 1.0]

PERCENTILES_TO_STATISTICS = [0.95, 0.99]
PERCENTILES_TO_CHART = [0.50, 0.95]
MODERN_UI_PERCENTILES_TO_CHART = [0.95]

Expand Down Expand Up @@ -681,6 +680,11 @@ def _cache_response_times(self, t: int) -> None:
self.response_times_cache.popitem(last=False)

def to_dict(self, escape_string_values=False):
response_time_percentiles = {
f"response_time_percentile_{percentile}": self.get_response_time_percentile(percentile)
for percentile in PERCENTILES_TO_STATISTICS
}

return {
"method": escape(self.method or "") if escape_string_values else self.method,
"name": escape(self.name) if escape_string_values else self.name,
Expand All @@ -693,8 +697,9 @@ def to_dict(self, escape_string_values=False):
"current_rps": self.current_rps,
"current_fail_per_sec": self.current_fail_per_sec,
"median_response_time": self.median_response_time,
"ninetieth_response_time": self.get_response_time_percentile(0.9),
"ninety_ninth_response_time": self.get_response_time_percentile(0.99),
"ninetieth_response_time": self.get_response_time_percentile(0.9), # for legacy ui
"ninety_ninth_response_time": self.get_response_time_percentile(0.99), # for legacy ui
**response_time_percentiles, # for modern ui
"avg_content_length": self.avg_content_length,
}

Expand Down
29 changes: 29 additions & 0 deletions locust/test/test_main.py
Expand Up @@ -233,6 +233,35 @@ def my_task(self):
stdout, stderr = proc.communicate()
self.assertIn("Starting web interface at", stderr)

def test_percentiles_to_statistics(self):
port = get_free_tcp_port()
with temporary_file(
content=textwrap.dedent(
"""
from locust import User, task, constant, events
from locust.stats import PERCENTILES_TO_STATISTICS
PERCENTILES_TO_STATISTICS = [0.9, 0.99]
class TestUser(User):
wait_time = constant(3)
@task
def my_task(self):
print("running my_task()")
"""
)
) as file_path:
proc = subprocess.Popen(
["locust", "-f", file_path, "--web-port", str(port), "--autostart", "--modern-ui"],
stdout=PIPE,
stderr=PIPE,
text=True,
)
gevent.sleep(1)
response = requests.get(f"http://localhost:{port}/")
self.assertEqual(200, response.status_code)
proc.send_signal(signal.SIGTERM)
stdout, stderr = proc.communicate()
self.assertIn("Starting web interface at", stderr)

def test_invalid_percentile_parameter(self):
with temporary_file(
content=textwrap.dedent(
Expand Down
1 change: 1 addition & 0 deletions locust/web.py
Expand Up @@ -613,6 +613,7 @@ def update_template_args(self):
if self.modern_ui:
percentiles = {
"percentiles_to_chart": stats_module.MODERN_UI_PERCENTILES_TO_CHART,
"percentiles_to_statistics": stats_module.PERCENTILES_TO_STATISTICS,
}
else:
percentiles = {
Expand Down
1 change: 0 additions & 1 deletion locust/webui/dist/assets/auth-3cd88f78.js.map

This file was deleted.

1 change: 1 addition & 0 deletions locust/webui/dist/assets/auth-8bb9e7f0.js.map

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion locust/webui/dist/auth.html
Expand Up @@ -7,7 +7,7 @@
<meta name="theme-color" content="#000000" />

<title>Locust</title>
<script type="module" crossorigin src="/assets/index-ea7131ad.js"></script>
<script type="module" crossorigin src="/assets/index-5a4a3f83.js"></script>
</head>
<body>
<div id="root"></div>
Expand Down
2 changes: 1 addition & 1 deletion locust/webui/dist/index.html
Expand Up @@ -7,7 +7,7 @@
<meta name="theme-color" content="#000000" />

<title>Locust</title>
<script type="module" crossorigin src="/assets/index-ea7131ad.js"></script>
<script type="module" crossorigin src="/assets/index-5a4a3f83.js"></script>
</head>
<body>
<div id="root"></div>
Expand Down
36 changes: 28 additions & 8 deletions locust/webui/src/components/StatsTable/StatsTable.tsx
@@ -1,18 +1,27 @@
import { connect } from 'react-redux';

import Table from 'components/Table/Table';
import ViewColumnSelector from 'components/ViewColumnSelector/ViewColumnSelector';
import { swarmTemplateArgs } from 'constants/swarm';
import useSelectViewColumns from 'hooks/useSelectViewColumns';
import useSortByField from 'hooks/useSortByField';
import { IRootState } from 'redux/store';
import { ISwarmStat } from 'types/ui.types';

const percentilesToStatisticsRows = swarmTemplateArgs.percentilesToStatistics
? swarmTemplateArgs.percentilesToStatistics.map(percentile => ({
title: `${percentile * 100}%ile (ms)`,
key: `responseTimePercentile${percentile}` as keyof ISwarmStat,
}))
: [];

const tableStructure = [
{ key: 'method', title: 'Type' },
{ key: 'name', title: 'Name' },
{ key: 'numRequests', title: '# Requests' },
{ key: 'numFailures', title: '# Fails' },
{ key: 'medianResponseTime', title: 'Median (ms)', round: 2 },
{ key: 'ninetiethResponseTime', title: '90%ile (ms)' },
{ key: 'ninetyNinthResponseTime', title: '99%ile (ms)' },
...percentilesToStatisticsRows,
{ key: 'avgResponseTime', title: 'Average (ms)', round: 2 },
{ key: 'minResponseTime', title: 'Min (ms)' },
{ key: 'maxResponseTime', title: 'Max (ms)' },
Expand All @@ -26,13 +35,24 @@ export function StatsTable({ stats }: { stats: ISwarmStat[] }) {
hasTotalRow: true,
});

const { selectedColumns, addColumn, removeColumn, filteredStructure } =
useSelectViewColumns(tableStructure);

return (
<Table<ISwarmStat>
currentSortField={currentSortField}
onTableHeadClick={onTableHeadClick}
rows={sortedStats}
structure={tableStructure}
/>
<>
<ViewColumnSelector
addColumn={addColumn}
removeColumn={removeColumn}
selectedColumns={selectedColumns}
structure={tableStructure}
/>
<Table<ISwarmStat>
currentSortField={currentSortField}
onTableHeadClick={onTableHeadClick}
rows={sortedStats}
structure={filteredStructure}
/>
</>
);
}

Expand Down
@@ -0,0 +1,57 @@
import { render, fireEvent, screen } from '@testing-library/react';
import { describe, expect, test, vi } from 'vitest';

import ViewColumnSelector from './ViewColumnSelector';

describe('ViewColumnSelector', () => {
const mockStructure = [
{ key: 'column1', title: 'Column 1' },
{ key: 'column2', title: 'Column 2' },
];
const mockSelectedColumns = ['column1'];
const mockAddColumn = vi.fn();
const mockRemoveColumn = vi.fn();

test('should render switches for each structure item', () => {
render(
<ViewColumnSelector
addColumn={mockAddColumn}
removeColumn={mockRemoveColumn}
selectedColumns={mockSelectedColumns}
structure={mockStructure}
/>,
);

const button = screen.getByRole('button');
fireEvent.click(button);

const switches = screen.getAllByRole('checkbox');
expect(switches.length).toEqual(mockStructure.length);
});

test('should toggle switches correctly', () => {
render(
<ViewColumnSelector
addColumn={mockAddColumn}
removeColumn={mockRemoveColumn}
selectedColumns={mockSelectedColumns}
structure={mockStructure}
/>,
);

const button = screen.getByRole('button');
fireEvent.click(button);

// Initial state check: 'column1' should be on and 'column2' should be off
const switch1 = screen.getByLabelText('Column 1');
const switch2 = screen.getByLabelText('Column 2');

// Click on 'column2' switch to add the column
fireEvent.click(switch2);
expect(mockAddColumn).toHaveBeenCalledWith('column2');

// Click on 'column1' switch to remove the column
fireEvent.click(switch1);
expect(mockRemoveColumn).toHaveBeenCalledWith('column1');
});
});
@@ -0,0 +1,61 @@
import { useState } from 'react';
import ViewColumnIcon from '@mui/icons-material/ViewColumn';
import { Button, FormControlLabel, FormGroup, Popover, Stack, Switch } from '@mui/material';

import { ITableStructure } from 'types/table.types';

interface IViewColumnSelector {
structure: ITableStructure[];
selectedColumns: string[];
addColumn: (column: string) => void;
removeColumn: (column: string) => void;
}

function ViewColumnSelector({
structure,
selectedColumns,
addColumn,
removeColumn,
}: IViewColumnSelector) {
const [anchorEl, setAnchorEl] = useState(null as HTMLButtonElement | null);

return (
<Stack direction='row' justifyContent='end' my={2} spacing={1}>
<Button onClick={event => setAnchorEl(event.currentTarget)} variant='outlined'>
<ViewColumnIcon />
</Button>
<Popover
anchorEl={anchorEl}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'left',
}}
onClose={() => setAnchorEl(null)}
open={Boolean(anchorEl)}
>
<FormGroup sx={{ p: 2 }}>
{structure.map(({ key, title }) => (
<FormControlLabel
control={
<Switch
checked={selectedColumns.includes(key)}
onChange={() => {
if (selectedColumns.includes(key)) {
removeColumn(key);
} else {
addColumn(key);
}
}}
/>
}
key={key}
label={title}
/>
))}
</FormGroup>
</Popover>
</Stack>
);
}

export default ViewColumnSelector;
59 changes: 59 additions & 0 deletions locust/webui/src/hooks/tests/useSelecteViewColumns.test.tsx
@@ -0,0 +1,59 @@
import { act, renderHook } from '@testing-library/react';
import { describe, expect, test } from 'vitest';

import useSelectViewColumns from 'hooks/useSelectViewColumns';

const mockStructure = [
{ title: 'Method', key: 'method' },
{ title: 'Name', key: 'name' },
{ title: '# Requests', key: 'numRequests' },
];

describe('useSelectViewColumns hook', () => {
test('should initialize with default columns', () => {
const { result } = renderHook(() => useSelectViewColumns(mockStructure));

expect(result.current.selectedColumns).toEqual(mockStructure.map(s => s.key));
});

test('should add a new column', () => {
const { result } = renderHook(() => useSelectViewColumns(mockStructure));

act(() => {
result.current.addColumn('column3');
});

expect(result.current.selectedColumns).toEqual([...mockStructure.map(s => s.key), 'column3']);
});

test('should remove an existing column', () => {
const { result } = renderHook(() => useSelectViewColumns(mockStructure));

act(() => {
result.current.removeColumn('method');
});

expect(result.current.selectedColumns).toEqual(['name', 'numRequests']);
});

test('filterStructure should filter out unselected columns', () => {
const { result } = renderHook(() => useSelectViewColumns(mockStructure));

act(() => {
// remove column with key 'method'
result.current.removeColumn('method');
});

const filteredStructure = result.current.filteredStructure;

// expect only columns with keys 'name' and 'numRequests' to be returned
expect(filteredStructure.length).toBe(2);
expect(filteredStructure).toEqual(
expect.arrayContaining([
expect.objectContaining({ key: 'name' }),
expect.objectContaining({ key: 'numRequests' }),
]),
);
expect(filteredStructure).not.toContainEqual(expect.objectContaining({ key: 'method' }));
});
});

0 comments on commit d5b3202

Please sign in to comment.