pypproxy 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. pypproxy/__init__.py +0 -0
  2. pypproxy/api/__init__.py +0 -0
  3. pypproxy/api/server.py +427 -0
  4. pypproxy/bulk/__init__.py +0 -0
  5. pypproxy/bulk/sender.py +97 -0
  6. pypproxy/cert/__init__.py +0 -0
  7. pypproxy/cert/ca.py +144 -0
  8. pypproxy/cert/client_cert.py +65 -0
  9. pypproxy/codec.py +176 -0
  10. pypproxy/config/__init__.py +0 -0
  11. pypproxy/config/config.py +106 -0
  12. pypproxy/dns/__init__.py +0 -0
  13. pypproxy/dns/server.py +149 -0
  14. pypproxy/exporter/__init__.py +0 -0
  15. pypproxy/exporter/exporter.py +122 -0
  16. pypproxy/exporter/importer.py +169 -0
  17. pypproxy/graphql/__init__.py +0 -0
  18. pypproxy/graphql/detector.py +76 -0
  19. pypproxy/graphql/introspection.py +217 -0
  20. pypproxy/graphql/modifier.py +98 -0
  21. pypproxy/graphql/schema_store.py +33 -0
  22. pypproxy/intercept/__init__.py +0 -0
  23. pypproxy/intercept/manager.py +142 -0
  24. pypproxy/interceptor/__init__.py +0 -0
  25. pypproxy/interceptor/interceptor.py +172 -0
  26. pypproxy/proto/__init__.py +0 -0
  27. pypproxy/proto/grpc.py +48 -0
  28. pypproxy/proto/mqtt.py +119 -0
  29. pypproxy/proto/ws.py +120 -0
  30. pypproxy/proto/ws_intercept.py +117 -0
  31. pypproxy/proxy/__init__.py +0 -0
  32. pypproxy/proxy/proxy.py +407 -0
  33. pypproxy/replay/__init__.py +0 -0
  34. pypproxy/replay/replay.py +77 -0
  35. pypproxy/rule/__init__.py +0 -0
  36. pypproxy/rule/rule.py +198 -0
  37. pypproxy/scan/__init__.py +0 -0
  38. pypproxy/scan/scanner.py +296 -0
  39. pypproxy/script/__init__.py +0 -0
  40. pypproxy/script/engine.py +49 -0
  41. pypproxy/security/__init__.py +0 -0
  42. pypproxy/security/header_checker.py +308 -0
  43. pypproxy/security/int_overflow.py +193 -0
  44. pypproxy/security/jwt_checker.py +273 -0
  45. pypproxy/security/plugin.py +152 -0
  46. pypproxy/security/randomness.py +165 -0
  47. pypproxy/store/__init__.py +0 -0
  48. pypproxy/store/db.py +189 -0
  49. pypproxy/store/filter_parser.py +181 -0
  50. pypproxy/store/fts.py +105 -0
  51. pypproxy/store/models.py +81 -0
  52. pypproxy/store/scope.py +63 -0
  53. pypproxy/store/store.py +120 -0
  54. pypproxy/ui/__init__.py +0 -0
  55. pypproxy/ui/app.py +386 -0
  56. pypproxy/ui/bulk_sender_ui.py +125 -0
  57. pypproxy/ui/cui.py +162 -0
  58. pypproxy/ui/detail.py +179 -0
  59. pypproxy/ui/diff_view.py +118 -0
  60. pypproxy/ui/graphql_tab.py +265 -0
  61. pypproxy/ui/import_tab.py +136 -0
  62. pypproxy/ui/intercept_dialog.py +74 -0
  63. pypproxy/ui/resender.py +140 -0
  64. pypproxy/ui/scan_tab.py +98 -0
  65. pypproxy/ui/security_tab.py +356 -0
  66. pypproxy/ui/settings.py +413 -0
  67. pypproxy/ui/theme.py +59 -0
  68. pypproxy-0.1.0.dist-info/METADATA +19 -0
  69. pypproxy-0.1.0.dist-info/RECORD +72 -0
  70. pypproxy-0.1.0.dist-info/WHEEL +4 -0
  71. pypproxy-0.1.0.dist-info/entry_points.txt +2 -0
  72. pypproxy-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,120 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import contextlib
