This commit is contained in:
Suchith Krishna S Donni 2025-02-21 16:54:10 -08:00 committed by GitHub
commit 4863e42c7c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 950 additions and 0 deletions

5
integration/loadtesting/.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
*.db
*.py[cod]
.web
__pycache__/
env/

View File

@ -0,0 +1,41 @@
# Load testing for Reflex using Locust
Steps to build locally -
Create virtual env
```
python3 -m venv env
```
Activate the virtual env
```
source env/bin/activate
```
Install dependencies
```
pip install -r requirements.txt
```
Also, install locust and locust plugin
```
pip install locust
pip install locust-plugins[websocket]
```
Start the reflex app using poetry
```
poetry run reflex run
```
Start locust in a new terminal window
```
locust
```
This will start a web app on `http://localhost:8089`
Configure the number of user and the ramp up. Watch results flow in. When done, terminate the locust process and get aggregate data in the terminal.

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@ -0,0 +1,10 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Github" clip-path="url(#clip0_469_1929)">
<path id="Vector" d="M8.0004 0.587524C3.80139 0.587524 0.400391 3.98851 0.400391 8.1875C0.400391 11.5505 2.57589 14.391 5.59689 15.398C5.97689 15.4645 6.11939 15.2365 6.11939 15.037C6.11939 14.8565 6.10989 14.258 6.10989 13.6215C4.20039 13.973 3.70639 13.156 3.55439 12.7285C3.46889 12.51 3.09839 11.8355 2.77539 11.655C2.50939 11.5125 2.12939 11.161 2.76589 11.1515C3.36439 11.142 3.79189 11.7025 3.93439 11.9305C4.61839 13.08 5.71089 12.757 6.14789 12.5575C6.21439 12.0635 6.41388 11.731 6.6324 11.541C4.94139 11.351 3.17439 10.6955 3.17439 7.7885C3.17439 6.962 3.46889 6.27801 3.95339 5.74601C3.87739 5.55601 3.61139 4.77701 4.02939 3.73201C4.02939 3.73201 4.66589 3.53251 6.11939 4.51101C6.7274 4.34001 7.3734 4.25451 8.0194 4.25451C8.6654 4.25451 9.3114 4.34001 9.9194 4.51101C11.3729 3.52301 12.0094 3.73201 12.0094 3.73201C12.4274 4.77701 12.1614 5.55601 12.0854 5.74601C12.5699 6.27801 12.8644 6.9525 12.8644 7.7885C12.8644 10.705 11.0879 11.351 9.3969 11.541C9.6724 11.7785 9.9099 12.2345 9.9099 12.947C9.9099 13.9635 9.9004 14.7805 9.9004 15.037C9.9004 15.2365 10.0429 15.474 10.4229 15.398C13.5165 14.3536 15.5996 11.4527 15.6004 8.1875C15.6004 3.98851 12.1994 0.587524 8.0004 0.587524Z" fill="#494369"/>
</g>
<defs>
<clipPath id="clip0_469_1929">
<rect width="16" height="16" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -0,0 +1,239 @@
from locust import task,between
from locust_plugins.users.socketio import SocketIOUser
import uuid
import json
import time
"""
Pending things to be completed -
1. Error handling
2. Code cleanup and moving variables into config file
3. Documentation
"""
class RecursiveJSONEncoder(json.JSONEncoder):
level = 0
def default(self, obj):
if isinstance(obj, dict):
self.level += 1
if self.level == 1:
return json.dumps({key: self.default(value) for key, value in obj.items()}, cls=RecursiveJSONEncoder)
else:
return {key: self.default(value) for key, value in obj.items()}
elif isinstance(obj, list):
return [self.default(element) if not isinstance(element, (str, int, float, bool, type(None))) else element for element in obj]
elif isinstance(obj, (str, int, float, bool, type(None))):
return obj
else:
return json.JSONEncoder.default(self, obj)
class ReflexLoadTest(SocketIOUser):
token = None
wait_time = between(1, 5)
request_start_time = None
def validate_init_connection_message(self, message:str) -> bool:
conn_response_code = "0"
if len(message.strip()) > 0:
message = message.strip()
message_code = message[0]
if message_code == conn_response_code:
extracted_message_json = json.loads(message[1:])
self.session_sid = extracted_message_json['sid']
self.session_data = extracted_message_json
return True
else:
return False
def validate_init_event_session_message(self, message: str) -> bool:
init_event_response_code = "40/_event"
if len(message) > 0 and message.count(",") > 0:
message_code, message_body = message.split(",")
if message_code == init_event_response_code:
extracted_message_json = json.loads(message_body)
self.event_sid = extracted_message_json['sid']
self.event_session_data = extracted_message_json
return True
else:
return False
def validate_event_response_message(self, message: str) -> bool:
event_response_code = "42/_event"
if len(message) > 0 and message.count(","):
message_code = message.split(",")[0]
print(message_code)
if message_code == event_response_code:
return True
def register_listener(self, callback_func, event_name):
self.is_waiting_for_message = True
self.callback_func = callback_func
self.event_name = event_name
self.request_start_time = time.time()
print("setting request start time here", self.request_start_time)
def remove_listener(self):
self.is_waiting_for_message = False
self.callback_func = None
self.event_name = None
self.request_start_time = None
def on_message(self, message):
if self.is_waiting_for_message and self.callback_func:
message_match_flag = self.callback_func(message)
if message_match_flag == True:
print(message, time.time(), self.request_start_time)
res_time = time.time() - self.request_start_time
self.fire_response_time_event(res_time, message)
self.remove_listener()
def fire_response_time_event(self, res_time, message):
self.environment.events.request.fire(
request_type="WSR",
name=self.event_name,
response_time=res_time,
response_length=len(message),
exception=None,
context=self.context(),
)
def wait_for_message(self):
counter = 0
while self.is_waiting_for_message:
#Todo Add timeout exception here
time.sleep(0.05)
counter += 0.05
def get_conn_route(self):
# todo put this in config file
host = "localhost"
port = 8000
protocol = "ws"
url = f"{protocol}://{host}:{str(port)}"
event_suffix = "/_event/?EIO=4&transport=websocket"
conn_route = url + event_suffix
return conn_route
def on_start(self):
#todo: Add custom exceptions
try:
self.connect_to_remote()
self.send_access_event()
except:
print("something went wrong")
def connect_to_remote(self):
conn_route = self.get_conn_route()
self.register_listener(self.validate_init_connection_message, "connection_init_response")
self.connect(conn_route)
self.wait_for_message()
def send_access_event(self):
message = self.construct_event_message("access_event")
self.register_listener(self.validate_init_event_session_message, "event_namespace_access_response")
self.send(message, name="_event_namespace_access_request")
self.wait_for_message()
self.token = str(uuid.uuid4())
# def disconnect_socket_connection(self):
# print("disconnecting connection")
# self.send("41/_event", name="disconnect")
def construct_event_message(self, event_type):
event_code = str(self.get_event_code(event_type))
event_message = (self.get_event_message(event_type))
return f'{event_code}/_event,{event_message}'
def get_event_code(self, event_type):
match event_type:
case "access_event":
return 40
case _:
return 42
def get_event_message(self, event_type):
token = self.token
match event_type:
case "access_event":
return json.dumps([])
case "pure_event":
payload = {
'token': token,
'name': 'state.set_is_hydrated',
'router_data': {'pathname': '/', 'query': {}, 'asPath': '/'},
'payload': {'value': True}
}
return json.dumps(RecursiveJSONEncoder().default(["event",payload]))
case "state_update":
payload = {
'name': 'state.base_state.toggle_query',
'payload': {},
'handler': None,
'token': token,
'router_data': {'pathname': '/', 'query': {}, 'asPath': '/'}
}
return json.dumps(RecursiveJSONEncoder().default(["event",payload]))
case "substate_update":
payload = {
'name': 'state.base_state.query_state.query_api.delta_limit',
'payload': {'limit': '20'},
'handler': None,
'token': token,
'router_data': {'pathname': '/', 'query': {}, 'asPath': '/'}
}
return json.dumps(RecursiveJSONEncoder().default(["event",payload]))
@task
def test_pure_function(self):
message = self.construct_event_message("pure_event")
print(message)
self.register_listener(self.validate_event_response_message, "pure_function_response")
self.send(message, name="test_pure_function")
self.wait_for_message()
@task
def test_state_update(self):
message = self.construct_event_message("state_update")
print(message)
self.register_listener(self.validate_event_response_message, "state_update_response")
self.send(message, name="test_state_update")
self.wait_for_message()
@task
def test_substate_update(self):
message = self.construct_event_message("substate_update")
print(message)
self.register_listener(self.validate_event_response_message, "substate_update_response")
self.send(message, name="test_substate_update")
self.wait_for_message()
@task
def test_idle_connection(self):
self.sleep_with_heartbeat(10)
# 42/_event,["event","{\"token\":\"99c13759-97a1-48eb-fe71-e3ee8f034869\",\"name\":\"state.set_is_hydrated\",\"router_data\":{\"pathname\":\"/\",\"query\":{},\"asPath\":\"/\"},\"payload\":{\"value\":true}}"]

View File

@ -0,0 +1,5 @@
import reflex as rx
config = rx.Config(
app_name="sandbox",
)

View File

@ -0,0 +1 @@
"""Base template for Reflex."""

View File

@ -0,0 +1,60 @@
import reflex as rx
from sandbox.states.queries import QueryAPI
def render_data(data: tuple[str, str]):
return rx.vstack(
rx.text(data[0], weight="bold"),
rx.input(
value=data[1],
width="100%",
on_change=lambda value: QueryAPI.update_data(value, data),
),
width="100%",
spacing="2",
)
def render_drawer_buttons(name: str, color: str, function: callable):
return rx.badge(
rx.text(name, width="100%", text_align="center"),
color_scheme=color,
on_click=function,
variant="surface",
padding="0.75em 1.25em",
width="100%",
cursor="pointer",
)
def render_drawer():
return rx.drawer.root(
rx.drawer.overlay(z_index="5"),
rx.drawer.portal(
rx.drawer.content(
rx.vstack(
rx.foreach(QueryAPI.selected_entry, render_data),
rx.vstack(
render_drawer_buttons(
"Commit", "grass", QueryAPI.commit_changes
),
render_drawer_buttons("Close", "ruby", QueryAPI.delta_drawer),
padding="1em 0.5em",
width="inherit",
),
bg=rx.color_mode_cond("#faf9fb", "#1a181a"),
height="100%",
width="100%",
padding="1.25em",
),
top="auto",
left="auto",
height="100%",
width="25em",
on_interact_outside=QueryAPI.delta_drawer(),
),
),
direction="right",
open=QueryAPI.is_open,
)

View File

@ -0,0 +1,40 @@
import reflex as rx
from sandbox.states.base import BaseState
from sandbox.styles import text
navbar: dict[str, str] = {
"width": "100%",
"padding": "1em 1.15em",
"justify_content": "space-between",
"bg": rx.color_mode_cond(
"rgba(255, 255, 255, 0.81)",
"rgba(18, 17, 19, 0.81)",
),
"align_items": "center",
"border_bottom": "1px solid rgba(46, 46, 46, 0.51)",
}
def render_navbar():
return rx.hstack(
rx.hstack(
rx.box(
rx.text(
"REST API Admin Panel",
font_size="var(--chakra-fontSizes-lg)",
**text,
),
),
display="flex",
align_items="center",
),
rx.hstack(
rx.button(
BaseState.is_request, on_click=BaseState.toggle_query, cursor="pointer"
),
rx.color_mode.button(),
align_items="center",
),
**navbar,
)

View File

@ -0,0 +1,93 @@
import reflex as rx
from sandbox.states.queries import QueryAPI
from sandbox.components.drawer import render_drawer
def create_table_header(title: str):
return rx.table.column_header_cell(title)
def create_query_rows(data: dict[str, str]):
def fill_rows_with_data(data_):
return rx.table.cell(
f"{data_[1]}",
on_click=QueryAPI.display_selected_row(data),
cursor="pointer",
)
return rx.table.row(
rx.foreach(data, fill_rows_with_data),
_hover={"bg": rx.color(color="gray", shade=4)},
)
def create_pagination():
return rx.hstack(
rx.hstack(
rx.text("Entries per page", weight="bold"),
rx.select(
QueryAPI.limits, default_value="10", on_change=QueryAPI.delta_limit
),
align_items="center",
),
rx.hstack(
rx.text(
f"Page {QueryAPI.current_page}/{QueryAPI.total_pages}",
width="100px",
weight="bold",
),
rx.chakra.button_group(
rx.icon(
tag="chevron-left", on_click=QueryAPI.previous, cursor="pointer"
),
rx.icon(tag="chevron-right", on_click=QueryAPI.next, cursor="pointer"),
is_attached=True,
),
align_items="center",
spacing="1",
),
align_items="center",
spacing="4",
)
def render_output():
return rx.center(
rx.cond(
QueryAPI.get_data,
rx.vstack(
render_drawer(),
create_pagination(),
rx.table.root(
rx.table.header(
rx.table.row(
rx.foreach(QueryAPI.get_table_headers, create_table_header)
),
),
rx.table.body(
rx.foreach(QueryAPI.paginated_data, create_query_rows)
),
width="100%",
variant="surface",
size="1",
),
rx.text(
"* Click a row to edit its contents.",
weight="bold",
size="1",
),
width="100%",
overflow="auto",
padding="2em 2em",
),
rx.spacer(),
),
flex="60%",
bg=rx.color_mode_cond(
"#faf9fb",
"#1a181a",
),
border_radius="10px",
overflow="auto",
)

View File

@ -0,0 +1,213 @@
import reflex as rx
from sandbox.states.base import BaseState
from sandbox.states.queries import QueryState, QueryAPI
from sandbox.styles import text
def item_title(title: str):
return rx.hstack(
rx.text(title, font_size="var(--chakra-fontSizes-sm)", **text),
rx.chakra.accordion_icon(),
width="100%",
justify_content="space-between",
)
def item_add_event(event_trigger: callable):
return rx.button(
rx.hstack(
rx.text("+"),
rx.text("add", weight="bold"),
width="100%",
justify_content="space-between",
),
size="1",
on_click=event_trigger,
padding="0.35em 0.75em",
cursor="pointer",
color_scheme="gray",
)
def form_item_entry(data: dict[str, str]):
def create_entry(title: str, function: callable):
return (
rx.input(
placeholder=title,
width="100%",
on_change=function,
variant="surface",
),
)
return rx.hstack(
create_entry("key", lambda key: QueryState.update_keyy(key, data)),
create_entry("value", lambda value: QueryState.update_value(value, data)),
rx.button(
"DEL",
on_click=QueryState.remove_entry(data),
color_scheme="ruby",
variant="surface",
cursor="pointer",
),
width="100%",
spacing="1",
)
def form_item(
title: str, state: list[dict[str, str]], func: callable, event_trigger: callable
):
return rx.chakra.accordion(
rx.chakra.accordion_item(
rx.chakra.accordion_button(item_title(title)),
rx.chakra.accordion_panel(
item_add_event(event_trigger),
width="100%",
display="flex",
justify_content="end",
),
rx.chakra.accordion_panel(
rx.vstack(rx.foreach(state, func), width="100%", spacing="1")
),
),
allow_toggle=True,
width="100%",
border="transparent",
)
def form_body_param_item(
state: list[dict[str, str]], func: callable, event_trigger: callable
):
return rx.chakra.accordion(
rx.chakra.accordion_item(
rx.chakra.accordion_button(item_title("Body")),
rx.chakra.accordion_panel(
rx.match(
QueryState.current_req,
(
"GET",
rx.select(
QueryState.get_params_body,
default_value="None",
width="100%",
),
),
(
"POST",
rx.vstack(
rx.hstack(
item_add_event(event_trigger),
width="100%",
justify_content="end",
),
rx.select(
QueryState.post_params_body,
default_value="JSON",
width="100%",
),
rx.vstack(
rx.foreach(state, func), width="100%", spacing="1"
),
width="100%",
),
),
),
),
),
allow_toggle=True,
width="100%",
border="transparent",
)
def form_request_item():
return rx.chakra.accordion(
rx.chakra.accordion_item(
rx.chakra.accordion_button(item_title("Requests")),
rx.chakra.accordion_panel(
rx.hstack(
rx.select(
QueryState.req_methods,
width="120px",
default_value="GET",
on_change=QueryState.get_request,
),
rx.input(
value=QueryState.req_url,
width="100%",
on_change=QueryState.set_req_url,
placeholder="https://example_site.com/api/v2/endpoint.json",
),
width="100%",
)
),
),
allow_toggle=True,
width="100%",
border="transparent",
)
def render_query_form():
return rx.vstack(
# form_request_item(),
form_item(
"Headers", QueryState.headers, form_item_entry, QueryState.add_header
),
form_body_param_item(QueryState.body, form_item_entry, QueryState.add_body),
form_item(
"Cookies", QueryState.cookies, form_item_entry, QueryState.add_cookies
),
width="100%",
spacing="2",
padding="0em 0.75em",
)
def render_query_header():
return rx.hstack(
rx.hstack(
rx.select(
QueryState.req_methods,
width="100px",
default_value="GET",
on_change=QueryState.get_request,
),
rx.input(
value=QueryState.req_url,
width="100%",
on_change=QueryState.set_req_url,
placeholder="https://example_site.com/api/v2/endpoint.json",
),
width="100%",
spacing="1",
),
rx.button(
"Send", size="2", on_click=QueryAPI.run_get_request, cursor="pointer"
),
width="100%",
border_bottom=rx.color_mode_cond(
"1px solid rgba(45, 45, 45, 0.05)", "1px solid rgba(45, 45, 45, 0.51)"
),
padding="1em 0.75em",
justify_content="end",
)
def render_query_component():
return rx.vstack(
render_query_header(),
render_query_form(),
flex=["100%", "100%", "100%", "100%", "30%"],
display=BaseState.query_component_toggle,
padding_bottom="0.75em",
border_radius="10px",
bg=rx.color_mode_cond(
"#faf9fb",
"#1a181a",
),
)

View File

@ -0,0 +1 @@
from .dashboard import dashboard

View File

@ -0,0 +1,30 @@
"""The dashboard page."""
import reflex as rx
from sandbox.components.navbar import render_navbar
from sandbox.components.query import render_query_component
from sandbox.components.output import render_output
from sandbox.states.queries import QueryAPI
@rx.page("/", on_load=QueryAPI.run_get_request)
def dashboard() -> rx.Component:
"""The dashboard page.
Returns:
The UI for the dashboard page.
"""
return rx.vstack(
render_navbar(),
rx.hstack(
render_query_component(),
render_output(),
width="100%",
display="flex",
flex_wrap="wrap",
spacing="6",
padding="2em 1em",
),
spacing="4",
)

View File

@ -0,0 +1,9 @@
import reflex as rx
from sandbox.pages import *
class State(rx.State):
"""Define empty state to allow access to rx.State.router."""
app = rx.App()

View File

@ -0,0 +1,17 @@
import reflex as rx
class BaseState(rx.State):
query_component_toggle: str = "none"
is_request: str = "New Request"
def toggle_query(self):
self.query_component_toggle = (
"flex" if self.query_component_toggle == "none" else "none"
)
self.is_request = (
"New Request" if self.query_component_toggle == "none" else "Close Request"
)

View File

@ -0,0 +1,180 @@
import uuid
import httpx
from sandbox.states.base import BaseState
# test URL: str = https://jsonplaceholder.typicode.com/posts
# test URL: str = https://jsonplaceholder.typicode.com/todos
class QueryState(BaseState):
# vars for handling request calls ...
req_methods: list[str] = ["GET", "POST"]
req_url: str = "https://jsonplaceholder.typicode.com/posts"
current_req: str = "GET"
get_params_body: list[str] = ["JSON", "Raw", "None"]
post_params_body: list[str] = [
"JSON",
"Raw",
"x-www-form-urlencoded",
"Form Data",
"Binary",
"None",
]
# params. for triggering API calls ...
url_param: dict[str, str]
headers: list[dict[str, str]]
body: list[dict[str, str]]
cookies: list[dict[str, str]]
# vars for GET request ...
get_data: list[dict[str, str]]
get_table_headers: list[str]
paginated_data: list[dict[str, str]]
# vars for pagination ...
number_of_rows: int
limits: list[str] = ["10", "20", "50"]
current_limit: int = 10
offset: int = 0
current_page: int = 1
total_pages: int = 1
formatted_headers: dict
def get_request(self, method: str):
self.current_req = method
def add_header(self):
self.headers.append(
{"id": str(uuid.uuid4()), "identifier": "headers", "key": "", "value": ""}
)
def add_body(self):
self.body.append(
{"id": str(uuid.uuid4()), "identifier": "body", "key": "", "value": ""}
)
def add_cookies(self):
self.cookies.append(
{"id": str(uuid.uuid4()), "identifier": "cookies", "key": "", "value": ""}
)
def pure(self):
return
def remove_entry(self, data: dict[str, str]):
if data["identifier"] == "headers":
self.headers = [item for item in self.headers if item["id"] != data["id"]]
if data["identifier"] == "body":
self.body = [item for item in self.body if item["id"] != data["id"]]
if data["identifier"] == "cookies":
self.cookies = [item for item in self.cookies if item["id"] != data["id"]]
async def update_attribute(self, data: dict[str, str], attribute: str, value: str):
data[attribute] = value
if data["identifier"] == "headers":
self.headers = [
data if item["id"] == data["id"] else item for item in self.headers
]
if data["identifier"] == "body":
self.body = [
data if item["id"] == data["id"] else item for item in self.body
]
if data["identifier"] == "cookies":
self.cookies = [
data if item["id"] == data["id"] else item for item in self.cookies
]
async def update_keyy(self, key: str, data: dict[str, str]):
await self.update_attribute(data, "key", key)
async def update_value(self, value: str, data: dict[str, str]):
await self.update_attribute(data, "value", value)
class QueryAPI(QueryState):
# vars to update row entries ...
is_open: bool = False
selected_entry: dict[str, str]
original_entry: dict[str, str]
async def process_headers(self):
for item in self.headers:
if item["key"]:
self.formatted_headers[item["key"]] = item["value"]
async def run_get_request(self):
await self.process_headers()
async with httpx.AsyncClient() as client:
res = await client.get(self.req_url, headers=self.formatted_headers)
self.get_data = res.json()
self.number_of_rows = len(self.get_data)
self.get_table_headers = list(self.get_data[0].keys())
# Calculate the total number of pages
self.total_pages = (
self.number_of_rows + self.current_limit - 1
) // self.current_limit
# Initialize the data to the first page
self.paginate()
def paginate(self):
start = self.offset
end = start + self.current_limit
self.paginated_data = self.get_data[start:end]
self.current_page = (self.offset // self.current_limit) + 1
def delta_limit(self, limit: str):
self.current_limit = int(limit)
self.offset = 0
self.total_pages = (
self.number_of_rows + self.current_limit - 1
) // self.current_limit
self.paginate()
def pure(self):
return
def previous(self):
if self.offset >= self.current_limit:
self.offset -= self.current_limit
else:
self.offset = 0
self.paginate()
def next(self):
if self.offset + self.current_limit < self.number_of_rows:
self.offset += self.current_limit
self.paginate()
def delta_drawer(self):
self.is_open = not self.is_open
def display_selected_row(self, data: dict[str, str]):
self.delta_drawer()
self.selected_entry = data.copy()
self.original_entry = data
def update_data(self, value: str, data: tuple[str, str]):
self.selected_entry[data[0]] = value
def commit_changes(self):
self.get_data = [
self.selected_entry if item == self.original_entry else item
for item in self.get_data
]
self.paginate()
self.delta_drawer()

View File

@ -0,0 +1,6 @@
"""Styles for the app."""
text: dict[str, str] = {
"font_family": "var(--chakra-fonts-branding)",
"font_weight": "var(--chakra-fontWeights-black)",
}