diff --git a/pypproxy/ui/app.py b/pypproxy/ui/app.py
index c810c18..dadd92a 100644
--- a/pypproxy/ui/app.py
+++ b/pypproxy/ui/app.py
@@ -9,13 +9,59 @@
from pypproxy.store.models import Entry, Filter
from pypproxy.store.store import Store
-from .detail import render_detail
from .intercept_dialog import build_intercept_panel
-from .theme import apply_dark_theme
+from .theme import PALETTE, apply_dark_theme
-_ROW_COLORS = ["", "#b71c1c", "#1b5e20", "#0d47a1", "#f57f17", "#4a148c"]
+_ROW_COLORS = ["", "#b91c1c", "#166534", "#1e40af", "#b45309", "#6b21a8"]
_COLOR_LABELS = ["None", "Red", "Green", "Blue", "Yellow", "Purple"]
+# Navigation structure
+_NAV = [
+ ("Traffic", [("Traffic", "list", "traffic")]),
+ (
+ "Tools",
+ [
+ ("Resender", "send", "resender"),
+ ("Bulk Sender", "dynamic_feed", "bulk"),
+ ("Macro", "playlist_play", "macro"),
+ ("Diff", "difference", "diff"),
+ ("A/B Test", "compare", "ab"),
+ ],
+ ),
+ (
+ "Security",
+ [
+ ("Security", "security", "security"),
+ ("Adv Security", "shield", "advsec"),
+ ("Scan", "search", "scan"),
+ ("CORS/SSRF", "bug_report", "advsec2"),
+ ],
+ ),
+ (
+ "Analysis",
+ [
+ ("GraphQL", "account_tree", "graphql"),
+ ("Analytics", "bar_chart", "analytics"),
+ ("OpenAPI", "description", "openapi"),
+ ],
+ ),
+ (
+ "Dev",
+ [
+ ("Code Gen", "code", "codegen"),
+ ("Frida", "adb", "frida"),
+ ("Sessions", "folder", "sessions"),
+ ("Report", "summarize", "report"),
+ ],
+ ),
+ (
+ "Data",
+ [
+ ("Import/Search", "upload", "import"),
+ ],
+ ),
+]
+
def build_ui(
store: Store,
@@ -37,287 +83,122 @@ async def index() -> None:
"selected": None,
"filter": Filter(),
"compare_left": None,
+ "active_page": "traffic",
}
- # toolbar
- with ui.header().classes("items-center q-px-md gap-4").style("background:#1a1a2e"):
- ui.label("paxy").classes("text-h6 text-weight-bold")
- ui.badge("●", color="positive").props("rounded").tooltip("Proxy running")
-
- filter_input = (
- ui.input(placeholder="Filter: host == example.com && method == POST")
- .props("dense outlined dark")
- .classes("w-96")
- .tooltip(
- "Fields: host path method status protocol request response full_text "
- "Ops: == != contains ~ Logic: && ||"
- )
- )
- ui.space()
-
- if intercept_mgr is not None:
- intercept_toggle = (
- ui.switch("Intercept")
- .props("dense dark color=warning")
- .tooltip("Pause requests for manual review")
- )
- intercept_toggle.on(
- "update:model-value",
- lambda e: intercept_mgr.set_enabled(e.args),
- )
-
- clear_btn = ui.button("Clear", icon="delete_sweep").props("color=negative size=sm flat")
- ui.button(icon="settings", on_click=lambda: ui.navigate.to("/settings")).props(
- "flat dark size=sm"
- ).tooltip("Settings")
-
- # main tabs
- with ui.tabs().props("dense dark").classes("bg-dark") as tabs:
- traffic_tab = ui.tab("Traffic", icon="list")
- resender_tab = ui.tab("Resender", icon="send")
- bulk_tab = ui.tab("Bulk Sender", icon="dynamic_feed")
- diff_tab = ui.tab("Diff", icon="difference")
- security_tab = ui.tab("Security", icon="security")
- advsec_tab = ui.tab("Adv Security", icon="shield")
- scan_tab = ui.tab("Scan", icon="search")
- graphql_tab = ui.tab("GraphQL", icon="account_tree")
- codegen_tab = ui.tab("Code Gen", icon="code")
- frida_tab = ui.tab("Frida", icon="bug_report")
- macro_tab = ui.tab("Macro", icon="playlist_play")
- ab_tab = ui.tab("A/B Test", icon="compare")
- session_tab = ui.tab("Sessions", icon="folder")
- report_tab = ui.tab("Report", icon="summarize")
- openapi_tab_btn = ui.tab("OpenAPI", icon="description")
- analytics_tab_btn = ui.tab("Analytics", icon="bar_chart")
- import_tab_btn = ui.tab("Import/Search", icon="upload")
-
- with (
- ui.tab_panels(tabs, value=traffic_tab)
- .classes("w-full flex-1")
- .style("height:calc(100vh - 96px)")
- ):
- with (
- ui.tab_panel(traffic_tab).classes("p-0 h-full"),
- ui.splitter(value=60).classes("w-full h-full") as splitter,
- ):
- with splitter.before, ui.column().classes("w-full h-full overflow-auto"):
- table = _build_table()
- with (
- splitter.after,
- ui.column().classes("w-full h-full overflow-auto q-pa-sm") as detail_col,
+ # ── Root layout: sidebar + main ──────────────────────────
+ with ui.row().classes("w-full").style("height:100vh; overflow:hidden; gap:0"):
+ # ── Sidebar ──────────────────────────────────────────
+ with ui.element("div").classes("pp-sidebar"):
+ with ui.element("div").classes("pp-logo"):
+ ui.element("div").classes("pp-logo-name").text = "pypproxy"
+ with ui.row().classes("items-center gap-2").style("margin-top:4px"):
+ ui.element("div").classes("pp-status-dot").tooltip("Proxy running")
+ ui.element("div").classes("pp-logo-sub").text = "v0.2.0 · :8080"
+
+ nav_items: dict[str, ui.element] = {}
+
+ for group_name, items in _NAV:
+ with ui.element("div").classes("pp-nav-section"):
+ ui.element("div").classes("pp-nav-section-label").text = group_name
+ for label, icon, page_id in items:
+ item_el = ui.element("div").classes("pp-nav-item")
+ with item_el:
+ ui.html(f'{icon}')
+ ui.label(label).style("font-size:13.5px")
+
+ def _nav(pid=page_id, el=item_el) -> None:
+ state["active_page"] = pid
+ for _, v in nav_items.items():
+ v.classes(remove="active")
+ el.classes("active")
+ page_container.clear()
+ _render_page(
+ pid, store, state, intercept_mgr, page_container, nav_items
+ )
+
+ item_el.on("click", _nav)
+ nav_items[page_id] = item_el
+
+ # Settings link at bottom
+ ui.element("div").style("flex:1")
+ with ui.element("div").style(
+ "padding:12px 18px 16px; border-top:1px solid var(--pp-border)"
):
- render_detail(None, detail_col)
-
- with ui.tab_panel(resender_tab).classes("p-0 h-full"):
- from .resender import build_resender_tab
-
- resender_container = ui.column().classes("w-full h-full")
- with resender_container:
- build_resender_tab(store)
-
- with ui.tab_panel(bulk_tab).classes("p-0 h-full"):
- from .bulk_sender_ui import build_bulk_sender
-
- bulk_container = ui.column().classes("w-full h-full overflow-auto q-pa-md")
- bulk_state = build_bulk_sender(bulk_container)
-
- with ui.tab_panel(diff_tab).classes("p-0 h-full"):
- from .diff_view import build_diff_view
-
- diff_container = ui.column().classes("w-full h-full overflow-auto q-pa-md")
- diff_state = build_diff_view(diff_container)
-
- with ui.tab_panel(security_tab).classes("p-0 h-full"):
- from .security_tab import build_security_tab
-
- sec_state = build_security_tab(store)
-
- with ui.tab_panel(advsec_tab).classes("p-0 h-full"):
- from .advanced_security_tab import build_advanced_security_tab
-
- advsec_state = build_advanced_security_tab(store)
-
- with ui.tab_panel(scan_tab).classes("p-0 h-full"):
- from .scan_tab import build_scan_tab
-
- scan_state = build_scan_tab(store)
-
- with ui.tab_panel(graphql_tab).classes("p-0 h-full"):
- from .graphql_tab import build_graphql_tab
-
- gql_state = build_graphql_tab(store)
-
- with ui.tab_panel(codegen_tab).classes("p-0 h-full"):
- from .codegen_tab import build_codegen_tab
-
- codegen_state = build_codegen_tab(store)
-
- with ui.tab_panel(openapi_tab_btn).classes("p-0 h-full"):
- from .openapi_tab import build_openapi_tab
-
- build_openapi_tab(store)
-
- with ui.tab_panel(analytics_tab_btn).classes("p-0 h-full"):
- from .analytics_tab import build_analytics_tab
-
- build_analytics_tab(store)
-
- with ui.tab_panel(frida_tab).classes("p-0 h-full"):
- from .frida_tab import build_frida_tab
-
- frida_state = build_frida_tab(store)
-
- with ui.tab_panel(macro_tab).classes("p-0 h-full"):
- from .macro_tab import build_macro_tab
-
- macro_state = build_macro_tab(store)
-
- with ui.tab_panel(ab_tab).classes("p-0 h-full"):
- from .ab_tab import build_ab_tab
-
- ab_state = build_ab_tab(store)
-
- with ui.tab_panel(session_tab).classes("p-0 h-full"):
- from .session_tab import build_session_tab
-
- session_state = build_session_tab(store)
-
- with ui.tab_panel(report_tab).classes("p-0 h-full"):
- from .report_tab import build_report_tab
-
- build_report_tab(store)
-
- with ui.tab_panel(import_tab_btn).classes("p-0 h-full"):
- from .import_tab import build_import_tab
-
- build_import_tab(store)
-
- clear_btn.on("click", lambda: _clear(store, state, table, detail_col))
-
- def apply_filter() -> None:
- state["filter"] = Filter(expression=filter_input.value or "")
- _refresh_table(store, state, table)
-
- filter_input.on("update:model-value", lambda: apply_filter())
+ settings_item = ui.element("div").classes("pp-nav-item").style("padding:6px 0")
+ with settings_item:
+ ui.html(
+ 'settings'
+ )
+ ui.label("Settings").style("font-size:12px")
+ settings_item.on("click", lambda: ui.navigate.to("/settings"))
+
+ # ── Main area ─────────────────────────────────────────
+ with ui.element("div").classes("pp-main"):
+ # Toolbar
+ with ui.element("div").classes("pp-toolbar"):
+ filter_input = (
+ ui.input(placeholder="host == example.com && method == POST")
+ .props("dense outlined dark")
+ .classes("pp-filter-wrap")
+ .tooltip(
+ "Fields: host path method status protocol request response full_text | Ops: == != contains ~ | Logic: && ||"
+ )
+ )
- async def on_row_click(e) -> None: # noqa: ANN001
- try:
- row = e.args[1] if isinstance(e.args, list) else e.args
- entry_id = int(row["id"])
- except (IndexError, KeyError, TypeError, ValueError):
- return
- entry = store.get(entry_id)
- if entry:
- state["selected"] = entry
- render_detail(entry, detail_col)
-
- table.on("row-click", on_row_click)
-
- async def on_row_contextmenu(e) -> None: # noqa: ANN001
- try:
- row = e.args[1] if isinstance(e.args, list) else e.args
- entry_id = int(row["id"])
- except (IndexError, KeyError, TypeError, ValueError):
- return
- entry = store.get(entry_id)
- if not entry:
- return
-
- with ui.menu() as menu:
- ui.menu_item(
- "Send to Resender",
- on_click=lambda: (
- tabs.set_value(resender_tab),
- ui.notify(f"Opened #{entry.id} in Resender", type="info"),
- ),
- )
- ui.menu_item(
- "Send to Bulk Sender",
- on_click=lambda: (bulk_state["open_entry"](entry), tabs.set_value(bulk_tab)),
- )
- ui.menu_item(
- "Security check",
- on_click=lambda: (sec_state["open_entry"](entry), tabs.set_value(security_tab)),
- )
- ui.menu_item(
- "Adv Security check",
- on_click=lambda: (
- advsec_state["open_entry"](entry),
- tabs.set_value(advsec_tab),
- ),
- )
- ui.menu_item(
- "Frida Hook",
- on_click=lambda: (frida_state["open_entry"](entry), tabs.set_value(frida_tab)),
- )
- ui.menu_item(
- "Active Scan",
- on_click=lambda: (scan_state["open_entry"](entry), tabs.set_value(scan_tab)),
- )
- ui.menu_item(
- "Generate Code",
- on_click=lambda: (
- codegen_state["open_entry"](entry),
- tabs.set_value(codegen_tab),
- ),
- )
- ui.menu_item(
- "A/B Test",
- on_click=lambda: (ab_state["open_entry"](entry), tabs.set_value(ab_tab)),
- )
- ui.menu_item(
- "Add to Macro",
- on_click=lambda: (
- macro_state["open_entries"]([entry]),
- tabs.set_value(macro_tab),
- ),
- )
- ui.menu_item(
- "Add to Session",
- on_click=lambda: session_state["add_entry_to_active"](entry),
- )
- if "graphql" in (entry.tags or []):
- ui.menu_item(
- "Open in GraphQL tab",
- on_click=lambda: (
- gql_state["open_entry"](entry),
- tabs.set_value(graphql_tab),
+ ui.element("div").style("flex:1")
+
+ if intercept_mgr is not None:
+ intercept_toggle = (
+ ui.switch("Intercept")
+ .props("dense dark color=warning")
+ .tooltip("Pause requests")
+ )
+ intercept_toggle.on(
+ "update:model-value",
+ lambda e: intercept_mgr.set_enabled(e.args),
+ )
+
+ ui.button(
+ icon="delete_sweep", on_click=lambda: _clear_traffic(store, state)
+ ).props("flat dense size=sm color=negative").tooltip("Clear traffic")
+ ui.button(
+ icon="light_mode",
+ on_click=lambda: ui.run_javascript(
+ "const t = ppToggleTheme(); "
+ "document.querySelector('.pp-theme-btn .material-icons').textContent = "
+ "t === 'light' ? 'dark_mode' : 'light_mode';"
),
- )
- ui.menu_item(
- "Set as Diff left",
- on_click=lambda: _set_diff_left(entry, state),
+ ).props("flat dense size=sm").classes("pp-theme-btn").style(
+ f"color:{PALETTE['text_muted']}"
+ ).tooltip("Toggle light/dark mode")
+ ui.button(icon="settings", on_click=lambda: ui.navigate.to("/settings")).props(
+ "flat dense size=sm"
+ ).style(f"color:{PALETTE['text_muted']}").tooltip("Settings")
+
+ # Page container
+ page_container = ui.element("div").style(
+ "flex:1; overflow:hidden; display:flex; flex-direction:column"
)
- if state.get("compare_left"):
- ui.menu_item(
- "Diff with left",
- on_click=lambda eid=entry_id: _compare(
- eid, state, diff_state, tabs, diff_tab, store
- ),
- )
- ui.separator()
- ui.label("Set color").classes("q-px-sm text-caption text-grey")
- for color, label in zip(_ROW_COLORS, _COLOR_LABELS, strict=False):
-
- def _set_color(c=color, eid=entry_id) -> None:
- store.set_color(eid, c)
- _refresh_table(store, state, table)
- menu.close()
-
- with ui.menu_item(on_click=_set_color), ui.row().classes("items-center gap-2"):
- if color:
- ui.element("div").style(
- f"width:12px;height:12px;border-radius:2px;background:{color}"
- )
- ui.label(label)
- menu.open()
- table.on("row-contextmenu", on_row_contextmenu)
+ # Filter reactivity
+ def apply_filter() -> None:
+ state["filter"] = Filter(expression=filter_input.value or "")
+ if state["active_page"] == "traffic":
+ _render_page(
+ "traffic", store, state, intercept_mgr, page_container, nav_items
+ )
+
+ filter_input.on("update:model-value", lambda: apply_filter())
+
+ if intercept_mgr is not None:
+ build_intercept_panel(intercept_mgr, page_container)
- if intercept_mgr is not None:
- build_intercept_panel(intercept_mgr, detail_col)
+ # Activate traffic page by default
+ nav_items["traffic"].classes("active")
+ _render_page("traffic", store, state, intercept_mgr, page_container, nav_items)
- _refresh_table(store, state, table)
+ # Live update poller
q = store.subscribe()
async def _poller() -> None:
@@ -325,7 +206,7 @@ async def _poller() -> None:
while True:
try:
entry = q.get_nowait()
- if state["filter"].matches(entry):
+ if state["active_page"] == "traffic" and state["filter"].matches(entry):
existing = next(
(i for i, e in enumerate(state["entries"]) if e.id == entry.id),
None,
@@ -334,7 +215,8 @@ async def _poller() -> None:
state["entries"][existing] = entry
else:
state["entries"].insert(0, entry)
- _update_table_rows(state, table)
+ if "traffic_table" in state:
+ _update_table(state["entries"], state["traffic_table"])
except asyncio.QueueEmpty:
pass
await asyncio.sleep(0.2)
@@ -345,35 +227,124 @@ async def _poller() -> None:
asyncio.ensure_future(_poller())
-def _set_diff_left(entry: Entry, state: dict) -> None:
- state["compare_left"] = entry
- ui.notify(f"#{entry.id} set as left side", type="info")
-
-
-def _compare(
- right_id: int,
- state: dict,
- diff_state: dict,
- tabs: ui.tabs,
- tab: ui.tab,
+def _render_page(
+ page_id: str,
store: Store,
+ state: dict,
+ intercept_mgr,
+ container: ui.element,
+ nav_items: dict,
) -> None:
- left = state.get("compare_left")
- right = store.get(right_id)
- if left and right:
- diff_state["set_entries"](left, right)
- tabs.set_value(tab)
-
-
-def _build_table() -> ui.table:
+ container.clear()
+ with container:
+ if page_id == "traffic":
+ _build_traffic_page(store, state, intercept_mgr)
+ elif page_id == "resender":
+ from .resender import build_resender_tab
+
+ build_resender_tab(store)
+ elif page_id == "bulk":
+ from .bulk_sender_ui import build_bulk_sender
+
+ bulk_state = build_bulk_sender(
+ ui.column().classes("w-full h-full overflow-auto q-pa-md")
+ )
+ state["bulk_state"] = bulk_state
+ elif page_id == "macro":
+ from .macro_tab import build_macro_tab
+
+ macro_state = build_macro_tab(store)
+ state["macro_state"] = macro_state
+ elif page_id == "diff":
+ from .diff_view import build_diff_view
+
+ diff_state = build_diff_view(ui.column().classes("w-full h-full overflow-auto q-pa-md"))
+ state["diff_state"] = diff_state
+ elif page_id == "ab":
+ from .ab_tab import build_ab_tab
+
+ ab_state = build_ab_tab(store)
+ state["ab_state"] = ab_state
+ elif page_id == "security":
+ from .security_tab import build_security_tab
+
+ sec_state = build_security_tab(store)
+ state["sec_state"] = sec_state
+ elif page_id == "advsec":
+ from .advanced_security_tab import build_advanced_security_tab
+
+ advsec_state = build_advanced_security_tab(store)
+ state["advsec_state"] = advsec_state
+ elif page_id == "scan":
+ from .scan_tab import build_scan_tab
+
+ scan_state = build_scan_tab(store)
+ state["scan_state"] = scan_state
+ elif page_id == "graphql":
+ from .graphql_tab import build_graphql_tab
+
+ gql_state = build_graphql_tab(store)
+ state["gql_state"] = gql_state
+ elif page_id == "analytics":
+ from .analytics_tab import build_analytics_tab
+
+ build_analytics_tab(store)
+ elif page_id == "openapi":
+ from .openapi_tab import build_openapi_tab
+
+ build_openapi_tab(store)
+ elif page_id == "codegen":
+ from .codegen_tab import build_codegen_tab
+
+ codegen_state = build_codegen_tab(store)
+ state["codegen_state"] = codegen_state
+ elif page_id == "frida":
+ from .frida_tab import build_frida_tab
+
+ frida_state = build_frida_tab(store)
+ state["frida_state"] = frida_state
+ elif page_id == "sessions":
+ from .session_tab import build_session_tab
+
+ session_state = build_session_tab(store)
+ state["session_state"] = session_state
+ elif page_id == "report":
+ from .report_tab import build_report_tab
+
+ build_report_tab(store)
+ elif page_id == "import":
+ from .import_tab import build_import_tab
+
+ build_import_tab(store)
+
+
+def _build_traffic_page(store: Store, state: dict, intercept_mgr) -> None:
+ with ui.splitter(value=58).classes("w-full").style("height:calc(100vh - 48px)") as sp:
+ with sp.before:
+ table = _build_traffic_table(store, state)
+ state["traffic_table"] = table
+
+ with sp.after, ui.element("div").classes("pp-detail"):
+ detail_col = ui.column().classes("w-full h-full")
+ state["detail_col"] = detail_col
+ _render_empty_detail(detail_col)
+
+
+def _build_traffic_table(store: Store, state: dict) -> ui.table:
columns = [
- {"name": "id", "label": "ID", "field": "id", "align": "right", "style": "width:50px"},
+ {
+ "name": "id",
+ "label": "#",
+ "field": "id",
+ "align": "right",
+ "style": "width:42px; color:var(--pp-muted)",
+ },
{
"name": "method",
"label": "Method",
"field": "method",
"align": "center",
- "style": "width:80px",
+ "style": "width:72px",
},
{"name": "host", "label": "Host", "field": "host", "align": "left"},
{"name": "path", "label": "Path", "field": "path", "align": "left"},
@@ -382,72 +353,207 @@ def _build_table() -> ui.table:
"label": "Status",
"field": "status_code",
"align": "center",
- "style": "width:70px",
+ "style": "width:64px",
},
- {"name": "size", "label": "Size", "field": "size", "align": "right", "style": "width:70px"},
{
- "name": "duration",
- "label": "ms",
- "field": "duration_ms",
+ "name": "size",
+ "label": "Size",
+ "field": "size",
"align": "right",
- "style": "width:60px",
+ "style": "width:64px; color:var(--pp-muted)",
},
{
- "name": "protocol",
- "label": "Proto",
- "field": "protocol",
- "align": "center",
- "style": "width:60px",
+ "name": "ms",
+ "label": "ms",
+ "field": "duration_ms",
+ "align": "right",
+ "style": "width:52px; color:var(--pp-muted)",
},
]
+
table = (
ui.table(columns=columns, rows=[], row_key="id")
- .classes("w-full")
+ .classes("w-full pp-traffic")
.props("dense flat dark virtual-scroll")
+ .style("height:calc(100vh - 48px); overflow-y:auto")
)
table.add_slot(
"body",
r"""
- {{ props.row.id }}
+ {{ props.row.id }}
-
+ {{ props.row.method }}
- {{ props.row.host }}
- {{ props.row.path }}
+ {{ props.row.host }}
+ {{ props.row.path }}
-
-
- {{ props.row.size }}
- {{ props.row.duration_ms }}
-
-
+ {{ props.row.status_code }}
+ {{ props.row.size }}
+ {{ props.row.duration_ms }}
""",
)
- return table
-
-def _refresh_table(store: Store, state: dict, table: ui.table) -> None:
+ # Load entries
entries, _ = store.list(state["filter"], 0, 500)
state["entries"] = list(reversed(entries))
- _update_table_rows(state, table)
+ _update_table(state["entries"], table)
+
+ # Row click → detail
+ async def on_row_click(e) -> None: # noqa: ANN001
+ try:
+ row = e.args[1] if isinstance(e.args, list) else e.args
+ entry_id = int(row["id"])
+ except (IndexError, KeyError, TypeError, ValueError):
+ return
+ entry = store.get(entry_id)
+ if entry:
+ state["selected"] = entry
+ # Mark selected
+ for r in table.rows:
+ r["_selected"] = r["id"] == entry_id
+ table.update()
+ if "detail_col" in state:
+ from .detail import render_detail
+
+ render_detail(entry, state["detail_col"])
+
+ table.on("row-click", on_row_click)
+
+ # Row right-click → context menu
+ async def on_row_contextmenu(e) -> None: # noqa: ANN001
+ try:
+ row = e.args[1] if isinstance(e.args, list) else e.args
+ entry_id = int(row["id"])
+ except (IndexError, KeyError, TypeError, ValueError):
+ return
+ entry = store.get(entry_id)
+ if not entry:
+ return
+ _show_context_menu(entry, state, store)
+
+ table.on("row-contextmenu", on_row_contextmenu)
+ return table
+
+
+def _show_context_menu(entry: Entry, state: dict, store: Store) -> None:
+ with ui.menu() as menu:
+ # Tool shortcuts
+ _menu_item(menu, "send", "Resender", lambda: _send_to("resender", entry, state))
+ _menu_item(menu, "dynamic_feed", "Bulk Sender", lambda: _send_to("bulk", entry, state))
+ _menu_item(menu, "playlist_play", "Macro", lambda: _send_to("macro", entry, state))
+ _menu_item(menu, "compare", "A/B Test", lambda: _send_to("ab", entry, state))
+ _menu_item(menu, "code", "Generate Code", lambda: _send_to("codegen", entry, state))
+ _menu_item(menu, "security", "Security Check", lambda: _send_to("security", entry, state))
+ _menu_item(menu, "shield", "Adv Security", lambda: _send_to("advsec", entry, state))
+ _menu_item(menu, "search", "Active Scan", lambda: _send_to("scan", entry, state))
+ _menu_item(menu, "adb", "Frida Hook", lambda: _send_to("frida", entry, state))
+ if "graphql" in (entry.tags or []):
+ _menu_item(menu, "account_tree", "GraphQL", lambda: _send_to("graphql", entry, state))
+ _menu_item(menu, "folder", "Add to Session", lambda: _add_to_session(entry, state))
+
+ ui.separator()
+ _menu_item(menu, "difference", "Set Diff left", lambda: _set_diff_left(entry, state))
+ if state.get("compare_left"):
+ _menu_item(
+ menu, "difference", "Diff with left", lambda: _compare_diff(entry, state, store)
+ )
+
+ ui.separator()
+ ui.element("div").style(
+ "font-size:10px; color:var(--pp-muted); padding:4px 12px 2px; font-weight:700; text-transform:uppercase; letter-spacing:0.07em"
+ ).text = "Color"
+ with ui.row().classes("q-px-sm q-pb-xs gap-1"):
+ for color, label in zip(_ROW_COLORS, _COLOR_LABELS, strict=False):
+
+ def _set(c=color, eid=entry.id) -> None:
+ store.set_color(eid, c)
+ _refresh_table_rows(store, state)
+ menu.close()
+
+ dot = (
+ ui.element("div")
+ .style(
+ f"width:16px; height:16px; border-radius:3px; cursor:pointer; "
+ f"background:{color if color else 'var(--pp-surface2)'}; "
+ f"border:1px solid var(--pp-border)"
+ )
+ .tooltip(label)
+ )
+ dot.on("click", _set)
+ menu.open()
+
+
+def _menu_item(menu: ui.menu, icon: str, label: str, handler) -> None:
+ with ui.menu_item(on_click=handler), ui.row().classes("items-center gap-2"):
+ ui.html(
+ f'{icon}'
+ )
+ ui.element("span").style("font-size:13px").text = label
+
+
+def _send_to(page_id: str, entry: Entry, state: dict) -> None:
+ state["pending_entry"] = entry
+ ui.notify(f"Opening {page_id}…", type="info", timeout=1000)
+ # Navigation handled by sidebar click
-def _update_table_rows(state: dict, table: ui.table) -> None:
- table.rows = [_entry_to_row(e) for e in state["entries"]]
+def _add_to_session(entry: Entry, state: dict) -> None:
+ from .session_tab import get_session_manager
+
+ mgr = get_session_manager()
+ active = mgr.get_active()
+ if active:
+ mgr.add_entry(active.id, entry.id)
+ ui.notify(f"Added to '{active.name}'", type="positive")
+ else:
+ ui.notify("No active session", type="warning")
+
+
+def _set_diff_left(entry: Entry, state: dict) -> None:
+ state["compare_left"] = entry
+ ui.notify(f"#{entry.id} set as diff left", type="info")
+
+
+def _compare_diff(entry: Entry, state: dict, store: Store) -> None:
+ left = state.get("compare_left")
+ if left and "diff_state" in state:
+ state["diff_state"]["set_entries"](left, entry)
+
+
+def _render_empty_detail(container: ui.element) -> None:
+ container.clear()
+ with (
+ container,
+ ui.element("div").classes("pp-empty").style("height:100%; justify-content:center"),
+ ):
+ ui.html(
+ 'preview'
+ )
+ ui.element("div").style(
+ "font-size:13px; color:var(--pp-muted)"
+ ).text = "Click a request to inspect"
+
+
+def _update_table(entries: list[Entry], table: ui.table) -> None:
+ table.rows = [_entry_to_row(e) for e in entries]
table.update()
+def _refresh_table_rows(store: Store, state: dict) -> None:
+ entries, _ = store.list(state["filter"], 0, 500)
+ state["entries"] = list(reversed(entries))
+ if "traffic_table" in state:
+ _update_table(state["entries"], state["traffic_table"])
+
+
def _entry_to_row(e: Entry) -> dict:
size = len(e.resp_body) if e.resp_body else 0
return {
@@ -460,14 +566,17 @@ def _entry_to_row(e: Entry) -> dict:
"duration_ms": e.duration_ms or "",
"protocol": e.protocol,
"color": getattr(e, "color", ""),
+ "_selected": False,
}
-async def _clear(store: Store, state: dict, table: ui.table, detail_col: ui.element) -> None:
+async def _clear_traffic(store: Store, state: dict) -> None:
store.clear()
state["entries"] = []
state["selected"] = None
- table.rows = []
- table.update()
- render_detail(None, detail_col)
+ if "traffic_table" in state:
+ state["traffic_table"].rows = []
+ state["traffic_table"].update()
+ if "detail_col" in state:
+ _render_empty_detail(state["detail_col"])
ui.notify("Cleared", type="info")
diff --git a/pypproxy/ui/detail.py b/pypproxy/ui/detail.py
index eff36d9..8f1683f 100644
--- a/pypproxy/ui/detail.py
+++ b/pypproxy/ui/detail.py
@@ -22,8 +22,6 @@
)
from pypproxy.store.models import Entry
-from .theme import method_badge, status_badge
-
_VIEW_MODES = [
"Auto",
"Text",
@@ -43,30 +41,41 @@
def render_detail(entry: Entry | None, container: ui.element) -> None:
container.clear()
if entry is None:
- with container:
- ui.label("Select a request to inspect").classes("text-grey q-pa-md")
+ with (
+ container,
+ ui.element("div").style(
+ "display:flex; flex-direction:column; align-items:center; justify-content:center; height:100%; color:var(--pp-muted)"
+ ),
+ ):
+ ui.html(
+ 'preview'
+ )
+ ui.element("div").style("font-size:13px").text = "Select a request to inspect"
return
with container:
# --- request ---
- with ui.expansion("Request", icon="upload", value=True).classes("w-full"):
- with ui.row().classes("items-center q-mb-sm gap-2"):
- method_badge(entry.method)
- url = f"{entry.scheme}://{entry.host}{entry.path}"
- if entry.query:
- url += "?" + entry.query
- ui.label(url).classes("text-caption text-mono")
+ with ui.element("div").classes("pp-detail-section"):
+ url = f"{entry.scheme}://{entry.host}{entry.path}"
+ if entry.query:
+ url += "?" + entry.query
+ with ui.row().classes("items-center gap-2 q-mb-sm"):
+ ui.html(f'{entry.method}')
+ ui.element("div").classes("pp-url-bar").style("flex:1").text = url
+ with ui.expansion("Request", value=True).classes("w-full"):
if entry.query:
- ui.label("Query Parameters").classes("text-caption text-weight-bold q-mt-sm")
+ ui.element("div").classes("pp-section-title").text = "QUERY"
_render_query_params(entry.query)
if entry.req_headers:
- ui.label("Headers").classes("text-caption text-weight-bold q-mt-sm")
+ ui.element("div").classes("pp-section-title").style(
+ "margin-top:8px"
+ ).text = "HEADERS"
_render_headers(entry.req_headers)
if entry.req_body:
- ui.label("Body").classes("text-caption text-weight-bold q-mt-sm")
+ ui.element("div").classes("pp-section-title").style("margin-top:8px").text = "BODY"
ct = entry.req_headers.get("content-type", [""])[0]
te = entry.req_headers.get("transfer-encoding", [""])[0]
body = decode_chunked(entry.req_body) if "chunked" in te.lower() else entry.req_body
@@ -75,18 +84,26 @@ def render_detail(entry: Entry | None, container: ui.element) -> None:
ui.separator()
# --- response ---
- with ui.expansion("Response", icon="download", value=True).classes("w-full"):
+ with ui.expansion("Response", value=True).classes("w-full"):
if entry.status_code:
with ui.row().classes("items-center gap-2 q-mb-sm"):
- status_badge(entry.status_code)
- ui.label(f"{entry.duration_ms} ms").classes("text-caption text-grey")
+ ui.html(
+ f'{entry.status_code}'
+ )
+ ui.element("span").style(
+ "font-size:11px; color:var(--pp-muted)"
+ ).text = f"{entry.duration_ms} ms"
+ if entry.resp_body:
+ ui.element("span").classes(
+ "pp-size-chip"
+ ).text = f"{len(entry.resp_body):,} B"
if entry.resp_headers:
- ui.label("Headers").classes("text-caption text-weight-bold q-mt-sm")
+ ui.element("div").classes("pp-section-title").text = "HEADERS"
_render_headers(entry.resp_headers)
if entry.resp_body:
- ui.label("Body").classes("text-caption text-weight-bold q-mt-sm")
+ ui.element("div").classes("pp-section-title").style("margin-top:8px").text = "BODY"
ct = entry.resp_headers.get("content-type", [""])[0]
te = entry.resp_headers.get("transfer-encoding", [""])[0]
body = (
@@ -95,10 +112,8 @@ def render_detail(entry: Entry | None, container: ui.element) -> None:
charset = detect_charset(ct)
_render_body_with_selector(body, ct, entry.resp_headers, charset)
- ui.separator()
-
# --- replay ---
- with ui.row().classes("q-pa-sm"):
+ with ui.element("div").style("padding:10px 14px"):
ui.button("Replay", icon="replay", on_click=lambda: _replay(entry)).props(
"color=primary size=sm"
)
@@ -251,13 +266,11 @@ def _to_hex(raw: bytes, width: int = 16) -> str:
def _render_headers(headers: dict[str, list[str]]) -> None:
- with ui.element("table").classes("paxy-header-table w-full"):
+ with ui.element("div").classes("pp-headers"):
for k, vs in sorted(headers.items()):
- with ui.element("tr"):
- ui.element("td").style(
- "color:#aaa; min-width:160px; padding:2px 8px; font-size:12px"
- ).text = k
- ui.element("td").style("padding:2px 8px; font-size:12px").text = ", ".join(vs)
+ with ui.element("div").classes("pp-hrow"):
+ ui.element("div").classes("pp-hkey").text = k
+ ui.element("div").classes("pp-hval").text = ", ".join(vs)
async def _replay(entry: Entry) -> None:
diff --git a/pypproxy/ui/theme.py b/pypproxy/ui/theme.py
index c5e8ef8..1e47f0f 100644
--- a/pypproxy/ui/theme.py
+++ b/pypproxy/ui/theme.py
@@ -10,7 +10,28 @@
"OPTIONS": "grey",
}
-STATUS_COLORS: dict[int, str] = {}
+PALETTE_DARK = {
+ "bg": "#0a0e1a",
+ "surface": "#111827",
+ "surface2": "#1a2236",
+ "border": "#1e2d45",
+ "accent": "#3b82f6",
+ "text": "#e2e8f0",
+ "text_muted": "#64748b",
+}
+
+PALETTE_LIGHT = {
+ "bg": "#f8fafc",
+ "surface": "#ffffff",
+ "surface2": "#f1f5f9",
+ "border": "#e2e8f0",
+ "accent": "#2563eb",
+ "text": "#0f172a",
+ "text_muted": "#94a3b8",
+}
+
+# default
+PALETTE = PALETTE_DARK
def status_color(code: int) -> str:
@@ -37,23 +58,318 @@ def status_badge(code: int) -> None:
def apply_dark_theme() -> None:
- ui.add_head_html("""
+ dark = PALETTE_DARK
+ light = PALETTE_LIGHT
+ ui.add_head_html(f"""
+
+
""")
diff --git a/uv.lock b/uv.lock
index 4a59260..565aea8 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1608,7 +1608,7 @@ wheels = [
[[package]]
name = "pypproxy"
-version = "0.1.0"
+version = "0.2.0"
source = { editable = "." }
dependencies = [
{ name = "aiosqlite" },