5
+ import threading
6
+
7
+ from .models import Entry, Filter
8
+
9
+
10
+ class Store:
11
+ """In-memory traffic store with optional SQLite persistence."""
12
+
13
+ def __init__(self) -> None:
14
+ self._entries: list[Entry] = []
15
+ self._by_id: dict[int, Entry] = {}
16
+ self._counter = 0
17
+ self._lock = threading.Lock()
18
+ self._subscribers: list[asyncio.Queue] = []
19
+ self._loop: asyncio.AbstractEventLoop | None = None
20
+ self._db: object | None = None # paxy.store.db.Database
21
+
22
+ def set_loop(self, loop: asyncio.AbstractEventLoop) -> None:
23
+ self._loop = loop
24
+
25
+ def set_db(self, db: object) -> None:
26
+ self._db = db
27
+
28
+ # --- write ---
29
+
30
+ def add(self, entry: Entry) -> Entry:
31
+ with self._lock:
32
+ self._counter += 1
33
+ entry.id = self._counter
34
+ self._entries.append(entry)
35
+ self._by_id[entry.id] = entry
36
+ self._publish(entry)
37
+ self._db_insert(entry)
38
+ return entry
39
+
40
+ def update(self, entry: Entry) -> None:
41
+ with self._lock:
42
+ self._by_id[entry.id] = entry
43
+ # update in-place in list
44
+ for i, e in enumerate(self._entries):
45
+ if e.id == entry.id:
46
+ self._entries[i] = entry
47
+ break
48
+ self._publish(entry)
49
+ self._db_update(entry)
50
+
51
+ def set_color(self, entry_id: int, color: str) -> None:
52
+ with self._lock:
53
+ e = self._by_id.get(entry_id)
54
+ if e:
55
+ e.color = color
56
+ self.update(e)
57
+
58
+ def clear(self) -> None:
59
+ with self._lock:
60
+ self._entries.clear()
61
+ self._by_id.clear()
62
+ if self._loop and self._db:
63
+ asyncio.run_coroutine_threadsafe(self._db.clear(), self._loop)
64
+
65
+ # --- read ---
66
+
67
+ def get(self, entry_id: int) -> Entry | None:
68
+ return self._by_id.get(entry_id)
69
+
70
+ def list(self, f: Filter, offset: int = 0, limit: int = 100) -> tuple[list[Entry], int]:
71
+ with self._lock:
72
+ filtered = [e for e in self._entries if f.matches(e)]
73
+ total = len(filtered)
74
+ if limit == 0:
75
+ return filtered[offset:], total
76
+ return filtered[offset : offset + limit], total
77
+
78
+ # --- pub/sub ---
79
+
80
+ def subscribe(self) -> asyncio.Queue:
81
+ q: asyncio.Queue = asyncio.Queue(maxsize=512)
82
+ with self._lock:
83
+ self._subscribers.append(q)
84
+ return q
85
+
86
+ def unsubscribe(self, q: asyncio.Queue) -> None:
87
+ with self._lock, contextlib.suppress(ValueError):
88
+ self._subscribers.remove(q)
89
+
90
+ def _publish(self, entry: Entry) -> None:
91
+ if self._loop is None:
92
+ return
93
+ with self._lock:
94
+ subs = list(self._subscribers)
95
+ for q in subs:
96
+ with contextlib.suppress(asyncio.QueueFull):
97
+ self._loop.call_soon_threadsafe(q.put_nowait, entry)
98
+
99
+ # --- DB helpers (fire-and-forget) ---
100
+
101
+ def _db_insert(self, entry: Entry) -> None:
102
+ if self._loop and self._db:
103
+ asyncio.run_coroutine_threadsafe(self._db.insert(entry), self._loop)
104
+
105
+ def _db_update(self, entry: Entry) -> None:
106
+ if self._loop and self._db:
107
+ asyncio.run_coroutine_threadsafe(self._db.update(entry), self._loop)
108
+
109
+ # --- restore from DB on startup ---
110
+
111
+ async def load_from_db(self) -> None:
112
+ if not self._db:
113
+ return
114
+ entries, _ = await self._db.list(Filter(), offset=0, limit=0)
115
+ with self._lock:
116
+ for e in entries:
117
+ self._entries.append(e)
118
+ self._by_id[e.id] = e
119
+ if e.id > self._counter:
120
+ self._counter = e.id
File without changes
pypproxy/ui/app.py ADDED
@@ -0,0 +1,386 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+
5
+ from nicegui import app as nicegui_app
6
+ from nicegui import ui
7
+
8
+ from pypproxy.intercept.manager import InterceptManager
9
+ from pypproxy.store.models import Entry, Filter
10
+ from pypproxy.store.store import Store
11
+
12
+ from .detail import render_detail
13
+ from .intercept_dialog import build_intercept_panel
14
+ from .theme import apply_dark_theme
15
+
16
+ _ROW_COLORS = ["", "#b71c1c", "#1b5e20", "#0d47a1", "#f57f17", "#4a148c"]
17
+ _COLOR_LABELS = ["None", "Red", "Green", "Blue", "Yellow", "Purple"]
18
+
19
+
20
+ def build_ui(
21
+ store: Store,
22
+ intercept_mgr: InterceptManager | None = None,
23
+ settings_kwargs: dict | None = None,
24
+ ) -> None:
25
+ if settings_kwargs:
26
+ from .settings import build_settings_page
27
+
28
+ build_settings_page(**settings_kwargs)
29
+
30
+ @ui.page("/")
31
+ async def index() -> None:
32
+ apply_dark_theme()
33
+ ui.dark_mode().enable()
34
+
35
+ state: dict = {
36
+ "entries": [],
37
+ "selected": None,
38
+ "filter": Filter(),
39
+ "compare_left": None,
40
+ }
41
+
42
+ # toolbar
43
+ with ui.header().classes("items-center q-px-md gap-4").style("background:#1a1a2e"):
44
+ ui.label("paxy").classes("text-h6 text-weight-bold")
45
+ ui.badge("●", color="positive").props("rounded").tooltip("Proxy running")
46
+
47
+ filter_input = (
48
+ ui.input(placeholder="Filter: host == example.com && method == POST")
49
+ .props("dense outlined dark")
50
+ .classes("w-96")
51
+ .tooltip(
52
+ "Fields: host path method status protocol request response full_text "
53
+ "Ops: == != contains ~ Logic: && ||"
54
+ )
55
+ )
56
+ ui.space()
57
+
58
+ if intercept_mgr is not None:
59
+ intercept_toggle = (
60
+ ui.switch("Intercept")
61
+ .props("dense dark color=warning")
62
+ .tooltip("Pause requests for manual review")
63
+ )
64
+ intercept_toggle.on(
65
+ "update:model-value",
66
+ lambda e: intercept_mgr.set_enabled(e.args),
67
+ )
68
+
69
+ clear_btn = ui.button("Clear", icon="delete_sweep").props("color=negative size=sm flat")
70
+ ui.button(icon="settings", on_click=lambda: ui.navigate.to("/settings")).props(
71
+ "flat dark size=sm"
72
+ ).tooltip("Settings")
73
+
74
+ # main tabs
75
+ with ui.tabs().props("dense dark").classes("bg-dark") as tabs:
76
+ traffic_tab = ui.tab("Traffic", icon="list")
77
+ resender_tab = ui.tab("Resender", icon="send")
78
+ bulk_tab = ui.tab("Bulk Sender", icon="dynamic_feed")
79
+ diff_tab = ui.tab("Diff", icon="difference")
80
+ security_tab = ui.tab("Security", icon="security")
81
+ scan_tab = ui.tab("Scan", icon="search")
82
+ graphql_tab = ui.tab("GraphQL", icon="account_tree")
83
+ import_tab_btn = ui.tab("Import/Search", icon="upload")
84
+
85
+ with (
86
+ ui.tab_panels(tabs, value=traffic_tab)
87
+ .classes("w-full flex-1")
88
+ .style("height:calc(100vh - 96px)")
89
+ ):
90
+ with (
91
+ ui.tab_panel(traffic_tab).classes("p-0 h-full"),
92
+ ui.splitter(value=60).classes("w-full h-full") as splitter,
93
+ ):
94
+ with splitter.before, ui.column().classes("w-full h-full overflow-auto"):
95
+ table = _build_table()
96
+ with (
97
+ splitter.after,
98
+ ui.column().classes("w-full h-full overflow-auto q-pa-sm") as detail_col,
99
+ ):
100
+ render_detail(None, detail_col)
101
+
102
+ with ui.tab_panel(resender_tab).classes("p-0 h-full"):
103
+ from .resender import build_resender_tab
104
+
105
+ resender_container = ui.column().classes("w-full h-full")
106
+ with resender_container:
107
+ build_resender_tab(store)
108
+
109
+ with ui.tab_panel(bulk_tab).classes("p-0 h-full"):
110
+ from .bulk_sender_ui import build_bulk_sender
111
+
112
+ bulk_container = ui.column().classes("w-full h-full overflow-auto q-pa-md")
113
+ bulk_state = build_bulk_sender(bulk_container)
114
+
115
+ with ui.tab_panel(diff_tab).classes("p-0 h-full"):
116
+ from .diff_view import build_diff_view
117
+
118
+ diff_container = ui.column().classes("w-full h-full overflow-auto q-pa-md")
119
+ diff_state = build_diff_view(diff_container)
120
+
121
+ with ui.tab_panel(security_tab).classes("p-0 h-full"):
122
+ from .security_tab import build_security_tab
123
+
124
+ sec_state = build_security_tab(store)
125
+
126
+ with ui.tab_panel(scan_tab).classes("p-0 h-full"):
127
+ from .scan_tab import build_scan_tab
128
+
129
+ scan_state = build_scan_tab(store)
130
+
131
+ with ui.tab_panel(graphql_tab).classes("p-0 h-full"):
132
+ from .graphql_tab import build_graphql_tab
133
+
134
+ gql_state = build_graphql_tab(store)
135
+
136
+ with ui.tab_panel(import_tab_btn).classes("p-0 h-full"):
137
+ from .import_tab import build_import_tab
138
+
139
+ build_import_tab(store)
140
+
141
+ clear_btn.on("click", lambda: _clear(store, state, table, detail_col))
142
+
143
+ def apply_filter() -> None:
144
+ state["filter"] = Filter(expression=filter_input.value or "")
145
+ _refresh_table(store, state, table)
146
+
147
+ filter_input.on("update:model-value", lambda: apply_filter())
148
+
149
+ async def on_row_click(e) -> None: # noqa: ANN001
150
+ try:
151
+ row = e.args[1] if isinstance(e.args, list) else e.args
152
+ entry_id = int(row["id"])
153
+ except (IndexError, KeyError, TypeError, ValueError):
154
+ return
155
+ entry = store.get(entry_id)
156
+ if entry:
157
+ state["selected"] = entry
158
+ render_detail(entry, detail_col)
159
+
160
+ table.on("row-click", on_row_click)
161
+
162
+ async def on_row_contextmenu(e) -> None: # noqa: ANN001
163
+ try:
164
+ row = e.args[1] if isinstance(e.args, list) else e.args
165
+ entry_id = int(row["id"])
166
+ except (IndexError, KeyError, TypeError, ValueError):
167
+ return
168
+ entry = store.get(entry_id)
169
+ if not entry:
170
+ return
171
+
172
+ with ui.menu() as menu:
173
+ ui.menu_item(
174
+ "Send to Resender",
175
+ on_click=lambda: (
176
+ tabs.set_value(resender_tab),
177
+ ui.notify(f"Opened #{entry.id} in Resender", type="info"),
178
+ ),
179
+ )
180
+ ui.menu_item(
181
+ "Send to Bulk Sender",
182
+ on_click=lambda: (bulk_state["open_entry"](entry), tabs.set_value(bulk_tab)),
183
+ )
184
+ ui.menu_item(
185
+ "Security check",
186
+ on_click=lambda: (sec_state["open_entry"](entry), tabs.set_value(security_tab)),
187
+ )
188
+ ui.menu_item(
189
+ "Active Scan",
190
+ on_click=lambda: (scan_state["open_entry"](entry), tabs.set_value(scan_tab)),
191
+ )
192
+ if "graphql" in (entry.tags or []):
193
+ ui.menu_item(
194
+ "Open in GraphQL tab",
195
+ on_click=lambda: (
196
+ gql_state["open_entry"](entry),
197
+ tabs.set_value(graphql_tab),
198
+ ),
199
+ )
200
+ ui.menu_item(
201
+ "Set as Diff left",
202
+ on_click=lambda: _set_diff_left(entry, state),
203
+ )
204
+ if state.get("compare_left"):
205
+ ui.menu_item(
206
+ "Diff with left",
207
+ on_click=lambda eid=entry_id: _compare(
208
+ eid, state, diff_state, tabs, diff_tab, store
209
+ ),
210
+ )
211
+ ui.separator()
212
+ ui.label("Set color").classes("q-px-sm text-caption text-grey")
213
+ for color, label in zip(_ROW_COLORS, _COLOR_LABELS, strict=False):
214
+
215
+ def _set_color(c=color, eid=entry_id) -> None:
216
+ store.set_color(eid, c)
217
+ _refresh_table(store, state, table)
218
+ menu.close()
219
+
220
+ with ui.menu_item(on_click=_set_color), ui.row().classes("items-center gap-2"):
221
+ if color:
222
+ ui.element("div").style(
223
+ f"width:12px;height:12px;border-radius:2px;background:{color}"
224
+ )
225
+ ui.label(label)
226
+ menu.open()
227
+
228
+ table.on("row-contextmenu", on_row_contextmenu)
229
+
230
+ if intercept_mgr is not None:
231
+ build_intercept_panel(intercept_mgr, detail_col)
232
+
233
+ _refresh_table(store, state, table)
234
+ q = store.subscribe()
235
+
236
+ async def _poller() -> None:
237
+ try:
238
+ while True:
239
+ try:
240
+ entry = q.get_nowait()
241
+ if state["filter"].matches(entry):
242
+ existing = next(
243
+ (i for i, e in enumerate(state["entries"]) if e.id == entry.id),
244
+ None,
245
+ )
246
+ if existing is not None:
247
+ state["entries"][existing] = entry
248
+ else:
249
+ state["entries"].insert(0, entry)
250
+ _update_table_rows(state, table)
251
+ except asyncio.QueueEmpty:
252
+ pass
253
+ await asyncio.sleep(0.2)
254
+ except asyncio.CancelledError:
255
+ store.unsubscribe(q)
256
+
257
+ nicegui_app.on_shutdown(lambda: store.unsubscribe(q))
258
+ asyncio.ensure_future(_poller())
259
+
260
+
261
+ def _set_diff_left(entry: Entry, state: dict) -> None:
262
+ state["compare_left"] = entry
263
+ ui.notify(f"#{entry.id} set as left side", type="info")
264
+
265
+
266
+ def _compare(
267
+ right_id: int,
268
+ state: dict,
269
+ diff_state: dict,
270
+ tabs: ui.tabs,
271
+ tab: ui.tab,
272
+ store: Store,
273
+ ) -> None:
274
+ left = state.get("compare_left")
275
+ right = store.get(right_id)
276
+ if left and right:
277
+ diff_state["set_entries"](left, right)
278
+ tabs.set_value(tab)
279
+
280
+
281
+ def _build_table() -> ui.table:
282
+ columns = [
283
+ {"name": "id", "label": "ID", "field": "id", "align": "right", "style": "width:50px"},
284
+ {
285
+ "name": "method",
286
+ "label": "Method",
287
+ "field": "method",
288
+ "align": "center",
289
+ "style": "width:80px",
290
+ },
291
+ {"name": "host", "label": "Host", "field": "host", "align": "left"},
292
+ {"name": "path", "label": "Path", "field": "path", "align": "left"},
293
+ {
294
+ "name": "status",
295
+ "label": "Status",
296
+ "field": "status_code",
297
+ "align": "center",
298
+ "style": "width:70px",
299
+ },
300
+ {"name": "size", "label": "Size", "field": "size", "align": "right", "style": "width:70px"},
301
+ {
302
+ "name": "duration",
303
+ "label": "ms",
304
+ "field": "duration_ms",
305
+ "align": "right",
306
+ "style": "width:60px",
307
+ },
308
+ {
309
+ "name": "protocol",
310
+ "label": "Proto",
311
+ "field": "protocol",
312
+ "align": "center",
313
+ "style": "width:60px",
314
+ },
315
+ ]
316
+ table = (
317
+ ui.table(columns=columns, rows=[], row_key="id")
318
+ .classes("w-full")
319
+ .props("dense flat dark virtual-scroll")
320
+ )
321
+ table.add_slot(
322
+ "body",
323
+ r"""
324
+ <q-tr :props="props"
325
+ :style="props.row.color ? 'background:' + props.row.color + '33' : ''"
326
+ @click="$emit('row-click', $event, props.row)"
327
+ @contextmenu.prevent="$emit('row-contextmenu', $event, props.row)"
328
+ style="cursor:pointer">
329
+ <q-td key="id" :props="props" class="text-right text-grey">{{ props.row.id }}</q-td>
330
+ <q-td key="method" :props="props">
331
+ <q-badge
332
+ :color="{'GET':'blue','POST':'green','PUT':'orange','PATCH':'purple','DELETE':'red'}[props.row.method] || 'grey'"
333
+ :label="props.row.method" rounded />
334
+ </q-td>
335
+ <q-td key="host" :props="props">{{ props.row.host }}</q-td>
336
+ <q-td key="path" :props="props">{{ props.row.path }}</q-td>
337
+ <q-td key="status" :props="props">
338
+ <q-badge v-if="props.row.status_code"
339
+ :color="props.row.status_code < 300 ? 'positive' : props.row.status_code < 400 ? 'info' : props.row.status_code < 500 ? 'warning' : 'negative'"
340
+ :label="props.row.status_code" rounded />
341
+ </q-td>
342
+ <q-td key="size" :props="props" class="text-right text-grey">{{ props.row.size }}</q-td>
343
+ <q-td key="duration" :props="props" class="text-right text-grey">{{ props.row.duration_ms }}</q-td>
344
+ <q-td key="protocol" :props="props">
345
+ <q-badge color="grey-7" :label="props.row.protocol" rounded />
346
+ </q-td>
347
+ </q-tr>
348
+ """,
349
+ )
350
+ return table
351
+
352
+
353
+ def _refresh_table(store: Store, state: dict, table: ui.table) -> None:
354
+ entries, _ = store.list(state["filter"], 0, 500)
355
+ state["entries"] = list(reversed(entries))
356
+ _update_table_rows(state, table)
357
+
358
+
359
+ def _update_table_rows(state: dict, table: ui.table) -> None:
360
+ table.rows = [_entry_to_row(e) for e in state["entries"]]
361
+ table.update()
362
+
363
+
364
+ def _entry_to_row(e: Entry) -> dict:
365
+ size = len(e.resp_body) if e.resp_body else 0
366
+ return {
367
+ "id": e.id,
368
+ "method": e.method,
369
+ "host": e.host,
370
+ "path": e.path + (f"?{e.query}" if e.query else ""),
371
+ "status_code": e.status_code,
372
+ "size": f"{size:,}" if size else "",
373
+ "duration_ms": e.duration_ms or "",
374
+ "protocol": e.protocol,
375
+ "color": getattr(e, "color", ""),
376
+ }
377
+
378
+
379
+ async def _clear(store: Store, state: dict, table: ui.table, detail_col: ui.element) -> None:
380
+ store.clear()
381
+ state["entries"] = []
382
+ state["selected"] = None
383
+ table.rows = []
384
+ table.update()
385
+ render_detail(None, detail_col)
386
+ ui.notify("Cleared", type="info")
@@ -0,0 +1,125 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+
5
+ from nicegui import ui
6
+
7
+ from pypproxy.bulk.sender import BulkPayload, bulk_send, race_send
8
+ from pypproxy.store.models import Entry
9
+
10
+
11
+ def build_bulk_sender(container: ui.element) -> dict:
12
+ """Build the bulk sender UI. Returns state with open_entry method."""
13
+ state: dict = {"entry": None}
14
+
15
+ container.clear()
16
+ with container:
17
+ ui.label("Bulk Sender").classes("text-subtitle2 q-mb-sm")
18
+ entry_label = ui.label("No entry selected").classes("text-grey text-caption q-mb-sm")
19
+
20
+ ui.separator()
21
+
22
+ # Mode selector
23
+ with ui.row().classes("items-center gap-4 q-mb-sm"):
24
+ mode = (
25
+ ui.select(["Payload list", "Race condition"], value="Payload list", label="Mode")
26
+ .props("dense outlined dark")
27
+ .classes("w-48")
28
+ )
29
+ count_input = (
30
+ ui.number(label="Count", value=10, min=1, max=500)
31
+ .props("dense outlined dark")
32
+ .classes("w-24")
33
+ )
34
+ concurrency_input = (
35
+ ui.number(label="Concurrency", value=10, min=1, max=100)
36
+ .props("dense outlined dark")
37
+ .classes("w-24")
38
+ )
39
+
40
+ # Payload editor (for payload list mode)
41
+ ui.label("Payloads (one per line, JSON or plain text):").classes("text-caption")
42
+ payload_input = (
43
+ ui.textarea(placeholder='{"key": "value1"}\n{"key": "value2"}')
44
+ .props("outlined dense rows=6")
45
+ .classes("w-full font-mono text-xs")
46
+ )
47
+
48
+ send_btn = ui.button("Send", icon="send").props("color=primary")
49
+ ui.separator()
50
+
51
+ # Results table
52
+ results_label = ui.label("").classes("text-caption text-grey")
53
+ results_table = (
54
+ ui.table(
55
+ columns=[
56
+ {"name": "label", "label": "Label", "field": "label", "align": "left"},
57
+ {
58
+ "name": "status",
59
+ "label": "Status",
60
+ "field": "status_code",
61
+ "align": "center",
62
+ },
63
+ {"name": "ms", "label": "ms", "field": "duration_ms", "align": "right"},
64
+ {"name": "error", "label": "Error", "field": "error", "align": "left"},
65
+ ],
66
+ rows=[],
67
+ row_key="label",
68
+ )
69
+ .classes("w-full")
70
+ .props("dense flat dark")
71
+ )
72
+
73
+ async def _send() -> None:
74
+ entry = state.get("entry")
75
+ if not entry:
76
+ ui.notify("No entry selected", type="warning")
77
+ return
78
+
79
+ send_btn.props("loading")
80
+ try:
81
+ if mode.value == "Race condition":
82
+ results = await race_send(
83
+ entry,
84
+ count=int(count_input.value),
85
+ timeout=30,
86
+ )
87
+ else:
88
+ payloads: list[BulkPayload] = []
89
+ for i, line in enumerate(payload_input.value.splitlines()):
90
+ line = line.strip()
91
+ if not line:
92
+ continue
93
+ try:
94
+ obj = json.loads(line)
95
+ body = json.dumps(obj).encode()
96
+ except Exception:
97
+ body = line.encode()
98
+ payloads.append(BulkPayload(label=f"payload-{i}", override_body=body))
99
+ if not payloads:
100
+ ui.notify("No payloads entered", type="warning")
101
+ return
102
+ results = await bulk_send(
103
+ entry,
104
+ payloads,
105
+ concurrency=int(concurrency_input.value),
106
+ )
107
+
108
+ results_table.rows = [r.to_dict() for r in results]
109
+ results_table.update()
110
+ status_counts: dict[int, int] = {}
111
+ for r in results:
112
+ status_counts[r.status_code] = status_counts.get(r.status_code, 0) + 1
113
+ summary = ", ".join(f"{s}: {c}" for s, c in sorted(status_counts.items()))
114
+ results_label.text = f"{len(results)} requests — {summary}"
115
+ ui.notify(f"Done: {len(results)} requests", type="positive")
116
+ finally:
117
+ send_btn.props(remove="loading")
118
+
119
+ send_btn.on("click", _send)
120
+
121
+ def open_entry(entry: Entry) -> None:
122
+ state["entry"] = entry
123
+ entry_label.text = f"#{entry.id} {entry.method} {entry.scheme}://{entry.host}{entry.path}"
124
+
125
+ return {"open_entry": open_entry}