From d45ea887b7793aacae1ad2dc5c9655122edc9c1c Mon Sep 17 00:00:00 2001 From: Sahil Sharma Date: Fri, 5 Jun 2026 17:57:19 -0400 Subject: [PATCH 01/11] style(ui): implement UMass Maroon and Dark Charcoal branding theme --- frontend/config.py | 4 +- frontend/constants.py | 1 + frontend/design_tokens.py | 87 ++++++------- frontend/icons/favicon.png | Bin 0 -> 106325 bytes frontend/icons/logo.png | Bin 0 -> 45434 bytes frontend/utils/ui_readability_css.py | 182 +++++++++++++++++++-------- 6 files changed, 174 insertions(+), 100 deletions(-) create mode 100644 frontend/icons/favicon.png create mode 100644 frontend/icons/logo.png diff --git a/frontend/config.py b/frontend/config.py index 1ae96765..d82a74bf 100644 --- a/frontend/config.py +++ b/frontend/config.py @@ -29,8 +29,8 @@ APP_TITLE = os.getenv("RESCUEBOX_APP_TITLE", "RescueBox") APP_PORT = int(os.getenv("RESCUEBOX_PORT", "8080")) APP_VERSION = os.getenv("RESCUEBOX_VERSION", "3.0.0") -# Tab icon: filesystem path so NiceGUI can serve it at /favicon.ico (webp is fine for modern browsers) -APP_FAVICON = Path(__file__).resolve().parent / "icons" / "rb.webp" +# Tab icon: filesystem path so NiceGUI can serve it at /favicon.ico +APP_FAVICON = Path(__file__).resolve().parent / "icons" / "favicon.png" APP_DARK_MODE = os.getenv("RESCUEBOX_DARK_MODE", "false").lower() == "true" APP_SHOW_BROWSER = os.getenv("RESCUEBOX_SHOW_BROWSER", "false").lower() == "false" diff --git a/frontend/constants.py b/frontend/constants.py index 28f25c05..58b83783 100644 --- a/frontend/constants.py +++ b/frontend/constants.py @@ -108,6 +108,7 @@ def is_valid_explicit_user_id(value: Optional[str]) -> bool: "demo": "/demo", "about": "/about", "home": "/", + "case": "/case", } # Legacy: License & Copyright UI lives on ``/about``; ``/licenses`` redirects there. diff --git a/frontend/design_tokens.py b/frontend/design_tokens.py index 935e5081..68937a50 100644 --- a/frontend/design_tokens.py +++ b/frontend/design_tokens.py @@ -1,9 +1,8 @@ """ Canonical Tailwind class strings for RescueBox. -Global primary actions use UMass Maroon (PMS 202, #881c1c, RGB 136 28 28); hover is a darker +Global primary actions use UMass Maroon (#881c1c); hover is a darker maroon (#6a1616). Quasar ``--q-primary`` is set in ``frontend/utils/ui_readability_css.py``. -See https://www.umass.edu/brand/visual-identity/brand-colors and ``frontend/design.json``. """ from __future__ import annotations @@ -12,18 +11,18 @@ class Design: """Brand-aligned utility classes (NiceGUI + Quasar + Tailwind).""" - # --- Navigation (Medium Gray #505759 — background from .rb-brand-nav in ui_readability_css) --- + # --- Navigation (UMass Maroon #881c1c with white text) --- NAV_HEADER = ( "rb-brand-nav text-white shadow-lg shadow-black/30 sticky top-0 z-50 " "w-full max-w-[100vw] overflow-hidden" ) NAV_LINK = ( "text-white hover:underline px-1.5 py-0.5 sm:px-2 sm:py-0.5 rounded " - "hover:bg-white/10 !text-sm sm:!text-base whitespace-nowrap !leading-snug" + "hover:bg-white/10 !text-sm sm:!text-base whitespace-nowrap !leading-snug font-semibold" ) - NAV_VERSION_MUTED = "!text-sm sm:!text-base font-medium text-zinc-400 shrink-0" + NAV_VERSION_MUTED = "!text-sm sm:!text-base font-medium text-slate-400 shrink-0" - # --- Buttons (Maroon #881c1c; hover #6a1616 — see :root / .rb-brand-primary in ui_readability_css) --- + # --- Buttons (UMass Maroon #881c1c; hover #6a1616) --- BTN_PRIMARY = ( "rb-brand-primary text-white px-5 py-2.5 rounded-xl " "font-semibold shadow-md shadow-black/20 transition-all active:scale-95" @@ -33,15 +32,15 @@ class Design: "font-medium shadow-sm transition-colors" ) BTN_PRIMARY_TIGHT = ( - "rb-brand-primary text-white px-3 py-1 rounded text-sm " "transition-colors" + "rb-brand-primary text-white px-3 py-1 rounded text-sm transition-colors" ) - BTN_GHOST = "text-zinc-600 hover:bg-zinc-100 px-4 py-2 rounded-lg transition-colors" + BTN_GHOST = "text-slate-600 hover:bg-slate-100 px-4 py-2 rounded-lg transition-colors" BTN_SECONDARY_NEUTRAL = ( - "bg-zinc-100 hover:bg-zinc-200 text-zinc-800 px-4 py-2 rounded-lg " - "font-medium transition-colors border border-zinc-200" + "bg-slate-100 hover:bg-slate-200 text-slate-800 px-4 py-2 rounded-lg " + "font-medium transition-colors border border-slate-200" ) - BTN_DISABLED = "bg-zinc-300 text-zinc-500 cursor-not-allowed" - # Browse / Cancel-style actions (UMass Medium Gray #505759 — see .rb-btn-medium-gray in ui_readability_css) + BTN_DISABLED = "bg-slate-200 text-slate-400 cursor-not-allowed" + # Browse / Cancel-style actions (UMass Maroon #881c1c) BTN_MEDIUM_GRAY = ( "rb-btn-medium-gray text-white rounded-lg font-medium transition-colors" ) @@ -50,81 +49,73 @@ class Design: LINK = "text-[#881c1c] hover:underline" # --- Chat bubbles (cards) --- - # User bubble: no tinted fill — white surface + zinc ring (assistant-style, right tail) CHAT_USER_BUBBLE = ( - "bg-white text-zinc-900 rounded-2xl rounded-tr-none px-4 py-3 shadow-sm " - "ring-1 ring-zinc-200 border-0" + "bg-slate-100 text-slate-800 rounded-2xl rounded-tr-none px-4 py-3 shadow-sm " + "ring-1 ring-slate-200 border-0" ) CHAT_USER_LABEL = ( - "font-medium !text-xs sm:!text-sm text-zinc-900 uppercase tracking-wide" + "font-medium !text-sm sm:!text-base text-slate-600 uppercase tracking-wide" ) CHAT_ASSISTANT_BUBBLE = ( - "bg-white text-zinc-800 ring-1 ring-zinc-200 rounded-2xl rounded-tl-none " + "bg-white text-slate-800 ring-1 ring-slate-200 rounded-2xl rounded-tl-none " "px-4 py-3 shadow-sm border-0" ) - # Use with CHAT_ASSISTANT_BUBBLE so assistant text, markdown, and tool-call cards share one column width. CHAT_ASSISTANT_BUBBLE_WIDTH = "w-full max-w-3xl min-w-0" CHAT_SYSTEM_TOOL = ( - "bg-zinc-50 border-l-4 border-[#505759] p-4 italic text-zinc-600 text-sm" + "bg-slate-50 border-l-4 border-[#881c1c] p-4 italic text-slate-600 text-sm" ) - # Plugins mode tool list rows (/chatbot Menu) — UMass Medium Gray #505759 (not indigo) CHATBOT_PLUGIN_MENU_ROW = ( - "border-2 border-[#505759]/35 bg-white shadow-sm hover:bg-[#505759]/10 " - "hover:border-[#505759] cursor-pointer transition-colors duration-150 items-start" + "border border-slate-200 bg-white shadow-sm hover:bg-slate-50 " + "hover:border-[#881c1c] cursor-pointer transition-all duration-150 items-start rounded-xl" ) # --- Form fields (chat / long text) --- INPUT_MODERN = ( - "w-full min-w-0 !text-base bg-white border-none ring-1 ring-zinc-300 " - "focus:ring-2 focus:ring-[#881c1c] rounded-2xl p-4 shadow-inner transition-all" + "w-full min-w-0 !text-base bg-white text-slate-800 border-none ring-1 ring-slate-200 " + "focus:ring-2 focus:ring-[#881c1c] rounded-2xl p-4 shadow-sm transition-all" ) - # Legacy-compatible: bordered field (jobs, forms) INPUT_OUTLINED = ( - "rounded-xl border-2 border-zinc-200 focus:border-[#881c1c] " + "rounded-xl border border-slate-200 bg-white text-slate-800 focus:border-[#881c1c] " "focus:ring-2 focus:ring-[#881c1c]/10 transition-all duration-200 resize-none shadow-sm" ) # --- Tool invocation / result (chat tool cards) --- - CARD_TOOL_CALL = "p-4 my-2 bg-zinc-50 border border-zinc-200 rounded-lg" - CARD_TOOL_RESULT = "p-4 my-2 bg-zinc-50 border border-zinc-200 rounded-lg" - LABEL_TOOL_CALL_TITLE = "font-semibold text-black mt-3" - LABEL_TOOL_CALL_ARGS = "font-medium text-black mt-3" - LABEL_TOOL_RESULT_TITLE = "font-medium text-black mt-3" - LABEL_TOOL_RESULT_CONTENT = "text-sm text-black mt-1 whitespace-pre-wrap" + CARD_TOOL_CALL = "p-4 my-2 bg-slate-50 border border-slate-200 rounded-xl text-slate-800" + CARD_TOOL_RESULT = "p-4 my-2 bg-slate-50 border border-slate-200 rounded-xl text-slate-800" + LABEL_TOOL_CALL_TITLE = "font-semibold text-slate-800 mt-3" + LABEL_TOOL_CALL_ARGS = "font-medium text-slate-700 mt-3" + LABEL_TOOL_RESULT_TITLE = "font-medium text-slate-800 mt-3" + LABEL_TOOL_RESULT_CONTENT = "text-sm text-slate-600 mt-1 whitespace-pre-wrap" # --- Status text --- STATUS_PROCESSING = "text-[#881c1c]" SPINNER_PROCESSING = "text-[#881c1c]" # --- Focused panel shell (dialogs, chat, plugin pickers) --- - # Outer card: rounded container, soft zinc shadow, no padding (header/body/footer own regions). PANEL_SHELL_CARD = ( - "w-full max-w-4xl mx-auto flex flex-col flex-1 min-h-0 " - "rounded-3xl shadow-xl shadow-zinc-200/50 border border-zinc-100 p-0 overflow-hidden" + "w-full max-w-6xl mx-auto flex flex-col flex-1 min-h-0 " + "rounded-3xl shadow-xl shadow-slate-200/50 border border-slate-200 p-0 overflow-hidden bg-white" ) - # Chat page only: no flex-1 on the card so short threads do not leave a tall empty band - # between messages and the input; scrolling is handled on the message column (max-h). PANEL_SHELL_CHAT_CARD = ( - "w-full max-w-4xl mx-auto flex flex-col min-h-0 " - "rounded-3xl shadow-xl shadow-zinc-200/50 border border-zinc-100 p-0 overflow-hidden" + "w-full max-w-6xl mx-auto flex flex-col min-h-0 " + "rounded-3xl shadow-xl shadow-slate-200/50 border border-slate-200 p-0 overflow-hidden bg-white" ) PANEL_SHELL_CARD_NARROW = ( "w-full max-w-2xl mx-auto flex flex-col min-h-0 max-h-[85vh] " - "rounded-3xl shadow-xl shadow-zinc-200/50 border border-zinc-100 p-0 overflow-hidden" + "rounded-3xl shadow-xl shadow-slate-200/50 border border-slate-200 p-0 overflow-hidden bg-white" ) PANEL_SHELL_CARD_MD = ( "w-full max-w-md min-w-0 mx-auto flex flex-col " - "rounded-3xl shadow-xl shadow-zinc-200/50 border border-zinc-100 p-0 overflow-hidden" + "rounded-3xl shadow-xl shadow-slate-200/50 border border-slate-200 p-0 overflow-hidden bg-white" ) PANEL_SHELL_CARD_WIDE = ( "w-[95vw] max-w-[1400px] max-h-[95vh] mx-auto flex flex-col min-h-0 " - "rounded-3xl shadow-xl shadow-zinc-200/50 border border-zinc-100 p-0 overflow-hidden" + "rounded-3xl shadow-xl shadow-slate-200/50 border border-slate-200 p-0 overflow-hidden bg-white" ) - PANEL_SHELL_HEADER = "w-full bg-zinc-50 p-4 border-b border-zinc-100 items-center justify-between flex-none" - PANEL_SHELL_HEADER_TITLE = "text-lg font-bold text-zinc-900 tracking-tight" - # Icon-only close on dialogs (Medium Gray #505759; hover matches .rb-btn-medium-gray hover) - PANEL_SHELL_HEADER_ICON = "!text-[#505759] hover:!text-[#3d4442] transition-colors" - PANEL_SHELL_BODY = "flex-1 min-h-0 overflow-y-auto bg-white p-6" + PANEL_SHELL_HEADER = "w-full bg-slate-50 p-4 border-b border-slate-200 items-center justify-between flex-none" + PANEL_SHELL_HEADER_TITLE = "text-lg font-bold text-slate-800 tracking-tight" + PANEL_SHELL_HEADER_ICON = "!text-[#881c1c] hover:!text-[#6a1616] transition-colors" + PANEL_SHELL_BODY = "flex-1 min-h-0 overflow-y-auto bg-slate-50 p-6" PANEL_SHELL_FOOTER = ( - "w-full flex-none p-4 bg-white border-t border-zinc-100 items-center gap-2" + "w-full flex-none p-4 bg-white border-t border-slate-200 items-center gap-2" ) diff --git a/frontend/icons/favicon.png b/frontend/icons/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..278ac350c3821e6cbf7b2c303ff2996635c6555c GIT binary patch literal 106325 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℞{`g;QU5#IUJw!3#vofLbgn0ML&pW{2VK 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 literal 0 HcmV?d00001 diff --git a/frontend/icons/logo.png b/frontend/icons/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..507cee9dd5c094268a27b3becb118e8568325ee9 GIT binary patch literal 45434 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 None: _READABILITY_CSS_DONE = True ui.add_head_html( """ + """ ) @@ -140,97 +152,341 @@ async def index(): logger.debug("Navigation bar added to page") ensure_explicit_user_id_for_tests() - explicit_user_id = get_explicit_user_id() - - with ui.column().classes("container mx-auto p-8"): - logger.debug("Creating main content container") - if explicit_user_id: - ui.label(UI_TITLES["home"]).classes("text-4xl font-bold mb-4") - ui.label(UI_TITLES["home_subtitle"]).classes("text-xl text-zinc-600") - with ui.card().classes("w-full max-w-xl mt-4 p-4 bg-zinc-50"): - ui.label( - f"{HOME_USER_ID['current_prefix']} {explicit_user_id}" - ).classes("text-sm font-medium") - ui.label(HOME_USER_ID["change_user_hint"]).classes( - "text-xs text-zinc-500 mt-1" - ) - with ui.row().classes("mt-3"): - - def _change_user_id(): - clear_explicit_user_id() - ui.timer(0.2, lambda: ui.navigate.reload(), once=True) - - # ui.button( - # HOME_USER_ID["change_user_button"], - # on_click=_change_user_id, - # ).classes("bg-zinc-200 text-zinc-800") - - with ui.row().classes("gap-4 mt-8"): - logger.debug("Creating action buttons") - - ui.button( - UI_BUTTONS["browse_models"], - on_click=lambda: ui.navigate.to(NAV_LINKS["models"]), - ).classes(Design.BTN_PRIMARY) - logger.debug("Browse Models button created") - - ui.button( - UI_BUTTONS["open_assistant"], - on_click=lambda: ui.navigate.to(NAV_LINKS["chatbot"]), - ).classes(Design.BTN_PRIMARY) - logger.debug("Open Assistant button created") - else: - with ui.card().classes("w-full max-w-xl p-6 shadow-md border"): - ui.label(HOME_USER_ID["title"]).classes("text-xl font-semibold mb-2") - ui.label(HOME_USER_ID["blurb"]).classes("text-zinc-600 mb-4") - uid_input = ui.input( - HOME_USER_ID["input_label"], - placeholder=HOME_USER_ID["placeholder"], - ).classes("w-full") - - def _save_home_user_id(): - val = (uid_input.value or "").strip() - if not val: - ui.notify( - "Please enter a User ID.", - type="warning", - classes="rb-notify-505759", - ) + active_case_id = get_active_case_id() + + with ui.column().classes("container mx-auto px-4 sm:px-8 py-8 w-full max-w-6xl pb-16 gap-8"): + # Main Header + with ui.row().classes("w-full items-center gap-3 mb-2"): + ui.icon("folder_shared", size="lg").classes("text-[#881c1c]") + ui.label("RescueBox Case Management").classes("text-4xl font-bold text-slate-800") + ui.label("Create a new investigative case or load an existing one to begin.").classes("text-lg text-slate-500 mb-8 pl-1") + + # Unconditional Dual-pane Case Management setup + with ui.row().classes("w-full gap-8 items-stretch flex-wrap md:flex-nowrap"): + # Left Pane: Create New Case + with ui.card().classes("flex-1 p-6 border-t-4 border-t-[#881c1c] border-x border-b border-slate-200 shadow-md rounded-2xl bg-white"): + with ui.row().classes("items-center gap-2 mb-4"): + ui.icon("create_new_folder", size="sm").classes("text-[#881c1c]") + ui.label("Create New Case").classes("text-2xl font-bold text-slate-800") + + case_num_input = ui.input( + "Case Number / ID (Required, Unique)", + placeholder="e.g., CASE-2026-0042", + ).classes("w-full mb-4").props("outlined dense") + with case_num_input.add_slot("prepend"): + ui.icon("assignment").classes("text-slate-400") + + investigators_input = ui.input( + "Investigators", + placeholder="e.g., Det. Smith, Agent Jones", + ).classes("w-full mb-4").props("outlined dense") + with investigators_input.add_slot("prepend"): + ui.icon("people").classes("text-slate-400") + + with ui.column().classes("w-full mb-6 gap-1"): + ui.label("Evidence Directory / UFDR Path").classes("text-sm font-medium text-slate-700") + with ui.row().classes("w-full items-center gap-2 flex-nowrap"): + path_input = ui.input( + placeholder="/path/to/evidence", + ).classes("flex-1").props("outlined dense") + with path_input.add_slot("prepend"): + ui.icon("folder").classes("text-slate-400") + + ui.button( + "Browse", + icon="folder_open", + color=None, + on_click=lambda: browse_directory_simple(path_input), + ).classes(Design.BTN_MEDIUM_GRAY) + + async def _create_case(): + num = (case_num_input.value or "").strip() + inv = (investigators_input.value or "").strip() + path = (path_input.value or "").strip() + + if not num: + ui.notify("Case Number is required.", type="warning") return - if not is_valid_explicit_user_id(val): - ui.notify( - HOME_USER_ID["invalid_format"], - type="warning", - classes="rb-notify-505759", - ) - return - claim = try_claim_explicit_user_id(val) - if claim == "taken": - ui.notify( - HOME_USER_ID["id_taken"], - type="warning", - classes="rb-notify-a2aaad", - ) - return - if claim != "ok": + if not path: + ui.notify("Evidence Path is required.", type="warning") return - set_explicit_user_id(val) - # After set_explicit_user_id (deferred browser write); reload must run later. - ui.timer(0.08, lambda: ui.navigate.reload(), once=True) - def _on_uid_keydown(e): - if getattr(e, "args", None) and e.args.get("key") == "Enter": - _save_home_user_id() + try: + case_db = get_case_db() + new_case = await case_db.create_case( + case_number=num, + investigators=inv, + evidence_path=path, + ) + set_active_case_id(new_case.caseId) + ui.notify(f"Case {num} created and loaded successfully.", type="positive") + ui.timer(0.5, lambda: ui.navigate.to("/case"), once=True) + except ValueError as e: + ui.notify(str(e), type="negative") + except Exception as e: + ui.notify(f"Failed to create case: {e}", type="negative") - uid_input.on("keydown", _on_uid_keydown) ui.button( - HOME_USER_ID["save_button"], - on_click=_save_home_user_id, - ).classes(f"mt-4 {Design.BTN_PRIMARY}") + "Create & Load Case", + icon="add_circle", + color=None, + on_click=_create_case, + ).classes(Design.BTN_PRIMARY + " w-full py-3 text-base") + + # Right Pane: Load Existing Case + with ui.card().classes("flex-1 p-6 border-t-4 border-t-[#881c1c] border-x border-b border-slate-200 shadow-md rounded-2xl bg-white flex flex-col"): + with ui.row().classes("items-center gap-2 mb-4"): + ui.icon("folder_open", size="sm").classes("text-[#881c1c]") + ui.label("Load Existing Case").classes("text-2xl font-bold text-slate-800") + + cases_container = ui.column().classes("w-full flex-1 overflow-y-auto space-y-3 max-h-[400px]") + + async def _load_cases(): + cases_container.clear() + try: + case_db = get_case_db() + all_cases = await case_db.get_all_cases() + if not all_cases: + with cases_container: + ui.label("No existing cases found.").classes("text-slate-400 italic p-4 text-center w-full") + return + + with cases_container: + for c in all_cases: + with ui.card().classes("w-full p-4 border-l-4 border-l-[#881c1c] border-y border-r border-slate-200 hover:border-slate-300 hover:shadow-md transition-all bg-slate-50 rounded-xl"): + with ui.row().classes("w-full justify-between items-center"): + with ui.column().classes("gap-1 flex-1 min-w-0"): + with ui.row().classes("items-center gap-1.5"): + ui.icon("folder", size="xs").classes("text-[#881c1c]") + ui.label(c.caseNumber).classes("font-bold text-lg text-slate-800 truncate") + if c.investigators: + with ui.row().classes("items-center gap-1.5"): + ui.icon("people", size="xs").classes("text-slate-400") + ui.label(f"Investigators: {c.investigators}").classes("text-sm text-slate-600 truncate") + with ui.row().classes("items-center gap-1.5"): + ui.icon("link", size="xs").classes("text-slate-400") + ui.label(f"Path: {c.evidencePath}").classes("text-xs font-mono text-slate-500 truncate") + + def _load(cid=c.caseId, cnum=c.caseNumber): + set_active_case_id(cid) + ui.notify(f"Loaded case {cnum}.", type="positive") + ui.timer(0.3, lambda: ui.navigate.to("/case"), once=True) + + ui.button( + "Load", + icon="login", + color=None, + on_click=lambda cid=c.caseId, cnum=c.caseNumber: _load(cid, cnum), + ).classes(Design.BTN_PRIMARY_COMPACT) + except Exception as e: + logger.error("Error loading cases: %s", e) + with cases_container: + ui.label(f"Error loading cases: {e}").classes("text-red-500") + + await _load_cases() logger.debug("Main dashboard page rendered successfully") +@ui.page("/case") +async def case_overview(): + """Active Case Overview / Dashboard.""" + logger.debug("Rendering case overview page") + + from frontend.utils import ( + apply_saved_theme, + browse_directory_simple, + get_active_case_id, + set_active_case_id, + clear_active_case_id, + get_active_case, + ensure_explicit_user_id_for_tests, + ) + from frontend.database import get_case_db, get_job_db, JobStatus + + apply_saved_theme() + logger.debug("Theme preference applied") + + ui.add_head_html( + """ + + """ + ) + + create_navbar() + logger.debug("Navigation bar added to page") + + ensure_explicit_user_id_for_tests() + active_case_id = get_active_case_id() + + if not active_case_id: + # If no active case, redirect to home page to create or load one + ui.notify("No active case loaded. Please create or load a case.", type="warning") + ui.timer(0.1, lambda: ui.navigate.to("/"), once=True) + return + + with ui.column().classes("container mx-auto px-4 sm:px-8 py-8 w-full max-w-6xl pb-16 gap-8"): + # Active Case Dashboard + case_db = get_case_db() + case = await case_db.get_case_by_id(active_case_id) + if not case: + # Fallback if case not found + clear_active_case_id() + ui.timer(0.1, lambda: ui.navigate.to("/"), once=True) + return + + with ui.row().classes("items-center gap-3 mb-2"): + ui.icon("folder_special", size="lg").classes("text-[#881c1c]") + ui.label(f"Case: {case.caseNumber}").classes("text-4xl font-bold text-slate-800") + if case.investigators: + with ui.row().classes("items-center gap-2 mb-6 pl-1"): + ui.icon("people", size="xs").classes("text-slate-500") + ui.label(f"Investigators: {case.investigators}").classes("text-lg text-slate-600") + + # Case Details Card + with ui.card().classes("w-full p-6 border-t-4 border-t-[#881c1c] border-x border-b border-slate-200 shadow-md rounded-2xl bg-white mb-8"): + with ui.row().classes("items-center gap-2 mb-4 border-b pb-2 border-slate-100"): + ui.icon("info", size="sm").classes("text-[#881c1c]") + ui.label("Case Information").classes("text-xl font-bold text-slate-800") + with ui.column().classes("w-full gap-3"): + with ui.row().classes("items-center gap-2.5"): + ui.icon("fingerprint", size="xs").classes("text-slate-400") + ui.label("Case ID:").classes("font-semibold text-slate-700 w-24 shrink-0") + ui.label(case.caseId).classes("font-mono text-slate-600 truncate bg-slate-50 px-2 py-0.5 rounded border border-slate-100") + with ui.row().classes("items-center gap-2.5"): + ui.icon("today", size="xs").classes("text-slate-400") + ui.label("Created:").classes("font-semibold text-slate-700 w-24 shrink-0") + ui.label(case.createdAt[:10] + " " + case.createdAt[11:16]).classes("text-slate-600") + with ui.row().classes("items-center gap-2.5 w-full flex-wrap sm:flex-nowrap"): + ui.icon("folder", size="xs").classes("text-slate-400") + ui.label("Evidence Path:").classes("font-semibold text-slate-700 w-24 shrink-0") + path_display = ui.input(value=case.evidencePath).classes("flex-1 min-w-0").props("outlined dense readonly") + with path_display.add_slot("prepend"): + ui.icon("folder", size="xs").classes("text-slate-400") + + async def _change_path(): + with ui.dialog() as d, ui.card().classes("p-6 w-full max-w-lg bg-white border-t-4 border-t-[#881c1c] border-x border-b border-slate-200 rounded-2xl shadow-xl"): + with ui.row().classes("items-center gap-2 mb-4"): + ui.icon("edit", size="sm").classes("text-[#881c1c]") + ui.label("Update Evidence Path").classes("text-xl font-bold text-slate-800") + new_path_input = ui.input("New Evidence Directory / UFDR Path", value=case.evidencePath).classes("w-full mb-6").props("outlined dense") + with new_path_input.add_slot("prepend"): + ui.icon("folder").classes("text-slate-400") + with ui.row().classes("w-full justify-end gap-2"): + ui.button("Cancel", icon="close", color=None, on_click=d.close).classes(Design.BTN_MEDIUM_GRAY) + + async def _save_path(): + p = (new_path_input.value or "").strip() + if not p: + ui.notify("Path cannot be empty.", type="warning") + return + await case_db.update_case_evidence_path(case.caseId, p) + ui.notify("Evidence path updated successfully.", type="positive") + d.close() + ui.timer(0.3, lambda: ui.navigate.reload(), once=True) + + ui.button("Save", icon="save", color=None, on_click=_save_path).classes(Design.BTN_PRIMARY_COMPACT) + d.open() + + ui.button("Change Path", icon="edit", color=None, on_click=_change_path).classes(Design.BTN_MEDIUM_GRAY) + + # Case Results (Jobs) Table + with ui.row().classes("items-center gap-2 mb-4"): + ui.icon("view_list", size="sm").classes("text-[#881c1c]") + ui.label("Case Results & Jobs").classes("text-2xl font-bold text-slate-800") + + jobs_container = ui.column().classes("w-full space-y-2") + + async def _load_case_jobs(): + jobs_container.clear() + try: + job_db = get_job_db() + jobs_data = await job_db.get_all_jobs() + if not jobs_data: + with jobs_container: + ui.label("No jobs or results associated with this case yet.").classes("text-slate-400 italic p-6 text-center w-full bg-slate-50 rounded-xl border border-dashed border-slate-200") + return + + with jobs_container: + # Header Row + with ui.row().classes("bg-[#1c1c1c] text-white p-4 font-semibold w-full rounded-t-xl items-center"): + ui.label("Job ID").classes("w-32 shrink-0") + ui.label("Plugin / Task").classes("flex-1 min-w-0") + ui.label("Start Time").classes("w-48 shrink-0") + ui.label("Status").classes("w-36 shrink-0") + ui.label("Actions").classes("w-48 shrink-0") + + for job in jobs_data: + uid = job.get("uid") + endpoint = job.get("endpoint") + pname = job.get("plugin_name") or endpoint or "Unknown" + start_time = job.get("startTime") or "N/A" + if "T" in start_time: + start_time = start_time.replace("T", " ")[:16] + status = job.get("status", "Unknown") + + # Status Pill Badges + status_pill_classes = { + "Completed": "bg-emerald-50 text-emerald-700 border border-emerald-200", + "Running": "bg-rose-50 text-[#881c1c] border border-rose-200", + "Failed": "bg-rose-50 text-rose-700 border border-rose-200", + "Canceled": "bg-slate-100 text-slate-600 border border-slate-200", + } + pill_cls = status_pill_classes.get(status, "bg-slate-50 text-slate-500 border border-slate-200") + + with ui.row().classes("p-4 border-b border-slate-200 hover:bg-slate-50 items-center w-full flex-nowrap gap-2 bg-white"): + ui.label(uid).classes("font-mono text-sm w-32 shrink-0 truncate text-slate-800").tooltip(uid) + ui.label(pname).classes("flex-1 min-w-0 truncate text-slate-800") + ui.label(start_time).classes("w-48 shrink-0 text-sm text-slate-600") + + # Render status pill badge + with ui.row().classes(f"w-36 shrink-0 items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold {pill_cls}"): + if status == "Completed": + ui.icon("check_circle", size="14px") + elif status == "Running": + ui.spinner(size="14px").classes("text-[#881c1c]") + elif status == "Failed": + ui.icon("error", size="14px") + else: + ui.icon("cancel", size="14px") + ui.label(status) + + with ui.row().classes("w-48 shrink-0 gap-2 flex-nowrap"): + ui.button( + "Open", + icon="visibility", + color=None, + on_click=lambda jid=uid: ui.navigate.to(f"/jobs/{jid}"), + ).classes(Design.BTN_PRIMARY_TIGHT) + + async def _remove_job(jid=uid): + await get_job_db().disassociate_job_from_case(jid) + ui.notify(f"Job {jid} removed from case.", type="info") + await _load_case_jobs() + + ui.button( + "Remove", + icon="delete", + color=None, + on_click=lambda jid=uid: _remove_job(jid), + ).classes("bg-rose-50 hover:bg-rose-100 text-[#881c1c] px-3 py-1 rounded text-sm transition-colors border border-rose-200") + + except Exception as e: + logger.error("Error loading case jobs: %s", e) + with jobs_container: + ui.label(f"Error loading jobs: {e}").classes("text-red-500") + + await _load_case_jobs() + + logger.debug("Case overview page rendered successfully") + + _LICENSES_COPYRIGHT_DIR = _project_root / "License&Copyright" @@ -326,7 +582,7 @@ async def _on_client_delete(client: Client): release_demo_folder_for_client(client) ui.run( - title=f"{APP_TITLE} · {APP_VERSION}", + title=APP_TITLE, port=APP_PORT, favicon=APP_FAVICON, show=False, diff --git a/frontend/utils/__init__.py b/frontend/utils/__init__.py index 46f68d3b..c0797099 100644 --- a/frontend/utils/__init__.py +++ b/frontend/utils/__init__.py @@ -52,6 +52,10 @@ set_draft_message, set_conversation_to_load, get_conversation_to_load, + get_active_case_id, + set_active_case_id, + clear_active_case_id, + get_active_case, ) from .ui import ( notify_success, @@ -132,6 +136,10 @@ "set_draft_message", "set_conversation_to_load", "get_conversation_to_load", + "get_active_case_id", + "set_active_case_id", + "clear_active_case_id", + "get_active_case", "notify_success", "notify_error", "notify_info", diff --git a/frontend/utils/storage.py b/frontend/utils/storage.py index e8e86661..165971b8 100644 --- a/frontend/utils/storage.py +++ b/frontend/utils/storage.py @@ -42,34 +42,15 @@ def get_user_id() -> Optional[str]: def get_explicit_user_id() -> Optional[str]: - try: - return app.storage.user.get("explicit_job_user_id") - except Exception: - if _runs_under_pytest(): - return _test_fallback_storage.get("explicit_job_user_id") - return None + return get_active_case_id() def set_explicit_user_id(value: str): - v = value.strip() - try: - app.storage.user["explicit_job_user_id"] = v - except Exception: - pass - if _runs_under_pytest(): - _test_fallback_storage["explicit_job_user_id"] = v + set_active_case_id(value) def clear_explicit_user_id(): - uid = get_explicit_user_id() - if uid: - release_explicit_user_id_claim(uid) - try: - app.storage.user.pop("explicit_job_user_id", None) - except Exception: - pass - if _runs_under_pytest(): - _test_fallback_storage.pop("explicit_job_user_id", None) + clear_active_case_id() def release_explicit_user_id_claim(uid: str): @@ -121,9 +102,59 @@ def try_claim_explicit_user_id(uid: str) -> str: return "invalid" +def get_active_case_id() -> Optional[str]: + try: + return app.storage.user.get("active_case_id") + except Exception: + if _runs_under_pytest(): + return _test_fallback_storage.get("active_case_id") + return None + + +def set_active_case_id(value: str): + v = value.strip() + try: + app.storage.user["active_case_id"] = v + except Exception: + pass + if _runs_under_pytest(): + _test_fallback_storage["active_case_id"] = v + + +def clear_active_case_id(): + try: + app.storage.user.pop("active_case_id", None) + except Exception: + pass + if _runs_under_pytest(): + _test_fallback_storage.pop("active_case_id", None) + + +def get_active_case() -> Optional[Any]: + case_id = get_active_case_id() + if not case_id: + return None + try: + from frontend.database.case_db import get_case_db + case = get_case_db().get_case_by_id_sync(case_id) + if not case and _runs_under_pytest(): + from frontend.database.case_db import CaseRecord + return CaseRecord( + caseId=case_id, + caseNumber="TEST-CASE", + investigators="Test Investigator", + evidencePath="/tmp", + createdAt="2026-06-04T13:15:00", + updatedAt="2026-06-04T13:15:00", + ) + return case + except Exception: + return None + + def get_user_id_for_jobs() -> Optional[str]: - """Alias for get_explicit_user_id for backward compatibility.""" - return get_explicit_user_id() + """Returns the active case ID so that all jobs and chat history are scoped to the active case.""" + return get_active_case_id() def set_user_preference(key: str, value: Any): diff --git a/frontend/utils/ui.py b/frontend/utils/ui.py index 9703d0fa..58a72c23 100644 --- a/frontend/utils/ui.py +++ b/frontend/utils/ui.py @@ -139,7 +139,7 @@ def require_demo_user_session(): if get_user_id_for_jobs(): return True - with ui.column().classes("container mx-auto p-8 max-w-2xl w-full"): + with ui.column().classes("container mx-auto px-4 sm:px-8 py-8 max-w-2xl w-full pb-16"): ui.label(HOME_USER_ID["title"]).classes("text-2xl font-semibold mb-2") ui.label(HOME_USER_ID["blurb"]).classes("text-zinc-600 mb-4") ui.link("Go to Home", NAV_LINKS["home"]).classes( From dbf93149776c944a6c5d0c209f015eb6d6665616 Mon Sep 17 00:00:00 2001 From: Sahil Sharma Date: Fri, 5 Jun 2026 17:58:28 -0400 Subject: [PATCH 04/11] refactor(chatbot): standardize chatbot layout, swap tab order, and fix hover readability --- frontend/components/chat/dialogs.py | 6 +- frontend/components/chat/rendering.py | 36 ++-- frontend/components/chat/ui_elements.py | 12 +- frontend/pages/chatbot/coordinator.py | 4 +- frontend/pages/chatbot/ui.py | 237 +++++++++++++++++++----- 5 files changed, 215 insertions(+), 80 deletions(-) diff --git a/frontend/components/chat/dialogs.py b/frontend/components/chat/dialogs.py index dd35253b..d71b82ad 100644 --- a/frontend/components/chat/dialogs.py +++ b/frontend/components/chat/dialogs.py @@ -8,7 +8,7 @@ def show_help_dialog(help_text: str, title: Optional[str] = "RescueBox Help") -> with ui.dialog() as dialog, ui.card().classes(Design.PANEL_SHELL_CARD_WIDE): with ui.row().classes(Design.PANEL_SHELL_HEADER): ui.label(title or "Help").classes(Design.PANEL_SHELL_HEADER_TITLE) - ui.button(icon="close", on_click=dialog.close).props("flat round dense") + ui.button(icon="close", color=None, on_click=dialog.close).props("flat round dense") with ui.column().classes("w-full flex-1 overflow-y-auto p-6"): ui.markdown(help_text or "No help available.") dialog.open() @@ -26,7 +26,7 @@ async def show_history_dialog( with ui.dialog() as dialog, ui.card().classes(Design.PANEL_SHELL_CARD_WIDE): with ui.row().classes(Design.PANEL_SHELL_HEADER): ui.label("Chat History").classes(Design.PANEL_SHELL_HEADER_TITLE) - ui.button(icon="close", on_click=dialog.close).props("flat round dense") + ui.button(icon="close", color=None, on_click=dialog.close).props("flat round dense") with ui.column().classes( f"{Design.PANEL_SHELL_BODY} gap-3 overflow-y-auto max-h-[60vh] w-full" @@ -99,5 +99,5 @@ def _render_message_card(msg: Any) -> None: ): for msg in messages: _render_message_card(msg) - ui.button("Close", on_click=dialog.close).classes(Design.BTN_MEDIUM_GRAY) + ui.button("Close", color=None, on_click=dialog.close).classes(Design.BTN_MEDIUM_GRAY) dialog.open() diff --git a/frontend/components/chat/rendering.py b/frontend/components/chat/rendering.py index 2ad33f01..0927cf79 100644 --- a/frontend/components/chat/rendering.py +++ b/frontend/components/chat/rendering.py @@ -5,26 +5,26 @@ logger = logging.getLogger(__name__) -ASSISTANT_MARKDOWN_CLASSES = "prose prose-zinc max-w-none !text-base !leading-relaxed" -USER_PLAIN_CLASSES = "!text-base !leading-relaxed text-zinc-800" +ASSISTANT_MARKDOWN_CLASSES = "prose prose-slate max-w-none !text-base !leading-relaxed" +USER_PLAIN_CLASSES = "!text-base !leading-relaxed text-slate-800" def render_welcome_message(container: ui.element) -> None: with container: with card().classes( - "w-full max-w-sm bg-white ring-1 ring-zinc-200 shadow-sm rounded-2xl rounded-tl-none" + "w-full max-w-sm bg-white ring-1 ring-slate-200 shadow-sm rounded-2xl rounded-tl-none border-l-4 border-l-[#881c1c]" ): with column().classes("p-3 gap-1"): label("Assistant").classes( - "font-medium !text-sm text-zinc-500 uppercase tracking-wide" + "font-medium !text-sm text-slate-500 uppercase tracking-wider" ) label("New conversation. How can I help you?").classes( - "!text-base !leading-relaxed text-zinc-800" + "!text-base !leading-relaxed text-slate-800" ) with row().classes("mt-2"): - button("Open Tools Menu", icon="menu").props( + button("Open Tools Menu", icon="menu", color=None).props( "flat dense no-caps" - ).classes("text-sm text-zinc-600 hover:text-zinc-900").on( + ).classes("text-sm text-slate-600 hover:text-[#881c1c]").on( "click", lambda: ui.run_javascript( 'document.querySelectorAll("button").forEach(b => { if(b.innerText.includes("Menu")) b.click(); })' @@ -44,12 +44,12 @@ def render_message_card( else Design.CHAT_ASSISTANT_BUBBLE ) with row().classes(f"w-full {alignment}"): - with card().classes(f"{bubble} max-w-2xl"): + with card().classes(f"{bubble} max-w-4xl"): if role == "user": label("YOU:").classes(Design.CHAT_USER_LABEL) else: label("Assistant").classes( - "font-medium !text-xs text-zinc-500 uppercase tracking-wide" + "font-semibold !text-sm text-slate-500 uppercase tracking-wider" ) if ( @@ -71,22 +71,22 @@ def render_conversation_card( container: ui.column, conversation: Any, view_callback, load_callback ) -> None: with container: - with card().classes("p-4 cursor-pointer hover:bg-zinc-50"): + with card().classes("p-4 cursor-pointer hover:bg-slate-50 border border-slate-200 rounded-xl shadow-sm transition-all"): with row().classes("items-center justify-between mb-2"): - label(conversation.title).classes("font-semibold flex-1") + label(conversation.title).classes("font-semibold flex-1 text-slate-800") with row().classes("gap-2"): button( - "View", on_click=lambda: view_callback(conversation.conversation_id) - ).classes("text-sm rb-brand-primary text-white") + "View", icon="visibility", color=None, on_click=lambda: view_callback(conversation.conversation_id) + ).classes("text-sm bg-slate-100 hover:bg-slate-200 text-slate-800 px-3 py-1 rounded transition-colors") button( - "Load", on_click=lambda: load_callback(conversation.conversation_id) - ).classes("text-sm rb-brand-primary text-white") + "Load", icon="login", color=None, on_click=lambda: load_callback(conversation.conversation_id) + ).classes("text-sm rb-brand-primary text-white px-3 py-1 rounded transition-colors") def render_message_in_dialog(message: Any) -> None: """Simplified version for dialog viewing.""" role = getattr(message, "role", "assistant") content = getattr(message, "content", "") - with column().classes("w-full border-b border-zinc-100 pb-2 mb-2"): - label(role.upper()).classes("text-xs font-bold text-zinc-400") - label(content).classes("text-sm text-zinc-800 whitespace-pre-wrap") + with column().classes("w-full border-b border-slate-100 pb-2 mb-2"): + label(role.upper()).classes("text-xs font-bold text-slate-400 uppercase tracking-wider") + label(content).classes("text-sm text-slate-800 whitespace-pre-wrap") diff --git a/frontend/components/chat/ui_elements.py b/frontend/components/chat/ui_elements.py index f827664e..8408662d 100644 --- a/frontend/components/chat/ui_elements.py +++ b/frontend/components/chat/ui_elements.py @@ -10,17 +10,17 @@ def create_chat_header(on_show_history: Optional[Callable] = None): "rb-chat-toolbar-floating items-center justify-end w-full px-4 py-3 sticky top-0 z-10 gap-3" ): models_btn = ( - ui.button("Menu") + ui.button("Menu", icon="menu", color=None) .classes(Design.BTN_PRIMARY_COMPACT) .props("unelevated no-caps") ) analyze_btn = ( - ui.button("Chat") + ui.button("Chat", icon="chat", color=None) .classes(Design.BTN_PRIMARY_COMPACT) .props("unelevated no-caps") ) history_btn = ( - ui.button("History", on_click=on_show_history) + ui.button("History", icon="history", color=None, on_click=on_show_history) .classes(Design.BTN_PRIMARY_COMPACT) .props("unelevated no-caps") ) @@ -30,7 +30,7 @@ def create_chat_header(on_show_history: Optional[Callable] = None): def create_chat_window() -> Any: # Use flex-1 to ensure it expands to available space in the card container = ui.column().classes( - "rb-chat-messages-scroll w-full flex-1 overflow-y-auto p-6 space-y-4 bg-white" + "rb-chat-messages-scroll w-full flex-1 overflow-y-auto p-4 space-y-3 bg-slate-50 min-w-0" ) render_welcome_message(container) return container @@ -38,7 +38,7 @@ def create_chat_window() -> Any: def create_input_area(status_text_ref: Optional[object], on_send: Callable): input_area = ui.column().classes( - "rb-chat-input-area w-full flex-none bg-white border-t p-4" + "rb-chat-input-area w-full flex-none bg-white border-t border-slate-200 p-4" ) set_latest_input_area(input_area) with input_area: @@ -56,7 +56,7 @@ def create_input_area(status_text_ref: Optional[object], on_send: Callable): if status_text_ref: status_label.bind_text_from(status_text_ref, "status_text") # Add a spinner that only shows while processing - # Use explicit maroon hex for spinner to avoid indigo defaults + # Use explicit UMass Maroon hex for spinner to avoid indigo defaults spinner = ui.spinner(color="#881c1c", size="sm").classes("ml-1") status_text_ref.attach_processing_strip(spinner) diff --git a/frontend/pages/chatbot/coordinator.py b/frontend/pages/chatbot/coordinator.py index 3d77d16d..d99cb3c3 100644 --- a/frontend/pages/chatbot/coordinator.py +++ b/frontend/pages/chatbot/coordinator.py @@ -604,8 +604,8 @@ def _apply_filter(): dialog.close() with ui.row().classes("mt-4 gap-2"): - ui.button("Use all", on_click=_use_all) - ui.button("Apply filter", on_click=_apply_filter) + ui.button("Use all", on_click=_use_all, color=None).classes(Design.BTN_MEDIUM_GRAY) + ui.button("Apply filter", on_click=_apply_filter, color=None).classes(Design.BTN_PRIMARY_COMPACT) dialog.open() try: return await asyncio.wait_for(future, timeout=120.0) diff --git a/frontend/pages/chatbot/ui.py b/frontend/pages/chatbot/ui.py index ef4f0394..62f3eda8 100644 --- a/frontend/pages/chatbot/ui.py +++ b/frontend/pages/chatbot/ui.py @@ -131,9 +131,9 @@ def render_message(container: element, message: ChatMessage): """Render a message in the chat container.""" with container: if message.role == "user": - chat_message(message.content, name="You", sent=True) + chat_message(message.content, name="You", sent=True, bg_color="blue-grey-1", text_color="dark") else: - chat_message(message.content, name="Assistant") + chat_message(message.content, name="RescueBox Assistant", bg_color="primary", text_color="white") def _history_record_to_chat_message(msg: Any) -> ChatMessage: @@ -190,15 +190,15 @@ def render_merged_job_tool_results( """ with container: with card().classes( - "w-full max-w-3xl border border-zinc-200 rounded-xl p-4 bg-white " - "shadow-sm space-y-2" + "w-full max-w-3xl border border-slate-200 rounded-2xl p-5 bg-slate-50 " + "shadow-sm space-y-2 border-l-4 border-l-[#881c1c]" ): - label("Assistant").classes("text-xs font-semibold text-zinc-500 uppercase") + label("Assistant").classes("text-sm font-semibold text-slate-500 uppercase tracking-wider") label((getattr(started_msg, "content", "") or "").strip()).classes( - "text-sm text-zinc-900 whitespace-pre-wrap break-words" + "text-base text-slate-800 whitespace-pre-wrap break-words font-medium" ) label((getattr(completed_msg, "content", "") or "").strip()).classes( - "text-sm text-green-800 font-medium whitespace-pre-wrap break-words" + "text-base text-emerald-700 font-semibold whitespace-pre-wrap break-words" ) ep = getattr(started_msg, "tool_call_endpoint", None) if ep: @@ -206,7 +206,7 @@ def render_merged_job_tool_results( dn = ToolRegistry.display_name_for_endpoint(ep) except Exception: dn = ep - label(f"Plugin: {dn}").classes("text-xs text-zinc-500") + label(f"Plugin: {dn}").classes("text-sm text-slate-500") args = getattr(started_msg, "tool_call_arguments", None) if isinstance(args, dict) and ( args.get("inputs") is not None or args.get("parameters") is not None @@ -225,7 +225,7 @@ def _open_job() -> None: navigate.to(f"/jobs/{jid}") button( - "Open job details", icon="open_in_new", on_click=_open_job + "Open job details", icon="open_in_new", color=None, on_click=_open_job ).classes(f"mt-1 {Design.BTN_MEDIUM_GRAY}") @@ -243,14 +243,14 @@ def render_persisted_history_message(container: element, msg: Any) -> None: if mt == "tool_result": with container: with card().classes( - "w-full max-w-3xl border border-zinc-200 rounded-xl p-4 bg-white " - "shadow-sm space-y-2" + "w-full max-w-3xl border border-slate-200 rounded-2xl p-5 bg-slate-50 " + "shadow-sm space-y-2 border-l-4 border-l-[#881c1c]" ): label("Assistant").classes( - "text-xs font-semibold text-zinc-500 uppercase" + "text-sm font-semibold text-slate-500 uppercase tracking-wider" ) label(content).classes( - "text-sm text-zinc-900 whitespace-pre-wrap break-words" + "text-base text-slate-800 whitespace-pre-wrap break-words font-medium" ) ep = getattr(msg, "tool_call_endpoint", None) if ep: @@ -258,7 +258,7 @@ def render_persisted_history_message(container: element, msg: Any) -> None: dn = ToolRegistry.display_name_for_endpoint(ep) except Exception: dn = ep - label(f"Plugin: {dn}").classes("text-xs text-zinc-500") + label(f"Plugin: {dn}").classes("text-sm text-slate-500") args = getattr(msg, "tool_call_arguments", None) if isinstance(args, dict) and ( args.get("inputs") is not None or args.get("parameters") is not None @@ -277,24 +277,24 @@ def _open_job() -> None: navigate.to(f"/jobs/{jid}") button( - "Open job details", icon="open_in_new", on_click=_open_job + "Open job details", icon="open_in_new", color=None, on_click=_open_job ).classes(f"mt-1 {Design.BTN_MEDIUM_GRAY}") return if mt == "tool_call": with container: with card().classes( - "w-full max-w-3xl border border-zinc-200 rounded-xl p-4 " - "bg-amber-50/80 space-y-2" + "w-full max-w-3xl border border-slate-200 rounded-2xl p-5 " + "bg-amber-50/80 space-y-2 border-l-4 border-l-[#881c1c]" ): - label("Tool call").classes("text-xs font-semibold text-[#881c1c]") + label("Tool call").classes("text-sm font-semibold text-[#881c1c] uppercase tracking-wider") tcalls = getattr(msg, "tool_calls", None) or [] if tcalls: code(json.dumps(tcalls, indent=2, default=str)).classes( "text-xs w-full whitespace-pre-wrap" ) elif content: - label(content).classes("text-sm text-zinc-800") + label(content).classes("text-base text-slate-800") message_id = getattr(msg, "message_id", None) if message_id: from frontend.components.chat import rerun_tool_call @@ -302,7 +302,7 @@ def _open_job() -> None: async def _do_rerun(mid: str = message_id) -> None: await rerun_tool_call(mid) - button("Re-run Job", icon="replay", on_click=_do_rerun).classes( + button("Re-run Job", icon="replay", color=None, on_click=_do_rerun).classes( f"mt-1 {Design.BTN_MEDIUM_GRAY}" ) return @@ -312,15 +312,15 @@ async def _do_rerun(mid: str = message_id) -> None: with card().classes( "w-full max-w-3xl border border-red-200 bg-red-50 p-4 space-y-1" ): - label("Error").classes("text-xs font-semibold text-red-800") - label(content).classes("text-sm text-red-900 whitespace-pre-wrap") + label("Error").classes("text-sm font-semibold text-red-800") + label(content).classes("text-base text-red-900 whitespace-pre-wrap") return with container: if role == "user": - chat_message(content, name="You", sent=True) + chat_message(content, name="You", sent=True, bg_color="blue-grey-1", text_color="dark") else: - chat_message(content, name="Assistant") + chat_message(content, name="RescueBox Assistant", bg_color="primary", text_color="white") def show_error_message(container: element, message: str): @@ -349,7 +349,7 @@ async def show_tool_selection(container: element, endpoint: str): render_tool_selection_message(container, endpoint) except Exception: with container: - label(f"Running {endpoint}...").classes("text-sm text-zinc-500 italic") + label(f"Running {endpoint}...").classes("text-sm text-slate-500 italic") async def load_and_show_form( @@ -364,6 +364,45 @@ async def load_and_show_form( ) return + # Inject pipelined job output if present + pipeline_job_id = None + try: + pipeline_job_id = app.storage.user.get("pipeline_job_id") + except Exception: + pass + + if pipeline_job_id: + try: + from frontend.database import get_job_db + job = get_job_db().get_job_by_uid_sync(pipeline_job_id) + if job and job.response: + from frontend.chatbot.multi_tool_handler import extract_output_path + from rb.api.models import ResponseBody + response_body = job.response + if not isinstance(response_body, ResponseBody): + response_body = ResponseBody(**response_body) + output_path = extract_output_path(response_body) + if output_path: + from rb.api.models import InputType + input_dir_key = None + for input_schema in task_schema.inputs: + if input_schema.input_type == InputType.DIRECTORY: + key_lower = input_schema.key.lower() + if "input" in key_lower and "dir" in key_lower: + input_dir_key = input_schema.key + break + if not input_dir_key: + for input_schema in task_schema.inputs: + if input_schema.input_type == InputType.DIRECTORY: + input_dir_key = input_schema.key + break + if input_dir_key: + arguments = arguments.copy() if arguments else {} + arguments[input_dir_key] = output_path + logger.info("Pipelining: injected output path '%s' into '%s'", output_path, input_dir_key) + except Exception as e: + logger.error("Error auto-injecting pipeline path: %s", e) + initial_values = core.convert_arguments_to_initial_values( arguments, task_schema, endpoint ) @@ -375,7 +414,7 @@ async def _wrapped_submit(form_data, endpoint=None, task_schema=None, **kwargs): form_data, endpoint=endpoint, task_schema=task_schema, **kwargs ) - await core.create_input_form( + return await core.create_input_form( task_schema, endpoint, initial_values=initial_values, @@ -456,6 +495,7 @@ def __init__( self.models_btn = None self.analyze_btn = None self.history_btn = None + self.active_form = None def build_ui(self): from frontend.components.chat import ( @@ -464,28 +504,88 @@ def build_ui(self): create_input_area, ) + pipeline_job_id = None + try: + pipeline_job_id = app.storage.user.get("pipeline_job_id") + except Exception: + pass + with column().classes( - "rb-chat-layout-core min-h-screen w-full flex flex-col -mt-16 bg-zinc-50 relative" + "rb-chat-layout-core min-h-screen w-full flex flex-col bg-slate-50 relative" ): - self.models_btn, self.analyze_btn, self.history_btn = create_chat_header( - on_show_history=self._show_history_dialog - ) - + # We integrate the buttons directly into the card header below, so we don't need the floating header row here anymore. with column().classes( - "container mx-auto w-full px-4 flex-1 flex flex-col min-h-0 pb-4" + "container mx-auto w-full max-w-6xl px-4 sm:px-8 py-8 flex-1 flex flex-col min-h-0 pb-16" ): + # Page Header (Matches Jobs, Logs, Models pages) + from frontend.constants import UI_TITLES + with row().classes("items-center gap-2 mb-6"): + icon("forum", size="lg").classes("text-[#881c1c]") + label(UI_TITLES.get("chatbot", "RescueBox Assistant")).classes( + "text-4xl font-bold text-slate-800" + ) + + if pipeline_job_id: + from frontend.database import get_job_db + job = get_job_db().get_job_by_uid_sync(pipeline_job_id) + if job: + endpoint = job.endpoint or "Unknown" + pname = job.plugin_name or endpoint + from frontend.chatbot.multi_tool_handler import extract_output_path + from rb.api.models import ResponseBody + response_body = job.response + if response_body: + if not isinstance(response_body, ResponseBody): + response_body = ResponseBody(**response_body) + output_path = extract_output_path(response_body) + else: + output_path = "N/A" + + with row().classes("w-full bg-rose-50 border border-rose-200 p-3 rounded-xl items-center justify-between mb-4 shadow-sm"): + with row().classes("items-center gap-2"): + icon("link").classes("text-[#881c1c]") + with column().classes("gap-0.5"): + label(f"Pipelining from Job {pipeline_job_id} ({pname})").classes("font-bold text-rose-900 text-sm") + label(f"Output Path: {output_path}").classes("font-mono text-xs text-rose-700") + + def _clear_pipeline(): + app.storage.user.pop("pipeline_job_id", None) + ui.notify("Pipeline cleared.", type="info") + ui.timer(0.1, lambda: ui.navigate.reload(), once=True) + + button("Clear Pipeline", on_click=_clear_pipeline).classes("bg-red-50 hover:bg-red-100 text-[#881c1c] px-3 py-1 rounded text-xs transition-colors") + with card().classes(Design.PANEL_SHELL_CHAT_CARD): with row().classes(Design.PANEL_SHELL_HEADER): - label("RescueBox Assistant").classes( - Design.PANEL_SHELL_HEADER_TITLE - ) - self.mode_indicator = badge("Chat mode", color=None).classes( - "text-xs font-medium rb-chat-mode-badge" - ) + with row().classes("items-center gap-3"): + icon("settings_suggest", size="sm").classes("text-[#881c1c]") + label("Active Mode:").classes( + "text-base font-bold text-slate-700" + ) + self.mode_indicator = badge("Chat mode", color=None).classes( + "text-sm font-semibold rb-chat-mode-badge px-3 py-1 rounded-full" + ) + + with row().classes("items-center gap-2"): + self.analyze_btn = ( + button("Chat", icon="chat", color=None) + .classes("rb-chatbot-tab-btn px-4 py-2 rounded-lg font-semibold transition-all text-base") + .props("unelevated no-caps") + ) + self.models_btn = ( + button("Menu", icon="menu", color=None) + .classes("rb-chatbot-tab-btn px-4 py-2 rounded-lg font-semibold transition-all text-base") + .props("unelevated no-caps") + ) + self.history_btn = ( + button("History", icon="history", color=None, on_click=self._show_history_dialog) + .classes("rb-chatbot-tab-btn px-4 py-2 rounded-lg font-semibold transition-all text-base") + .props("unelevated no-caps") + ) chat_container = create_chat_window() - input_area = create_input_area(self.status_text_ref, self.on_send) - self.input_field = input_area.input_field + self.input_area = create_input_area(self.status_text_ref, self.on_send) + self.input_field = self.input_area.input_field below_input_area = column().classes( "rb-chat-below-input-area w-full max-w-none space-y-4 mt-2 mb-4" @@ -498,14 +598,24 @@ def build_ui(self): chat_container, self.input_field, self.status_text_ref, - input_area, + self.input_area, below_input_area, ) def _setup_mode_handlers(self, chat_container): + # Initial active state: Chat mode + self.analyze_btn.classes("rb-tab-active") + async def handle_models_click(): self.mode_indicator.set_text("Menu mode") + self.models_btn.classes("rb-tab-active") + self.analyze_btn.classes(remove="rb-tab-active") chat_container.clear() + + # Hide the chat input area completely in Menu Mode + if hasattr(self, "input_area") and self.input_area: + self.input_area.classes("hidden") + await asyncio.sleep(0.01) # Give NiceGUI a moment from .handlers import ToolPicker @@ -516,7 +626,16 @@ async def handle_models_click(): async def handle_analyze_click(): self.mode_indicator.set_text("Chat mode") + self.analyze_btn.classes("rb-tab-active") + self.models_btn.classes(remove="rb-tab-active") chat_container.clear() + + # Show and enable the chat input area in Chat Mode + if hasattr(self, "input_area") and self.input_area: + self.input_area.classes(remove="hidden") + if self.state_manager: + self.state_manager.set_input_enabled(True) + from frontend.components.chat import render_welcome_message render_welcome_message(chat_container) @@ -525,9 +644,20 @@ async def handle_analyze_click(): self.analyze_btn.on_click(handle_analyze_click) async def _on_tool_selected(self, endpoint, arguments): + # Delete previous unsubmitted form if it exists + if hasattr(self, "active_form") and self.active_form: + try: + self.active_form.delete() + except Exception: + pass + self.active_form = None + async def handle_form_submit( request_body, endpoint=None, task_schema=None, **kwargs ): + # Form is being submitted, so it's no longer an active unsubmitted form + if hasattr(self, "active_form"): + self.active_form = None return await self.form_submit_handler.submit_form( request_body, endpoint, @@ -540,12 +670,17 @@ async def handle_form_submit( def _on_cancel(): if self.state_manager: self.state_manager.set_input_enabled(True) + if hasattr(self, "active_form") and self.active_form: + form_to_delete = self.active_form + self.active_form = None + # Delete the form card in the next event loop tick to let container.clear() finish safely + ui.timer(0.01, lambda: form_to_delete.delete(), once=True) # Stage 1: Grey out input area while form is being filled if self.state_manager: self.state_manager.set_input_enabled(False, hide_completely=False) - await load_and_show_form( + self.active_form = await load_and_show_form( self.chat_container, self.core, endpoint, @@ -687,7 +822,7 @@ async def _handle_conversation_select(self, conversation_id: str): with self.chat_container: separator() label("Conversation history").classes( - "text-xs font-medium text-zinc-500 uppercase tracking-wide" + "text-xs font-semibold text-slate-500 uppercase tracking-wider" ) i = 0 @@ -850,20 +985,20 @@ async def chatbot_page( ui.add_head_html( """ """ From e4c5e627d059504660900a436a0278604464d2db Mon Sep 17 00:00:00 2001 From: Sahil Sharma Date: Fri, 5 Jun 2026 17:58:44 -0400 Subject: [PATCH 05/11] feat(ui): redesign plugin selector and analysis mode with rich icons and action badges --- frontend/components/pickers.py | 1 + frontend/pages/chatbot/handlers.py | 248 +++++++++++++++++++++-------- 2 files changed, 181 insertions(+), 68 deletions(-) diff --git a/frontend/components/pickers.py b/frontend/components/pickers.py index 620c738d..af19b8b3 100644 --- a/frontend/components/pickers.py +++ b/frontend/components/pickers.py @@ -28,6 +28,7 @@ def show_analysis_picker_dialog( for num, option in options.items(): ui.button( f'{num}. {option["name"]} - {option["desc"]}', + color=None, on_click=lambda *a, opt=option: on_selected(opt["name"]), ).classes( "text-left p-2 h-auto whitespace-normal justify-start text-sm " diff --git a/frontend/pages/chatbot/handlers.py b/frontend/pages/chatbot/handlers.py index 1a07148a..ce569ce9 100644 --- a/frontend/pages/chatbot/handlers.py +++ b/frontend/pages/chatbot/handlers.py @@ -78,23 +78,32 @@ async def _execute_job( f"Processing {ToolRegistry.display_name_for_endpoint(endpoint)}..." ) + pipeline_total = (1 + len(remaining_calls)) if remaining_calls else None + db_kwargs = { + k: v for k, v in kwargs.items() if k not in ("form_element",) + } + + # Create and track job in the main thread (so we get the job_id and can redirect immediately) + job_id = None + try: + job_record = await DatabaseService.create_and_track_job( + request_body, + endpoint, + task_schema, + user_id=get_user_id_for_jobs(), + pipeline_total_steps=pipeline_total, + **db_kwargs, + ) + job_id = job_record.get("job_id") if job_record else None + except Exception as e: + self.logger.error(f"Failed to create and track job in DB: {e}") + + if job_id: + # Redirect immediately to the general jobs view so the user can see the list of jobs + ui.timer(0.1, lambda: ui.navigate.to("/jobs"), once=True) + async def do_submit(): try: - pipeline_total = (1 + len(remaining_calls)) if remaining_calls else None - db_kwargs = { - k: v for k, v in kwargs.items() if k not in ("form_element",) - } - - job_record = await DatabaseService.create_and_track_job( - request_body, - endpoint, - task_schema, - user_id=get_user_id_for_jobs(), - pipeline_total_steps=pipeline_total, - **db_kwargs, - ) - job_id = job_record.get("job_id") if job_record else None - if conversation_id and job_id: await DatabaseService.save_job_started_to_history( conversation_id, @@ -114,36 +123,60 @@ async def do_submit(): except Exception: pass - await self._handle_success( - request_body, - endpoint, - task_schema, - target_container, - core, - remaining_calls, - conversation_id, - response_body, - {"job_id": job_id}, - ) + try: + await self._handle_success( + request_body, + endpoint, + task_schema, + target_container, + core, + remaining_calls, + conversation_id, + response_body, + {"job_id": job_id}, + ) + except Exception as ui_err: + self.logger.debug(f"UI update skipped (likely navigated away): {ui_err}") except Exception as e: self.logger.error(f"Job submission failed: {e}") + message = str(e) + if job_id: + try: + await DatabaseService.update_job_status( + job_uid=job_id, status="Failed", status_text=message + ) + except Exception as db_err: + self.logger.error(f"Failed to update job status to Failed in DB: {db_err}") + if conversation_id: + try: + await DatabaseService.save_error_to_history( + conversation_id, endpoint, message + ) + except Exception as hist_err: + self.logger.error(f"Failed to save error to chat history: {hist_err}") if loading_row and hasattr(loading_row, "delete"): try: loading_row.delete() except Exception: pass - message = str(e) - if "demo_???" in message: - from frontend.pages.chatbot.ui import UIOperations + + try: + if "demo_???" in message: + from frontend.pages.chatbot.ui import UIOperations - UIOperations.safe_notify(message, type="warning") - else: - self.error_handler.display_error_boundary( - target_container, "Submission Failed", message - ) + UIOperations.safe_notify(message, type="warning") + else: + self.error_handler.display_error_boundary( + target_container, "Submission Failed", message + ) + except Exception as ui_err: + self.logger.debug(f"Could not display error to UI: {ui_err}") finally: - self.state_manager.set_processing(False) - self.state_manager.set_input_enabled(True) + try: + self.state_manager.set_processing(False) + self.state_manager.set_input_enabled(True) + except Exception: + pass background_tasks.create(do_submit()) return True @@ -235,25 +268,48 @@ async def show(self): f"ToolPicker.show menu source: {'Instance' if hasattr(self.tool_registry, 'TOOL_MENU') else 'Class'}. Items: {len(menu)}" ) + def get_tool_icon(name: str) -> str: + name_lower = name.lower() + if "transcribe" in name_lower or "audio" in name_lower: + return "mic" + if "describe" in name_lower or "summarize images" in name_lower: + return "visibility" + if "search images" in name_lower: + return "image_search" + if "age" in name_lower or "gender" in name_lower: + return "face_retouching_natural" + if "deepfake" in name_lower: + return "security" + if "upload face" in name_lower: + return "cloud_upload" + if "find face" in name_lower or "face match" in name_lower: + return "person_search" + if "summarize text" in name_lower or "text_summarization" in name_lower: + return "summarize" + if "search text" in name_lower: + return "find_in_page" + if "mount" in name_lower or "ufdr" in name_lower: + return "folder_open" + if "similar" in name_lower: + return "photo_library" + return "extension" + with self.container: - # Replicating original TOOL_PICKER_CLASSES - picker_classes = ( - "w-full max-w-3xl min-w-0 mx-auto bg-gradient-to-br from-zinc-50 via-white to-zinc-100 " - "border-2 border-[#505759]/40 shadow-lg rounded-xl text-base" - ) - with ui.card().classes(picker_classes): + with ui.card().classes("w-full max-w-full bg-white border border-slate-200 shadow-md rounded-2xl overflow-hidden p-0"): with ui.row().classes(Design.PANEL_SHELL_HEADER): - ui.label("RescueBox Plugin Selector").classes( - Design.PANEL_SHELL_HEADER_TITLE - ) + with ui.row().classes("items-center gap-2"): + ui.icon("extension", size="sm").classes("text-[#881c1c]") + ui.label("RescueBox Plugin Selector").classes( + Design.PANEL_SHELL_HEADER_TITLE + ) - with ui.column().classes("p-4 gap-3 w-full"): + with ui.column().classes("p-6 gap-3 w-full bg-slate-50"): ui.label("Choose a plugin to run:").classes( - "text-sm font-semibold text-zinc-700" + "text-sm font-semibold text-slate-500 uppercase tracking-wider" ) if not menu: ui.label("No plugins available in TOOL_MENU.").classes( - "text-sm text-red-500" + "text-sm text-rose-500 font-medium" ) else: for num, tool in menu.items(): @@ -261,7 +317,9 @@ async def show(self): f"Adding tool to UI: {num} - {tool.get('name')}" ) row = ui.row().classes( - f"w-full min-w-0 py-2 px-3 rounded-lg {Design.CHATBOT_PLUGIN_MENU_ROW} cursor-pointer" + "w-full min-w-0 py-4 px-5 rounded-xl border border-slate-200 bg-white shadow-sm " + "hover:bg-slate-50 hover:border-[#881c1c] cursor-pointer transition-all duration-150 " + "items-center justify-between gap-4 border-l-4 border-l-[#881c1c]" ) row.on( "click", @@ -270,12 +328,27 @@ async def show(self): ), ) with row: - ui.label( - f'{num}. {tool["name"]} — {tool.get("desc", "No description")}' - ).classes( - "w-full text-left text-sm leading-snug font-medium text-zinc-900 " - "whitespace-normal break-words" - ) + # Left side: Icon and Text + with ui.row().classes("items-center gap-4 flex-1 min-w-0"): + # Beautiful icon container + with ui.element("div").classes( + "w-12 h-12 rounded-xl bg-[#881c1c]/5 flex items-center justify-center shrink-0 border border-[#881c1c]/10" + ): + ui.icon(get_tool_icon(tool["name"]), size="24px").classes("text-[#881c1c]") + + # Text column + with ui.column().classes("flex-1 min-w-0 gap-0.5"): + ui.label(f'{num}. {tool["name"]}').classes( + "text-lg font-bold text-slate-800 leading-snug" + ) + ui.label(tool.get("desc", "No description")).classes( + "text-sm sm:text-base text-slate-500 whitespace-normal break-words leading-relaxed" + ) + + # Right side: Launch action indicator + with ui.row().classes("items-center gap-1 shrink-0 text-[#881c1c] font-semibold text-sm bg-[#881c1c]/5 hover:bg-[#881c1c]/10 px-3 py-1.5 rounded-lg transition-all"): + ui.label("Launch") + ui.icon("arrow_forward", size="16px") self.logger.info("ToolPicker.show finished building UI.") @@ -291,29 +364,64 @@ async def show(self): self.logger.info("AnalysisPicker.show started") with self.container: - # Replicating original ANALYSIS_PICKER_CLASSES - picker_classes = "w-full max-w-2xl mx-auto bg-zinc-50 border-2 border-[#505759]/40 text-sm shadow-lg rounded-xl" - with ui.card().classes(picker_classes): + with ui.card().classes("w-full max-w-full bg-white border border-slate-200 shadow-md rounded-2xl overflow-hidden p-0"): with ui.row().classes(Design.PANEL_SHELL_HEADER): - ui.label("Analysis Mode").classes(Design.PANEL_SHELL_HEADER_TITLE) + with ui.row().classes("items-center gap-2"): + ui.icon("analytics", size="sm").classes("text-[#881c1c]") + ui.label("Analysis Mode").classes(Design.PANEL_SHELL_HEADER_TITLE) - with ui.column().classes("p-4 gap-3 w-full"): + with ui.column().classes("p-6 gap-3 w-full bg-slate-50"): ui.label("Select an analysis type:").classes( - "text-sm text-zinc-600" + "text-sm font-semibold text-slate-500 uppercase tracking-wider" ) options = ["Surface Scan", "Deep Forensic", "AI Content Analysis"] + analysis_details = { + "Surface Scan": { + "desc": "Quickly analyze metadata, file headers, and basic structures", + "icon": "radar" + }, + "Deep Forensic": { + "desc": "Comprehensive, byte-level analysis of all partitions and hidden data", + "icon": "biotech" + }, + "AI Content Analysis": { + "desc": "Leverage machine learning models to detect objects, faces, and transcribe media", + "icon": "psychology" + } + } for a_type in options: + details = analysis_details.get(a_type, {"desc": "Run automated analysis", "icon": "analytics"}) self.logger.info(f"Adding analysis option: {a_type}") row = ui.row().classes( - f"w-full min-w-0 py-3 px-3 rounded-lg {Design.CHATBOT_PLUGIN_MENU_ROW} cursor-pointer" + "w-full min-w-0 py-4 px-5 rounded-xl border border-slate-200 bg-white shadow-sm " + "hover:bg-slate-50 hover:border-[#881c1c] cursor-pointer transition-all duration-150 " + "items-center justify-between gap-4 border-l-4 border-l-[#881c1c]" ) row.on( "click", lambda *a, t=a_type: self.on_analysis_selected(t) ) with row: - ui.label(a_type).classes( - "w-full text-left text-sm leading-snug font-medium text-zinc-900" - ) + # Left side: Icon and Text + with ui.row().classes("items-center gap-4 flex-1 min-w-0"): + # Beautiful icon container + with ui.element("div").classes( + "w-12 h-12 rounded-xl bg-[#881c1c]/5 flex items-center justify-center shrink-0 border border-[#881c1c]/10" + ): + ui.icon(details["icon"], size="24px").classes("text-[#881c1c]") + + # Text column + with ui.column().classes("flex-1 min-w-0 gap-0.5"): + ui.label(a_type).classes( + "text-lg font-bold text-slate-800 leading-snug" + ) + ui.label(details["desc"]).classes( + "text-sm sm:text-base text-slate-500 whitespace-normal break-words leading-relaxed" + ) + + # Right side: Launch action indicator + with ui.row().classes("items-center gap-1 shrink-0 text-[#881c1c] font-semibold text-sm bg-[#881c1c]/5 hover:bg-[#881c1c]/10 px-3 py-1.5 rounded-lg transition-all"): + ui.label("Analyze") + ui.icon("arrow_forward", size="16px") self.logger.info("AnalysisPicker.show finished building UI.") @@ -336,14 +444,16 @@ async def show_case_notes_dialog() -> Optional[str]: future = loop.create_future() with ui.dialog() as dialog, ui.card().classes(Design.PANEL_SHELL_CARD_NARROW): with ui.row().classes(Design.PANEL_SHELL_HEADER): - ui.label("Job Submission Details").classes(Design.PANEL_SHELL_HEADER_TITLE) + with ui.row().classes("items-center gap-2"): + ui.icon("rate_review", size="sm").classes("text-[#881c1c]") + ui.label("Job Submission Details").classes(Design.PANEL_SHELL_HEADER_TITLE) ui.button( - icon="close", on_click=lambda: (future.set_result(None), dialog.close()) + icon="close", color=None, on_click=lambda: (future.set_result(None), dialog.close()) ).props("flat round dense").classes(Design.PANEL_SHELL_HEADER_ICON) with ui.column().classes(Design.PANEL_SHELL_BODY + " gap-4"): ui.label("Add optional notes for the case file:").classes( - "text-sm text-zinc-500" + "text-sm text-slate-500 font-medium" ) # Use rb-case-notes-field to ensure maroon/gray brand colors and no blue/indigo notes = ( @@ -355,10 +465,12 @@ async def show_case_notes_dialog() -> Optional[str]: with ui.row().classes(Design.PANEL_SHELL_FOOTER + " justify-end"): ui.button( "Skip & Submit", + color=None, on_click=lambda: (future.set_result(""), dialog.close()), ).classes(Design.BTN_MEDIUM_GRAY).props("outline") ui.button( "Submit with Notes", + color=None, on_click=lambda: (future.set_result(notes.value), dialog.close()), ).classes(Design.BTN_PRIMARY) dialog.open() From 819353be90571a7a4b764e50169aef61f2c8fa5f Mon Sep 17 00:00:00 2001 From: Sahil Sharma Date: Fri, 5 Jun 2026 17:59:01 -0400 Subject: [PATCH 06/11] style(forms): align dynamic form fields and inputs with UMass theme --- frontend/chatbot/forms.py | 2 +- frontend/chatbot/tool_config.py | 33 +++++++++++++++++ frontend/components/forms/dialogs.py | 3 +- frontend/components/forms/field_builders.py | 39 ++++++++++++++------- frontend/components/forms/form_generator.py | 4 +-- 5 files changed, 65 insertions(+), 16 deletions(-) diff --git a/frontend/chatbot/forms.py b/frontend/chatbot/forms.py index 93f03def..77e0e969 100644 --- a/frontend/chatbot/forms.py +++ b/frontend/chatbot/forms.py @@ -26,7 +26,7 @@ async def create_input_form( form_card = ui.card().classes( "w-full max-w-full min-w-0 text-sm " "bg-white ring-1 ring-zinc-200 rounded-2xl rounded-tl-none shadow-sm " - "border-0 rb-form-wrapper" + "border-0 rb-form-wrapper !p-0" ) with form_card: form_generator = FormGenerator() diff --git a/frontend/chatbot/tool_config.py b/frontend/chatbot/tool_config.py index e803b450..02d05c4f 100644 --- a/frontend/chatbot/tool_config.py +++ b/frontend/chatbot/tool_config.py @@ -285,6 +285,38 @@ def create_advanced_granite_prompt(user_query: str) -> list[dict[str, str]]: # Generate Dynamic Schema for the prompt tools_definitions = generate_tool_definitions() + from frontend.utils import get_active_case + from nicegui import app + + active_case = get_active_case() + context_prefix = "" + if active_case: + context_prefix += f"ACTIVE CASE CONTEXT:\n- Case Number: {active_case.caseNumber}\n- Evidence Path: {active_case.evidencePath}\n" + context_prefix += "- Use the evidence path as the default input directory/file path for all tools if not specified otherwise.\n" + + pipeline_job_id = None + try: + pipeline_job_id = app.storage.user.get("pipeline_job_id") + except Exception: + pass + + if pipeline_job_id: + try: + from frontend.database import get_job_db + job = get_job_db().get_job_by_uid_sync(pipeline_job_id) + if job and job.response: + from frontend.chatbot.multi_tool_handler import extract_output_path + from rb.api.models import ResponseBody + response_body = job.response + if not isinstance(response_body, ResponseBody): + response_body = ResponseBody(**response_body) + output_path = extract_output_path(response_body) + if output_path: + context_prefix += f"PIPELINED JOB CONTEXT:\n- Source Job ID: {pipeline_job_id}\n- Source Job Output Path: {output_path}\n" + context_prefix += "- Use this output path as the input directory/file path for the next tool call if the user asks to analyze/pipeline/use the results of the previous job.\n" + except Exception as e: + logger.error("Error extracting pipeline job output path in prompt: %s", e) + # ========================================== # FEW-SHOT PROMPTING (The Secret Sauce) # ========================================== @@ -293,6 +325,7 @@ def create_advanced_granite_prompt(user_query: str) -> list[dict[str, str]]: system_msg = { "role": "system", "content": ( + f"{context_prefix}\n" "You are a forensic analysis assistant for RescueBox.\n" "RULES:\n" "1. CHAINING: If the user requests multiple actions, generate a LIST of tools in execution order.\n" diff --git a/frontend/components/forms/dialogs.py b/frontend/components/forms/dialogs.py index d5386013..39f81150 100644 --- a/frontend/components/forms/dialogs.py +++ b/frontend/components/forms/dialogs.py @@ -21,11 +21,12 @@ async def show_case_notes_dialog() -> Optional[str]: ).classes(f"w-full min-h-24 {Design.INPUT_OUTLINED}") with ui.row().classes(f"{Design.PANEL_SHELL_FOOTER} justify-end flex-wrap"): - ui.button("Cancel", on_click=lambda: dialog.submit(None)).classes( + ui.button("Cancel", color=None, on_click=lambda: dialog.submit(None)).classes( Design.BTN_MEDIUM_GRAY ) ui.button( "Submit Job", + color=None, on_click=lambda: dialog.submit((textarea.value or "").strip()), ).classes("rb-brand-primary text-white rounded-xl px-4 py-2") diff --git a/frontend/components/forms/field_builders.py b/frontend/components/forms/field_builders.py index a1bdc202..7bf4d1e3 100644 --- a/frontend/components/forms/field_builders.py +++ b/frontend/components/forms/field_builders.py @@ -175,17 +175,26 @@ async def create_parameter_field( def create_directory_input( field_id, initial_value, form_widgets, autofill_output_key=None ): + from frontend.utils import get_active_case + active_case = get_active_case() + default_path = active_case.evidencePath if active_case else "" + + val = "" + if isinstance(initial_value, dict): + val = initial_value.get("path", "") + elif isinstance(initial_value, str): + val = initial_value + + if not val: + val = default_path + with ui.column().classes("w-full min-w-0 gap-1"): ui.label("Directory path").classes("text-sm font-medium text-zinc-700") with ui.row().classes("w-full min-w-0 items-center gap-2 flex-nowrap"): dir_input = ( ui.input( placeholder="/path/to/directory", - value=( - initial_value.get("path", "") - if isinstance(initial_value, dict) - else "" - ), + value=val, ) .classes("flex-1 min-w-0") .props("outlined dense") @@ -219,24 +228,30 @@ def validate(): ui.button( "Browse", on_click=lambda: browse_directory_simple( - dir_input, on_after_select=validate + dir_input, initial_path=default_path or None, on_after_select=validate ), ).classes(Design.BTN_MEDIUM_GRAY) form_widgets[field_id] = dir_input def create_file_input(field_id, initial_value, form_widgets, autofill_mount_key=None): + from frontend.utils import get_active_case + active_case = get_active_case() + default_path = active_case.evidencePath if active_case else "" + + val = "" + if isinstance(initial_value, dict): + val = initial_value.get("path", "") + elif isinstance(initial_value, str): + val = initial_value + with ui.column().classes("w-full min-w-0 gap-1"): ui.label("File path").classes("text-sm font-medium text-zinc-700") with ui.row().classes("w-full min-w-0 items-center gap-2 flex-nowrap"): file_input = ( ui.input( placeholder="/path/to/file", - value=( - initial_value.get("path", "") - if isinstance(initial_value, dict) - else "" - ), + value=val, ) .classes("flex-1 min-w-0") .props("outlined dense") @@ -270,7 +285,7 @@ def validate(): ui.button( "Browse", on_click=lambda: browse_file_simple( - file_input, on_after_select=validate + file_input, initial_path=default_path or None, on_after_select=validate ), ).classes(Design.BTN_MEDIUM_GRAY) form_widgets[field_id] = file_input diff --git a/frontend/components/forms/form_generator.py b/frontend/components/forms/form_generator.py index 9db455c3..b0f39eb7 100644 --- a/frontend/components/forms/form_generator.py +++ b/frontend/components/forms/form_generator.py @@ -42,7 +42,7 @@ def _cancel_wrapper(): return on_cancel() - ui.button("Cancel", on_click=_cancel_wrapper).classes( + ui.button("Cancel", color=None, on_click=_cancel_wrapper).classes( Design.BTN_MEDIUM_GRAY ) @@ -59,7 +59,7 @@ async def _submit_wrapper(): finally: btn.props["loading"] = False - submit_btn = ui.button("▶ Submit Job", on_click=_submit_wrapper).classes( + submit_btn = ui.button("▶ Submit Job", color=None, on_click=_submit_wrapper).classes( "rb-brand-primary text-white rounded-xl" ) btn_ref[0] = submit_btn From 4b29cf2511951d695af4262bf48d4d29a84d5875 Mon Sep 17 00:00:00 2001 From: Sahil Sharma Date: Fri, 5 Jun 2026 17:59:19 -0400 Subject: [PATCH 07/11] fix(ui): standardize jobs and logs pages, debounce logs search, and fix reset --- frontend/components/jobs.py | 68 +++++++++++++++++------------ frontend/components/logs.py | 72 ++++++++++++++++++++++++++----- frontend/database/job_db.py | 50 +++++++++++++++++++++ frontend/pages/jobs/components.py | 19 +++++--- frontend/pages/jobs/details.py | 8 +++- frontend/pages/jobs/list.py | 28 ++++++++---- frontend/pages/logs.py | 26 ++++++++--- 7 files changed, 209 insertions(+), 62 deletions(-) diff --git a/frontend/components/jobs.py b/frontend/components/jobs.py index 4ad6c81d..307164c5 100644 --- a/frontend/components/jobs.py +++ b/frontend/components/jobs.py @@ -46,6 +46,7 @@ def _download() -> None: ui.button( "Export CASE JSON-LD", icon="download", + color=None, on_click=_download, ).classes( Design.BTN_MEDIUM_GRAY @@ -182,7 +183,7 @@ async def render_job_details_panel( with container: with ui.card().classes( - "w-full min-w-0 max-w-full self-stretch bg-white border border-zinc-300 p-6" + "w-full min-w-0 max-w-full self-stretch bg-white border border-slate-200 shadow-md rounded-2xl p-6" ): # Job metadata header with ui.column().classes("gap-4 w-full min-w-0 max-w-full"): @@ -324,7 +325,7 @@ async def render_job_outputs_card(container, api_client, job): return with ui.card().classes( - "w-full min-w-0 max-w-full self-stretch bg-white border border-zinc-300 p-6" + "w-full min-w-0 max-w-full self-stretch bg-white border border-slate-200 shadow-md rounded-2xl p-6" ): # Breadcrumbs live on the job page layout only (avoid duplicating under Outputs). @@ -418,14 +419,15 @@ def render_job_row( ) status = job.get("status", "Unknown") - status_colors = { - "Running": "text-[#881c1c]", - "Completed": "text-green-600", - "Failed": "text-red-600", - "Canceled": "text-zinc-600", + + # Status Pill Badges + status_pill_classes = { + "Completed": "bg-emerald-50 text-emerald-700 border border-emerald-200", + "Running": "bg-rose-50 text-[#881c1c] border border-rose-200", + "Failed": "bg-rose-50 text-rose-700 border border-rose-200", + "Canceled": "bg-slate-100 text-slate-600 border border-slate-200", } - status_color = status_colors.get(status, "text-zinc-600") - # logger.debug("Job status: %s, color class: %s", status, status_color) + pill_cls = status_pill_classes.get(status, "bg-slate-50 text-slate-500 border border-slate-200") # Format timestamps start_time_str = "N/A" @@ -433,7 +435,6 @@ def render_job_row( try: start_time = datetime.fromisoformat(job["startTime"].replace("Z", "+00:00")) start_time_str = start_time.strftime("%Y-%m-%d %H:%M") - # logger.debug("Formatted start time: %s", start_time_str) except Exception as e: logger.warning( "Failed to parse start time: %s, error: %s", job["startTime"], e @@ -445,7 +446,6 @@ def render_job_row( try: end_time = datetime.fromisoformat(job["endTime"].replace("Z", "+00:00")) end_time_str = end_time.strftime("%Y-%m-%d %H:%M") - # logger.debug("Formatted end time: %s", end_time_str) except Exception as e: logger.warning("Failed to parse end time: %s, error: %s", job["endTime"], e) end_time_str = job["endTime"] @@ -453,55 +453,67 @@ def render_job_row( job_uid = job.get("uid", "N/A") with container: with ui.row().classes( - "p-4 border-b hover:bg-zinc-50 items-center w-full flex-nowrap gap-2" + "p-4 border-b border-slate-200 hover:bg-slate-50 items-center w-full flex-nowrap gap-2 bg-white" ): # Job ID - truncated with ellipsis, full ID on hover with ui.element("div").classes("w-40 min-w-0 shrink-0"): - id_label = ui.label(job_uid).classes("font-mono text-sm truncate block") + id_label = ui.label(job_uid).classes("font-mono text-sm truncate block text-slate-800") id_label.tooltip(job_uid) # Model name (and notes indicator) with ui.element("div").classes( - "flex-1 min-w-0 overflow-hidden flex items-center gap-2" + "flex-1 min-w-0 overflow-hidden flex items-center gap-2 text-slate-800" ): - ui.label(plugin_name or "Unknown").classes("truncate block") + ui.label(plugin_name or "Unknown").classes("truncate block font-medium") if job.get("caseNotes"): notes_preview = (job["caseNotes"] or "")[:50] if len(job.get("caseNotes", "") or "") > 50: notes_preview += "…" ui.icon("description", size="sm").classes( - "text-zinc-500 shrink-0" + "text-slate-500 shrink-0" ).tooltip(notes_preview) # Times (start / end) - with ui.column().classes("w-64 shrink-0"): - ui.label(start_time_str).classes("text-sm") - ui.label(end_time_str).classes("text-xs text-zinc-600") - - # Status - with ui.row().classes("w-32 shrink-0 items-center gap-1"): - ui.label(status).classes(f"{status_color} font-semibold") - if status == "Running": - ui.spinner(color="primary", size="xs") + with ui.column().classes("w-64 shrink-0 gap-0.5"): + ui.label(start_time_str).classes("text-sm text-slate-700") + ui.label(f"Ended: {end_time_str}" if end_time_str != "N/A" else "Active").classes("text-xs text-slate-500") + + # Status Pill Badge + with ui.row().classes(f"w-32 shrink-0 items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold {pill_cls}"): + if status == "Completed": + ui.icon("check_circle", size="14px") + elif status == "Running": + ui.spinner(size="14px").classes("text-[#881c1c]") + elif status == "Failed": + ui.icon("error", size="14px") + else: + ui.icon("cancel", size="14px") + ui.label(status) # Actions - with ui.row().classes("gap-2 w-48 shrink-0"): + with ui.row().classes("gap-2 w-48 shrink-0 flex-nowrap"): if on_view: ui.button( "View", + icon="visibility", + color=None, on_click=lambda: on_view(job["uid"]) if on_view else None, ).classes(Design.BTN_PRIMARY_TIGHT) if status == "Running" and on_cancel: ui.button( "Cancel", + icon="cancel", + color=None, on_click=lambda: on_cancel(job["uid"]) if on_cancel else None, - ).classes("bg-red-600 text-white text-sm") + ).classes("bg-rose-50 hover:bg-rose-100 text-rose-700 px-3 py-1 rounded text-sm transition-colors border border-rose-200") elif status != "Running" and on_delete: ui.button( "Delete", + icon="delete", + color=None, on_click=lambda: on_delete(job["uid"]) if on_delete else None, - ).classes(Design.BTN_PRIMARY_TIGHT) + ).classes("bg-rose-50 hover:bg-rose-100 text-[#881c1c] px-3 py-1 rounded text-sm transition-colors border border-rose-200") # logger.debug("Delete button added") diff --git a/frontend/components/logs.py b/frontend/components/logs.py index 467b6576..215bb4fa 100644 --- a/frontend/components/logs.py +++ b/frontend/components/logs.py @@ -9,17 +9,26 @@ def render_log_viewer(container: ui.element, log_file: Path, max_lines: int = 1000): """ - Render a log viewer inside `container`. Returns the code element for updates. + Render a log viewer inside `container` with search/filtering capabilities. + Returns the code element for updates. """ try: with container: - # Controls row - with ui.row().classes("gap-4 items-center mb-4"): + # Controls row with Refresh and Search Input (instant typing filter) + with ui.row().classes("gap-4 items-center mb-4 w-full flex-wrap sm:flex-nowrap"): refresh_btn = ( ui.button("Refresh") .props("icon=refresh") .classes(Design.BTN_PRIMARY_COMPACT) ) + + # Search input with prepended search icon, clearable prop, and debounce + search_input = ui.input( + placeholder="Search/filter logs...", + ).props("outlined dense clearable debounce=300").classes("w-64 bg-white") + with search_input.add_slot("prepend"): + ui.icon("search").classes("text-slate-400") + ui.label(f"Log file: {str(log_file)}").classes("text-sm text-zinc-600") # Log content display - full width, fill viewport height below navbar @@ -27,23 +36,64 @@ def render_log_viewer(container: ui.element, log_file: Path, max_lines: int = 10 with ui.scroll_area().classes( "min-h-[calc(100vh-12rem)] w-full max-w-full" ): - log_display = ui.code().classes( - "w-full max-w-full text-xs font-mono whitespace-pre-wrap" + # Use a custom lightweight label subclass instead of ui.code to prevent Prism.js DOM bloat and focus lag + class LogDisplayLabel(ui.label): + @property + def content(self) -> str: + return self.text + @content.setter + def content(self, value: str): + self.set_text(value) + + log_display = LogDisplayLabel().classes( + "w-full max-w-full text-xs font-mono whitespace-pre-wrap block p-4 bg-slate-50 rounded-xl border border-slate-200 shadow-inner" ) - log_display.props("language=text") - # Attach simple refresh handler (caller may override or call _load_logs directly) + # Initialize attributes on log_display + log_display.search_input = search_input + log_display.raw_content = "" + + def _apply_filter(query: str = None): + if query is None: + query = (search_input.value or "").strip() + else: + query = str(query).strip() if query is not None else "" + + if not log_display.raw_content: + log_display.content = "" + return + + if not query: + log_display.content = log_display.raw_content + return + + # Filter lines + lines = log_display.raw_content.splitlines() + matching_lines = [line for line in lines if query.lower() in line.lower()] + + if matching_lines: + header = f"[Found {len(matching_lines)} matching lines for '{query}']\n\n" + log_display.content = header + "\n".join(matching_lines) + else: + log_display.content = f"[No matching lines found for '{query}']" + + # Expose apply_filter on log_display so external callers can trigger it + log_display.apply_filter = _apply_filter + + # Attach simple refresh handler def _refresh(): try: - from frontend.pages.logs import read_log_file, format_log_content - + from frontend.pages.logs import read_log_file content = read_log_file(log_file, max_lines) - formatted = format_log_content(content) - log_display.content = formatted + log_display.raw_content = content + _apply_filter(search_input.value) except Exception as e: logger.exception("Failed refreshing logs: %s", e) + # Bind event handlers refresh_btn.on("click", lambda e=None: _refresh()) + search_input.on_value_change(lambda e: _apply_filter(e.value)) + # Return the element for callers to update return log_display except Exception as e: diff --git a/frontend/database/job_db.py b/frontend/database/job_db.py index a4b69d38..a044e31e 100644 --- a/frontend/database/job_db.py +++ b/frontend/database/job_db.py @@ -684,6 +684,46 @@ async def create_job( ) raise RuntimeError("Failed to create job due to database errors") + def get_job_by_uid_sync(self, uid: str) -> Optional[JobRecord]: + """ + Get job by UID synchronously. + """ + conn = self.connect() + try: + self._ensure_userid_column(conn) + self._ensure_caseNotes_column(conn) + self._ensure_endpoint_chain_column(conn) + self._ensure_pipeline_root_job_id_column(conn) + self._ensure_pipeline_metadata_filter_criteria_column(conn) + except Exception: + pass + + cursor = conn.execute("SELECT * FROM jobs WHERE uid = ?", (uid,)) + row = cursor.fetchone() + + if row: + job_dict = self._row_to_dict(row) + try: + from frontend.utils import get_user_id_for_jobs + current_user_id = get_user_id_for_jobs() + except Exception: + current_user_id = None + + if ( + current_user_id + and job_dict.get("userId") + and job_dict.get("userId") != current_user_id + ): + logger.warning("Access denied for job %s: session mismatch", uid) + return None + try: + job_record = JobRecord(**job_dict) + return job_record + except Exception as e: + logger.error("Failed to validate job %s as JobRecord: %s", uid, e) + return None + return None + async def get_job_by_uid(self, uid: str) -> Optional[JobRecord]: """ Get job by UID. @@ -949,6 +989,16 @@ async def update_job_status( logger.warning("Job %s not found for update", uid) return False + async def disassociate_job_from_case(self, uid: str) -> bool: + """ + Disassociate a job from its case by setting its userId to None. + """ + conn = self.connect() + logger.info("Disassociating job %s from case", uid) + cursor = conn.execute("UPDATE jobs SET userId = NULL WHERE uid = ?", (uid,)) + conn.commit() + return cursor.rowcount > 0 + async def delete_job(self, uid: str) -> bool: """ Delete job by UID. diff --git a/frontend/pages/jobs/components.py b/frontend/pages/jobs/components.py index 6323611c..016c5651 100644 --- a/frontend/pages/jobs/components.py +++ b/frontend/pages/jobs/components.py @@ -32,8 +32,8 @@ async def export_audit(): logger.error("Error exporting audit trail: %s", e) notify_error(f"Error exporting audit trail: {str(e)}") - return ui.button("📋 Export Audit Trail", on_click=export_audit).classes( - "rb-brand-primary text-white rounded-xl" + return ui.button("Export Audit Trail", icon="assignment_turned_in", color=None, on_click=export_audit).classes( + "bg-slate-100 hover:bg-slate-200 text-slate-800 px-4 py-2 rounded-lg font-medium transition-colors border border-slate-200" ) @@ -43,10 +43,13 @@ def render_job_action_buttons(job_fields: Dict[str, Any]): if model_uid: ui.button( "Model Doc", + color=None, on_click=lambda: ui.navigate.to(f"/models/{model_uid}/details"), ).classes("rb-brand-primary text-white") ui.button( - "Run Model", on_click=lambda: ui.navigate.to(f"/models/{model_uid}/run") + "Run Model", + color=None, + on_click=lambda: ui.navigate.to(f"/models/{model_uid}/run") ).classes("rb-brand-primary text-white rounded-xl") @@ -73,11 +76,13 @@ def render_readonly_form(task_schema, request_body): def render_error_status(status: str, status_text: Optional[str] = None): - with ui.card().classes("bg-red-50 border border-red-300 p-6"): - ui.label("Job Failed").classes("text-2xl font-bold text-red-800 mb-2") - ui.label(f"Status: {status}").classes("text-lg text-red-600") + with ui.card().classes("bg-rose-50 border border-rose-200 p-6 rounded-2xl shadow-sm border-t-4 border-t-rose-500"): + with ui.row().classes("items-center gap-2 mb-2"): + ui.icon("error", size="md").classes("text-rose-600") + ui.label("Job Failed").classes("text-2xl font-bold text-rose-800") + ui.label(f"Status: {status}").classes("text-lg text-rose-700 font-medium") if status_text: - ui.label(status_text).classes("text-sm text-red-500 mt-2") + ui.label(status_text).classes("text-sm text-rose-600 mt-2 bg-white/50 p-3 rounded-lg border border-rose-100 whitespace-pre-wrap") async def render_model_info(api_client, job_fields: Dict[str, Any]): diff --git a/frontend/pages/jobs/details.py b/frontend/pages/jobs/details.py index 433686ba..784de234 100644 --- a/frontend/pages/jobs/details.py +++ b/frontend/pages/jobs/details.py @@ -52,7 +52,13 @@ async def job_details_page_route(job_id: str): return jf = extract_job_fields(job) - with ui.column().classes("w-full items-stretch px-4 sm:px-8 py-8"): + + # Auto-refresh if the job is running or pending + status = str(jf.get("status", "")).lower() + if status in ("running", "pending"): + ui.timer(3.0, lambda: ui.navigate.reload(), once=True) + + with ui.column().classes("container mx-auto px-4 sm:px-8 py-8 w-full max-w-6xl pb-16 items-stretch"): create_breadcrumbs( [{"label": "Jobs", "link": "/jobs"}, {"label": f"Job {job_id}"}] ) diff --git a/frontend/pages/jobs/list.py b/frontend/pages/jobs/list.py index 520653db..9f7df23f 100644 --- a/frontend/pages/jobs/list.py +++ b/frontend/pages/jobs/list.py @@ -29,9 +29,10 @@ def __init__(self): self.jobs = [] async def render(self): - with ui.column().classes("container mx-auto p-8"): - with ui.row().classes("items-center justify-between mb-6"): - ui.label(UI_TITLES["jobs"]).classes("text-4xl font-bold") + with ui.column().classes("container mx-auto px-4 sm:px-8 py-8 w-full max-w-6xl pb-16"): + with ui.row().classes("items-center gap-2 mb-6"): + ui.icon("view_list", size="lg").classes("text-[#881c1c]") + ui.label(UI_TITLES["jobs"]).classes("text-4xl font-bold text-slate-800") self.jobs_container = ui.column().classes("space-y-2 w-full") await self.load_jobs() @@ -50,7 +51,7 @@ async def render_jobs(self): self.jobs_container.clear() with self.jobs_container: with ui.row().classes( - "bg-[#505759] border-b border-[#3d4442] p-4 font-semibold text-white w-full" + "bg-[#1c1c1c] text-white p-4 font-semibold w-full rounded-t-xl items-center" ): ui.label("Job ID").classes("w-40 shrink-0") ui.label("Plugin").classes("flex-1 min-w-0") @@ -63,14 +64,15 @@ async def render_jobs(self): if len(group) > 1: root_id = pipeline_group_root_id(group) with ui.row().classes( - "w-full items-center gap-2 py-2 px-3 mb-1 rounded-md bg-[#505759] text-white" + "w-full items-center gap-2 py-2 px-3 mb-1 rounded-md bg-[#881c1c] text-white" ): + ui.icon("layers").classes("text-white") ui.label("Pipeline").classes("font-semibold") ui.link(root_id, f"/jobs/{root_id}").classes( - "text-white/90 hover:underline" + "text-white/90 hover:underline font-mono" ) group_wrap = ui.column().classes( - "w-full border-l-2 border-[#505759]/50 pl-3 mb-3" + "w-full border-l-2 border-[#881c1c]/50 pl-3 mb-3" ) else: group_wrap = self.jobs_container @@ -116,4 +118,14 @@ async def jobs_page_route(): return apply_saved_theme() create_navbar() - await JobsPage().render() + + page = JobsPage() + await page.render() + + # Auto-refresh if there are any running or pending jobs in the list + has_active_jobs = any( + str(job.get("status", "")).lower() in ("running", "pending") + for job in page.jobs + ) + if has_active_jobs: + ui.timer(3.0, lambda: ui.navigate.reload(), once=True) diff --git a/frontend/pages/logs.py b/frontend/pages/logs.py index 9c0240d6..9a8acbc9 100644 --- a/frontend/pages/logs.py +++ b/frontend/pages/logs.py @@ -34,12 +34,14 @@ async def render(self): logger.info("Rendering logs page") with ui.column().classes( - "w-full max-w-full min-w-0 p-4 gap-4 flex flex-col flex-1" + "container mx-auto px-4 sm:px-8 py-8 w-full max-w-6xl pb-16 gap-4 flex flex-col flex-1" ): # Page header - ui.label(UI_TITLES.get("logs", "Application Logs")).classes( - "text-2xl font-bold mb-4" - ) + with ui.row().classes("items-center gap-2 mb-4"): + ui.icon("terminal", size="lg").classes("text-[#881c1c]") + ui.label(UI_TITLES.get("logs", "Application Logs")).classes( + "text-4xl font-bold text-slate-800" + ) # Use extracted log viewer component (full width, fill available space) try: @@ -61,9 +63,19 @@ async def render(self): async def _load_logs(self): """Load and display log file contents. Reads the log file, limits to max_lines, and displays in the UI.""" self.log_content = read_log_file(LOG_FILE, self.max_lines) - formatted_content = format_log_content(self.log_content) - - self.log_display.content = formatted_content + + # Cache raw content in log_display and apply search filter if available + if hasattr(self, "log_display") and self.log_display is not None: + self.log_display.raw_content = self.log_content + if hasattr(self.log_display, "apply_filter"): + self.log_display.apply_filter() + else: + formatted_content = format_log_content(self.log_content) + self.log_display.content = formatted_content + else: + formatted_content = format_log_content(self.log_content) + if hasattr(self, "log_display") and self.log_display is not None: + self.log_display.content = formatted_content # Auto-scroll to bottom await ui.run_javascript( From 2a7582c9ab0d5b0faf59bac6064846fc5ee32060 Mon Sep 17 00:00:00 2001 From: Sahil Sharma Date: Fri, 5 Jun 2026 17:59:35 -0400 Subject: [PATCH 08/11] feat(ui): fix image summary side pane slide-out and add file download --- frontend/components/results.py | 38 +++++++++++++++++++++++++--------- frontend/utils/browser.py | 6 ++++-- 2 files changed, 32 insertions(+), 12 deletions(-) diff --git a/frontend/components/results.py b/frontend/components/results.py index acb4a741..5c540166 100644 --- a/frontend/components/results.py +++ b/frontend/components/results.py @@ -137,9 +137,10 @@ def open_file(path: str): with ui.row().classes("gap-2 mt-2"): ui.button( "Open folder", + color=None, on_click=lambda: open_folder(os.path.dirname(path)), - ).props("outline") - ui.button("Close", on_click=d.close).classes(Design.BTN_MEDIUM_GRAY) + ).classes(Design.BTN_SECONDARY_NEUTRAL) + ui.button("Close", color=None, on_click=d.close).classes(Design.BTN_MEDIUM_GRAY) d.open() else: ui.navigate.to(route) @@ -302,9 +303,10 @@ def open_image_bbox_preview_dialog( ui.button( "Open folder", icon="folder_open", + color=None, on_click=lambda: open_folder(os.path.dirname(abs_path)), - ).props("outline") - ui.button("Close", on_click=dialog.close).classes(Design.BTN_MEDIUM_GRAY) + ).classes(Design.BTN_SECONDARY_NEUTRAL) + ui.button("Close", color=None, on_click=dialog.close).classes(Design.BTN_MEDIUM_GRAY) dialog.open() @@ -570,6 +572,7 @@ def render_directory(container, response): ui.button( "Open Folder", icon="folder_open", + color=None, on_click=lambda: open_folder(path), ).classes(Design.BTN_PRIMARY_COMPACT) if path: @@ -636,11 +639,13 @@ def render_file(container, response): ui.button( "Open File", icon="visibility", + color=None, on_click=lambda: open_file(path), ).classes(Design.BTN_PRIMARY_COMPACT) ui.button( "Open Folder", icon="folder", + color=None, on_click=lambda: open_folder(os.path.dirname(path)), ).classes(Design.BTN_SECONDARY_NEUTRAL) if path: @@ -887,10 +892,9 @@ def _open_image_summary_markdown_modal(file_info: Dict[str, Any]) -> None: ) with ui.dialog() as dialog: dialog.props("position=right full-height").classes("image-summary-side-dialog") - dialog.style("width: min(520px, 48vw); max-width: 100vw;") with ui.card().classes( - "w-full h-full min-h-0 flex flex-col p-6 rounded-none shadow-2xl border-l border-zinc-200 bg-white" - ): + "h-full min-h-0 flex flex-col p-6 rounded-none shadow-2xl border-l border-zinc-200 bg-white" + ).style("width: min(520px, 48vw); max-width: 100vw;"): ui.label(name).classes("text-2xl font-semibold shrink-0 mb-4") with ui.column().classes( "overflow-y-auto flex-1 min-h-0 w-full image-summary-md-modal" @@ -899,9 +903,23 @@ def _open_image_summary_markdown_modal(file_info: Dict[str, Any]) -> None: with ui.row().classes("gap-2 mt-4 shrink-0 justify-end flex-wrap"): if path_full: ui.button( - "Open raw file", on_click=lambda: open_file(path_full) - ).props("flat outline") - ui.button("Close", on_click=dialog.close).classes( + "Open raw file", color=None, on_click=lambda: open_file(path_full) + ).classes(Design.BTN_SECONDARY_NEUTRAL) + + def _download_raw(): + try: + import os + with open(path_full, "rb") as f: + data = f.read() + ui.download(data, os.path.basename(path_full)) + except Exception as e: + ui.notify(f"Error downloading file: {e}", type="negative") + + ui.button( + "Download raw file", color=None, on_click=_download_raw + ).classes(Design.BTN_SECONDARY_NEUTRAL) + + ui.button("Close", color=None, on_click=dialog.close).classes( Design.BTN_MEDIUM_GRAY ) dialog.open() diff --git a/frontend/utils/browser.py b/frontend/utils/browser.py index 32047c04..1baf75e1 100644 --- a/frontend/utils/browser.py +++ b/frontend/utils/browser.py @@ -196,11 +196,12 @@ def show(self): # Footer with ui.row().classes(Design.PANEL_SHELL_FOOTER + " justify-end"): - ui.button("Cancel", on_click=self.dialog.close).classes( + ui.button("Cancel", color=None, on_click=self.dialog.close).classes( Design.BTN_MEDIUM_GRAY ).props("outline") ui.button( "Select This Folder", + color=None, on_click=lambda: ( self.on_select(self.state["current_path"]), self.dialog.close(), @@ -343,11 +344,12 @@ def show(self): "text-sm font-medium text-[#881c1c] truncate" ) - ui.button("Cancel", on_click=self.dialog.close).classes( + ui.button("Cancel", color=None, on_click=self.dialog.close).classes( Design.BTN_MEDIUM_GRAY ).props("outline") self.confirm_btn = ui.button( "Confirm Selection", + color=None, on_click=lambda: ( self.on_select(self.state["selected_file"]), self.dialog.close(), From 5cf06c9017a3e6a9479b9af843e8d122021bf3f8 Mon Sep 17 00:00:00 2001 From: Sahil Sharma Date: Fri, 5 Jun 2026 17:59:56 -0400 Subject: [PATCH 09/11] cleanup(ui): standardize models/about pages and remove broken demo links --- frontend/components/about.py | 188 ++++++++++-------- frontend/components/demo.py | 10 +- frontend/components/errors.py | 4 +- frontend/components/models.py | 35 ++-- frontend/pages/about.py | 118 ++++++++--- frontend/pages/demo.py | 21 +- .../pages/demo_image_summary_walkthrough.py | 13 +- frontend/pages/demo_other_walkthrough.py | 17 +- frontend/pages/demo_quick_start.py | 11 +- frontend/pages/demo_transcribe_walkthrough.py | 9 +- frontend/pages/models.py | 4 +- 11 files changed, 279 insertions(+), 151 deletions(-) diff --git a/frontend/components/about.py b/frontend/components/about.py index e885c012..49e6569b 100644 --- a/frontend/components/about.py +++ b/frontend/components/about.py @@ -16,7 +16,7 @@ logger.setLevel(logging.INFO) -_REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent +_REPO_ROOT = Path(__file__).resolve().parent.parent.parent LICENSE_ROOT = _REPO_ROOT / "License&Copyright" # Relative to LICENSE_ROOT — default document when ``?doc=`` is missing/invalid. DEFAULT_LICENSE_REL = "LICENSE" @@ -114,7 +114,7 @@ def render_one_file( ) ui.code(raw).classes( "w-full max-w-none text-sm whitespace-pre-wrap break-words " - "block p-4 bg-zinc-50 rounded-lg border border-zinc-300" + "block p-4 bg-slate-50 rounded-xl border border-slate-200 shadow-inner" ) @@ -124,92 +124,124 @@ def render_license_documents_section( static_url: str = "/license-copyright", page_path: str = "/about", ) -> None: - """License & Copyright picker and viewer; uses ``?doc=`` on ``page_path``.""" - doc = request.query_params.get("doc") + """License & Copyright picker and viewer; uses dynamic, inline, closable, and scrollable rendering.""" root = LICENSE_ROOT files = list_text_docs(root) ui.element("div").props('id="license-copyright"').classes("scroll-mt-24") with ui.card().classes( - "w-full max-w-3xl p-6 bg-white border border-zinc-300 rounded-xl shadow-sm" + "w-full max-w-3xl p-6 bg-white border border-slate-200 rounded-2xl shadow-md border-t-4 border-t-[#881c1c] flex flex-col gap-4" ): ui.label("License & Copyright").classes( - "text-xl font-semibold text-[#505759] mb-2" + "text-xl font-semibold text-slate-800" ) ui.label( - "RescueBox LICENSE, COPYRIGHT, and NOTICE, see bundled third-party notices when you choose Third party." - ).classes("text-sm text-zinc-600 mb-4") + "Select a document below to view RescueBox LICENSE, COPYRIGHT, NOTICE, or bundled third-party notices." + ).classes("text-sm text-zinc-600") - if not root.is_dir(): - ui.label(f"Folder not found: {root}").classes("text-red-600") - return - if not files: - ui.label("No license documents found in that folder.").classes("text-zinc-600") - return - - primary_entries, third_party_files = _primary_and_third_party_paths(files) - - rel = unquote(doc) if doc else "" - if rel not in files: - if DEFAULT_LICENSE_REL in files: - rel = DEFAULT_LICENSE_REL - elif primary_entries: - rel = primary_entries[0][1] - else: - rel = files[0] - - base = page_path.rstrip("/") or "/about" - - def _navigate_to_doc(new_rel: str) -> None: - if new_rel in files: - ui.navigate.to(f"{base}?doc={quote(new_rel, safe='')}") - - # Main picker: primary docs + optional "Third party". - # NiceGUI dict options are {value: label} (keys are selected values; values are shown in the UI). - main_options: dict[str, str] = {path: label for label, path in primary_entries} - if third_party_files: - main_options[_THIRD_PARTY_SENTINEL] = "Third party" - - if rel in third_party_files: - main_value = _THIRD_PARTY_SENTINEL - else: - main_value = next( - (path for label, path in primary_entries if path == rel), - primary_entries[0][1] if primary_entries else rel, - ) - - def _on_main_pick(e) -> None: - v = e.value - if not isinstance(v, str): + if not root.is_dir(): + ui.label(f"Folder not found: {root}").classes("text-red-600") return - if v == _THIRD_PARTY_SENTINEL and third_party_files: - target = rel if rel in third_party_files else third_party_files[0] - _navigate_to_doc(target) - elif v != _THIRD_PARTY_SENTINEL: - _navigate_to_doc(v) - - ui.select( - options=main_options, - value=main_value, - label="Document", - on_change=_on_main_pick, - ).classes("w-full max-w-2xl") - - if third_party_files: - with ui.column().classes("w-full max-w-2xl mt-2") as third_wrap: - third_wrap.visible = rel in third_party_files - - def _on_third_pick(e) -> None: - v = e.value - if isinstance(v, str) and v in third_party_files: - _navigate_to_doc(v) - - ui.select( + if not files: + ui.label("No license documents found in that folder.").classes("text-zinc-600") + return + + primary_entries, third_party_files = _primary_and_third_party_paths(files) + + # Main options for the select dropdown + main_options: dict[str, str] = {path: label for label, path in primary_entries} + if third_party_files: + main_options[_THIRD_PARTY_SENTINEL] = "Third party" + + # Dropdowns row + with ui.row().classes("w-full gap-4 items-center flex-wrap sm:flex-nowrap"): + # Primary document selector + main_select = ui.select( + options=main_options, + label="Document", + value=None, + on_change=lambda e: _on_main_change(e), + ).classes("flex-1 min-w-[200px]") + + # Third-party document selector (hidden by default) + third_select = ui.select( options=third_party_files, - value=rel if rel in third_party_files else third_party_files[0], label="Third-party document", - on_change=_on_third_pick, - ).classes("w-full") - - body = ui.column().classes("w-full min-w-0 mt-6") - render_one_file(body, root, rel, static_url=static_url) + value=None, + on_change=lambda e: _on_third_change(e), + ).classes("flex-1 min-w-[200px]") + third_select.visible = False + + # Closable & Scrollable Viewer Container (hidden by default) + with ui.card().classes("w-full p-4 bg-slate-50 border border-slate-200 rounded-xl shadow-sm flex flex-col gap-3") as viewer_card: + viewer_card.visible = False + + # Viewer Header + with ui.row().classes("w-full justify-between items-center border-b pb-2 border-slate-200"): + with ui.row().classes("items-center gap-2"): + ui.icon("article", size="sm").classes("text-[#881c1c]") + viewer_title = ui.label("").classes("text-sm font-bold text-slate-700 font-mono") + + # Close button + ui.button( + "Close", + icon="close", + color=None, + on_click=lambda: _close_viewer(), + ).props("flat dense no-caps").classes( + "text-slate-600 hover:text-slate-800 hover:bg-slate-100 px-3 py-1 " + "rounded-lg border border-slate-200 transition-colors text-sm font-medium" + ) + + # Scrollable body + viewer_body = ui.column().classes("w-full max-h-[350px] overflow-y-auto pr-2") + + # Helper to render document content + def _show_document(rel_path: str): + viewer_body.clear() + viewer_title.text = rel_path + render_one_file(viewer_body, root, rel_path, static_url=static_url) + viewer_card.visible = True + + # Close viewer action + def _close_viewer(): + viewer_card.visible = False + main_select.value = None + third_select.value = None + third_select.visible = False + + # On primary selection change + def _on_main_change(e): + val = e.value + if not val: + return + if val == _THIRD_PARTY_SENTINEL: + third_select.visible = True + # Automatically select and show the first third party file + first_third = third_party_files[0] if third_party_files else None + if first_third: + third_select.value = first_third + _show_document(first_third) + else: + third_select.visible = False + third_select.value = None + _show_document(val) + + # On third-party selection change + def _on_third_change(e): + val = e.value + if val and val in third_party_files: + _show_document(val) + + # Initial load from query param if present + doc = request.query_params.get("doc") + if doc: + rel = unquote(doc) + if rel in files: + if rel in third_party_files: + main_select.value = _THIRD_PARTY_SENTINEL + third_select.value = rel + third_select.visible = True + else: + main_select.value = rel + _show_document(rel) diff --git a/frontend/components/demo.py b/frontend/components/demo.py index 03a68f17..fb25edcd 100644 --- a/frontend/components/demo.py +++ b/frontend/components/demo.py @@ -190,17 +190,21 @@ def refresh() -> None: with nav: ui.button( "Demo root", + color=None, on_click=lambda: go_to(str(root)), ).classes( - "text-xs" - ).props("dense outline") + "text-xs bg-slate-100 hover:bg-slate-200 text-slate-800 px-2 py-1 rounded border border-slate-200 transition-colors" + ).props("dense") if cur != root: parent = cur.parent if parent == root or _is_under_root(parent, root): ui.button( "Up one level", + color=None, on_click=lambda: go_to(str(parent)), - ).classes("text-xs").props("dense outline") + ).classes( + "text-xs bg-slate-100 hover:bg-slate-200 text-slate-800 px-2 py-1 rounded border border-slate-200 transition-colors" + ).props("dense") for path, is_dir in _list_entries( cur, diff --git a/frontend/components/errors.py b/frontend/components/errors.py index 4aebce6a..adfd91ec 100644 --- a/frontend/components/errors.py +++ b/frontend/components/errors.py @@ -41,7 +41,7 @@ def render_error_boundary( try: # action should be a tuple (label, on_click_callable, classes) label, callback, classes = action - ui.button(label, on_click=callback).classes(classes) + ui.button(label, color=None, on_click=callback).classes(classes) except Exception as e: logger.exception( "Error rendering error message component: %s", e @@ -97,7 +97,7 @@ def show_validation_dialog( ui.label("Additional errors:").classes("font-semibold mb-2") for additional_error in additional_errors: ui.label(f"• {additional_error}").classes("mb-1") - ui.button("OK", on_click=error_dialog.close).classes( + ui.button("OK", color=None, on_click=error_dialog.close).classes( f"mt-4 {Design.BTN_MEDIUM_GRAY}" ) error_dialog.open() diff --git a/frontend/components/models.py b/frontend/components/models.py index ae7561da..2dfd937d 100644 --- a/frontend/components/models.py +++ b/frontend/components/models.py @@ -3,6 +3,7 @@ from datetime import datetime from nicegui import ui from frontend.constants import UI_BUTTONS +from frontend.design_tokens import Design # Configure logging for this module logger = logging.getLogger(__name__) @@ -95,7 +96,7 @@ def render_model_card( with container: logger.debug("Creating model card container") with ui.card().classes( - "rb-models-plugin-card w-full p-6 hover:shadow-lg transition-shadow" + "rb-models-plugin-card w-full p-6 hover:shadow-md transition-all border-l-4 border-l-[#881c1c] border-y border-r border-slate-200 rounded-xl bg-white" ): with ui.row().classes("items-center justify-between w-full"): # Left section - Model info @@ -103,7 +104,7 @@ def render_model_card( # Icon and name row with ui.row().classes("items-center gap-3"): # Model icon based on category (you can enhance this) - icon = ( + icon_name = ( "image" if "image" in model.get("name", "").lower() else ( @@ -112,28 +113,32 @@ def render_model_card( else ( "description" if "text" in model.get("name", "").lower() - else "category" + else "extension" ) ) ) - logger.debug("Selected icon: %s for model category", icon) - # ui.icon(icon, size='lg').classes('text-indigo-600') - ui.label(model["name"]).classes("text-2xl font-bold") + logger.debug("Selected icon: %s for model category", icon_name) + ui.icon(icon_name, size='sm').classes('text-[#881c1c]') + ui.label(model["name"]).classes("text-2xl font-bold text-slate-800") logger.debug("Model name label added: %s", model["name"]) # Version, author, GPU info with ui.row().classes( - "gap-4 mt-2 text-sm text-zinc-600 items-center" + "gap-4 mt-2 text-sm text-slate-500 items-center" ): ui.label(f"v{model['version']}") ui.label("•") ui.label(model.get("author", "Unknown")) if model.get("gpu"): - ui.badge("GPU Required", color="black").classes("text-xs") + ui.badge("GPU Required", color="orange").classes("text-xs font-semibold px-2 py-0.5 rounded") # Right section - Status and actions - with ui.column().classes("items-end gap-2"): - # Status badge + with ui.column().classes("items-end gap-3"): + # Status Badge + status_pill_cls = "bg-emerald-50 text-emerald-700 border border-emerald-200" if is_online else "bg-rose-50 text-rose-700 border border-rose-200" + with ui.row().classes(f"items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold {status_pill_cls}"): + ui.icon("check_circle" if is_online else "error", size="14px") + ui.label(status_text) # Action buttons with ui.row().classes("gap-2"): @@ -141,19 +146,23 @@ def render_model_card( if on_inspect: ui.button( UI_BUTTONS["plugin_readme"], + icon="menu_book", + color=None, on_click=lambda: ( on_inspect(model["uid"]) if on_inspect else None ), - ).classes("rb-brand-primary text-white") + ).classes(Design.BTN_PRIMARY_COMPACT) logger.debug("README button added") if not is_online and on_connect: ui.button( - "🔌 Connect", + "Connect", + icon="power", + color=None, on_click=lambda: ( on_connect(model["uid"]) if on_connect else None ), - ).classes("bg-zinc-600 text-white") + ).classes("bg-slate-100 hover:bg-slate-200 text-slate-800 px-4 py-2 rounded-lg font-medium transition-colors border border-slate-200") logger.debug("Connect button added (model is offline)") logger.debug("Model card rendered successfully") diff --git a/frontend/pages/about.py b/frontend/pages/about.py index ad3edcf6..657decc2 100644 --- a/frontend/pages/about.py +++ b/frontend/pages/about.py @@ -28,37 +28,103 @@ async def about_page(request: Request): apply_saved_theme() create_navbar() - with ui.column().classes("w-full max-w-full min-w-0 container mx-auto p-4 pb-16"): - ui.label("About").classes("text-3xl font-bold text-zinc-900 mb-6") - + with ui.column().classes("container mx-auto px-4 sm:px-8 py-8 w-full max-w-6xl pb-16 gap-6"): + # Hero Header Card with ui.card().classes( - "w-full max-w-3xl mb-10 p-6 bg-white border border-zinc-300 rounded-xl shadow-sm" + "w-full p-6 sm:p-8 bg-gradient-to-br from-slate-900 via-[#1c1c1c] to-slate-900 " + "text-white rounded-2xl shadow-lg border border-slate-800 relative overflow-hidden" ): - ui.label("Application").classes("text-lg font-semibold text-[#505759] mb-4") - _rows = ( - ("name", APP_TITLE, False), - ("version", APP_VERSION, False), - ("authors", ABOUT_AUTHORS, False), - ("rescue lab website", RESCUE_LAB_URL, True), - ("repository", ABOUT_REPO_URL, True), + # Decorative background pattern/overlay + ui.element("div").classes( + "absolute -right-10 -bottom-10 w-40 h-40 bg-[#881c1c]/20 rounded-full blur-3xl" ) - for key, val, is_url in _rows: - with ui.row().classes( - "w-full gap-4 py-2 border-b border-zinc-200 last:border-0 items-start" + with ui.row().classes("items-center gap-4 w-full relative z-10"): + ui.icon("info", size="2.5rem").classes("text-[#881c1c]") + with ui.column().classes("gap-1 flex-1"): + ui.label("About RescueBox").classes("text-2xl sm:text-3xl font-extrabold tracking-tight") + ui.label( + "An advanced, AI-powered forensic and investigative platform designed for deep data analysis, " + "media processing, and intelligence gathering." + ).classes("text-slate-300 text-sm sm:text-base max-w-3xl leading-relaxed") + + # Two-Column Layout + with ui.grid().classes("w-full grid-cols-1 lg:grid-cols-3 gap-6 items-start"): + # Left Column (System Info & Licenses) - Takes 2 cols on large screens + with ui.column().classes("lg:col-span-2 w-full gap-6"): + # System Info Card + with ui.card().classes( + "w-full p-6 bg-white border border-slate-200 rounded-2xl shadow-sm border-t-4 border-t-[#881c1c]" + ): + ui.label("System Information").classes("text-xl font-bold text-slate-800 mb-4") + + _system_rows = ( + ("Application Name", APP_TITLE, "label", False), + ("Software Version", f"v{APP_VERSION}", "tag", False), + ("Core Developers", ABOUT_AUTHORS, "people", False), + ("Official Repository", ABOUT_REPO_URL, "code", True), + ) + + with ui.column().classes("w-full gap-3"): + for label_text, val, icon_name, is_url in _system_rows: + with ui.row().classes( + "w-full gap-4 py-3 border-b border-slate-100 last:border-0 items-center hover:bg-slate-50/50 px-2 rounded-lg transition-colors" + ): + ui.icon(icon_name, size="sm").classes("text-[#881c1c] shrink-0") + with ui.column().classes("gap-0.5 flex-1 min-w-0"): + ui.label(label_text).classes("text-xs font-semibold text-slate-400 uppercase tracking-wider") + if is_url and val.startswith("http"): + ui.link(val, val, new_tab=True).classes( + "text-sm font-medium text-[#881c1c] hover:underline break-all min-w-0" + ) + else: + ui.label(val).classes("text-sm font-semibold text-slate-800 break-words") + + # Licenses Section + with ui.column().classes("w-full"): + render_license_documents_section(request, page_path="/about") + + # Right Column (Sponsor & Quick Actions) - Takes 1 col + with ui.column().classes("w-full gap-6"): + # RescueLab Sponsor Card + with ui.card().classes( + "w-full p-6 bg-white border border-slate-200 rounded-2xl shadow-sm " + "flex flex-col items-center text-center gap-4 border-t-4 border-t-[#881c1c]" ): - ui.label(f"{key}").classes( - "text-sm font-mono text-zinc-600 shrink-0 w-44 sm:w-52" + ui.label("Research & Sponsorship").classes("text-sm font-bold text-slate-400 uppercase tracking-wider self-start") + ui.element("img").props("src=/icons/rb.webp alt=\"RescueLab Logo\"").classes("h-16 object-contain my-2") + with ui.column().classes("gap-1 items-center"): + ui.label("RescueLab").classes("text-lg font-bold text-slate-800") + ui.label("University of Massachusetts Amherst").classes("text-xs font-semibold text-slate-500") + ui.label( + "RescueLab conducts cutting-edge research in systems, security, and digital forensics. " + "RescueBox is developed and maintained as part of our commitment to open-source investigative tools." + ).classes("text-slate-600 text-xs leading-relaxed") + ui.separator().classes("w-full my-1") + ui.link("Visit RescueLab Website", RESCUE_LAB_URL, new_tab=True).classes( + "text-sm font-bold text-[#881c1c] hover:underline flex items-center gap-1" ) - if is_url and val.startswith("http"): - ui.link(val, val, new_tab=True).classes( - "text-sm text-[#505759] hover:text-[#3d4442] hover:underline " - "break-all min-w-0 flex-1" - ) - else: - ui.label(val).classes( - "text-sm text-zinc-900 break-words flex-1 min-w-0" - ) - render_license_documents_section(request, page_path="/about") + # Quick Actions / Resources Card + with ui.card().classes( + "w-full p-6 bg-white border border-slate-200 rounded-2xl shadow-sm border-t-4 border-t-[#881c1c]" + ): + ui.label("Quick Resources").classes("text-sm font-bold text-slate-400 uppercase tracking-wider mb-2") + + _resources = ( + ("Case Dashboard", "folder_shared", "/", "Manage active cases and evidence"), + ("AI Assistant", "forum", "/chatbot", "Interact with Granite AI models"), + ("Jobs & Pipelines", "view_list", "/jobs", "Monitor background tasks"), + ("System Logs", "terminal", "/logs", "View real-time application logs"), + ) + + with ui.column().classes("w-full gap-2"): + for name, icon_name, path, desc in _resources: + with ui.row().classes( + "w-full p-2.5 rounded-xl border border-slate-100 hover:border-slate-200 hover:bg-slate-50 cursor-pointer items-center gap-3 transition-all" + ).on("click", lambda _, p=path: ui.navigate.to(p)): + ui.icon(icon_name, size="sm").classes("text-[#881c1c] shrink-0") + with ui.column().classes("gap-0.5 flex-1 min-w-0"): + ui.label(name).classes("text-sm font-bold text-slate-800") + ui.label(desc).classes("text-[11px] text-slate-500 truncate") logger.debug("About page rendered") diff --git a/frontend/pages/demo.py b/frontend/pages/demo.py index c431bf61..dff30cd8 100644 --- a/frontend/pages/demo.py +++ b/frontend/pages/demo.py @@ -52,10 +52,12 @@ async def demo_page(walkthrough: Optional[str] = None): preset = normalize_demo_walkthrough_query(walkthrough) samples_only = preset != "all" - with ui.column().classes("container mx-auto p-8 max-w-5xl w-full min-w-0"): + with ui.column().classes("container mx-auto px-4 sm:px-8 py-8 w-full max-w-6xl pb-16"): if samples_only: with ui.column().props("id=sample-inputs").classes("scroll-mt-24 w-full"): - ui.label("Sample inputs & outputs").classes("text-2xl font-bold mb-1") + with ui.row().classes("items-center gap-2 mb-1"): + ui.icon("folder_zip", size="sm").classes("text-[#881c1c]") + ui.label("Sample inputs & outputs").classes("text-2xl font-bold text-slate-800") if preset in _SAMPLE_FILTER_BLURB: ui.label(_SAMPLE_FILTER_BLURB[preset]).classes( "text-zinc-600 text-sm mb-3" @@ -67,19 +69,22 @@ async def demo_page(walkthrough: Optional[str] = None): ) if guide and label: ui.link(label, guide).classes( - "text-[#a2aaad] hover:text-[#8a9194] hover:underline text-sm mb-4 inline-block" + "text-[#881c1c] hover:underline text-sm mb-4 inline-block" ) else: - ui.label("RescueBox Demo").classes("text-3xl font-bold mb-4") + with ui.row().classes("items-center gap-2 mb-2"): + ui.icon("school", size="lg").classes("text-[#881c1c]") + ui.label("RescueBox Demo").classes("text-4xl font-bold text-slate-800") ui.label("Follow the step-by-step guide to learn RescueBox.").classes( - "text-black-600 mb-6" + "text-slate-500 mb-6 pl-1 text-lg" ) - with ui.column().classes("gap-3 items-start"): + with ui.column().classes("gap-3 items-stretch w-full max-w-2xl"): # Neutral outline: no Quasar primary / no brand fill (color=None + flat outline). _demo_btn = ( - "text-zinc-800 px-6 py-3 rounded-xl font-semibold " - "bg-white border border-zinc-300 hover:bg-zinc-50 transition-colors" + "text-slate-800 px-6 py-3 rounded-xl font-semibold " + "bg-white border border-slate-200 hover:bg-slate-50 hover:shadow-md transition-all " + "w-full text-left flex items-center gap-3 border-l-4 border-l-[#881c1c]" ) _demo_btn_props = "flat unelevated no-caps" ui.button( diff --git a/frontend/pages/demo_image_summary_walkthrough.py b/frontend/pages/demo_image_summary_walkthrough.py index e8b7eb29..ede6493d 100644 --- a/frontend/pages/demo_image_summary_walkthrough.py +++ b/frontend/pages/demo_image_summary_walkthrough.py @@ -42,10 +42,12 @@ async def demo_image_search_walkthrough_page(): text = load_markdown_file(_MD_FILE, _fallback_markdown) - with ui.column().classes("container mx-auto p-8 max-w-4xl w-full min-w-0 pb-16"): - ui.label("Search Image — Assistant prompt walkthrough").classes( - "text-3xl font-bold mb-2" - ) + with ui.column().classes("container mx-auto px-4 sm:px-8 py-8 w-full max-w-4xl pb-16"): + with ui.row().classes("items-center gap-2 mb-2"): + ui.icon("image", size="lg").classes("text-[#881c1c]") + ui.label("Search Image — Assistant prompt walkthrough").classes( + "text-4xl font-bold text-slate-800" + ) render_guided_markdown_body(ui.column().classes("w-full min-w-0"), text) @@ -54,8 +56,9 @@ async def demo_image_search_walkthrough_page(): with ui.row().classes("gap-4 flex-wrap items-center mt-8"): ui.button( "Back to Demo", + icon="arrow_back", on_click=lambda: ui.navigate.to(NAV_LINKS["demo"]), - ).classes("rb-brand-primary text-white") + ).classes("bg-slate-100 hover:bg-slate-200 text-slate-800 px-4 py-2 rounded-lg font-medium transition-colors border border-slate-200") # ui.link("Open Assistant", NAV_LINKS["chatbot"]).classes("text-[#881c1c] hover:underline") # ui.link(UI_TITLES["jobs"], NAV_LINKS["jobs"]).classes("text-[#881c1c] hover:underline") diff --git a/frontend/pages/demo_other_walkthrough.py b/frontend/pages/demo_other_walkthrough.py index 3642f34c..2ec9d355 100644 --- a/frontend/pages/demo_other_walkthrough.py +++ b/frontend/pages/demo_other_walkthrough.py @@ -42,10 +42,12 @@ async def demo_other_walkthrough_page(): text = load_markdown_file(_MD_FILE, _fallback_markdown) - with ui.column().classes("container mx-auto p-8 max-w-4xl w-full min-w-0 pb-16"): - ui.label("Interesting plugins & pipeline walkthrough").classes( - "text-3xl font-bold mb-2" - ) + with ui.column().classes("container mx-auto px-4 sm:px-8 py-8 w-full max-w-4xl pb-16"): + with ui.row().classes("items-center gap-2 mb-2"): + ui.icon("extension", size="lg").classes("text-[#881c1c]") + ui.label("Interesting plugins & pipeline walkthrough").classes( + "text-4xl font-bold text-slate-800" + ) render_guided_markdown_body(ui.column().classes("w-full min-w-0"), text) @@ -54,14 +56,15 @@ async def demo_other_walkthrough_page(): with ui.row().classes("gap-4 flex-wrap items-center mt-8"): ui.button( "Back to Demo", + icon="arrow_back", on_click=lambda: ui.navigate.to(NAV_LINKS["demo"]), - ).classes("rb-brand-primary text-white") + ).classes("bg-slate-100 hover:bg-slate-200 text-slate-800 px-4 py-2 rounded-lg font-medium transition-colors border border-slate-200") ui.link("Open Assistant", NAV_LINKS["chatbot"]).classes( - "text-[#881c1c] hover:underline" + "text-[#881c1c] hover:underline font-medium" ) ui.link(UI_TITLES["jobs"], NAV_LINKS["jobs"]).classes( - "text-[#881c1c] hover:underline" + "text-[#881c1c] hover:underline font-medium" ) schedule_hash_fragment_scroll() diff --git a/frontend/pages/demo_quick_start.py b/frontend/pages/demo_quick_start.py index 48d81301..94888015 100644 --- a/frontend/pages/demo_quick_start.py +++ b/frontend/pages/demo_quick_start.py @@ -43,8 +43,10 @@ async def demo_quick_start_page(): text = load_markdown_file(_QUICK_START_MD, _fallback_markdown) - with ui.column().classes("container mx-auto p-8 max-w-4xl w-full min-w-0 pb-16"): - ui.label("RescueBox quick start").classes("text-3xl font-bold mb-2") + with ui.column().classes("container mx-auto px-4 sm:px-8 py-8 w-full max-w-4xl pb-16"): + with ui.row().classes("items-center gap-2 mb-2"): + ui.icon("rocket_launch", size="lg").classes("text-[#881c1c]") + ui.label("RescueBox quick start").classes("text-4xl font-bold text-slate-800") render_guided_markdown_body(ui.column().classes("w-full min-w-0"), text) @@ -53,11 +55,12 @@ async def demo_quick_start_page(): with ui.row().classes("gap-4 flex-wrap items-center mt-8"): ui.button( "Back to Demo", + icon="arrow_back", on_click=lambda: ui.navigate.to(NAV_LINKS["demo"]), - ).classes("rb-brand-primary text-white") + ).classes("bg-slate-100 hover:bg-slate-200 text-slate-800 px-4 py-2 rounded-lg font-medium transition-colors border border-slate-200") ui.link("Demo samples", demo_samples_url("quick_start")).classes( - "text-[#881c1c] hover:underline text-sm" + "text-[#881c1c] hover:underline text-sm font-medium" ) schedule_hash_fragment_scroll() diff --git a/frontend/pages/demo_transcribe_walkthrough.py b/frontend/pages/demo_transcribe_walkthrough.py index beed7fb7..e5645c82 100644 --- a/frontend/pages/demo_transcribe_walkthrough.py +++ b/frontend/pages/demo_transcribe_walkthrough.py @@ -42,8 +42,10 @@ async def demo_transcribe_walkthrough_page(): text = load_markdown_file(_MD_FILE, _fallback_markdown) - with ui.column().classes("container mx-auto p-8 max-w-4xl w-full min-w-0 pb-16"): - ui.label("Transcribe — menu walkthrough").classes("text-3xl font-bold mb-2") + with ui.column().classes("container mx-auto px-4 sm:px-8 py-8 w-full max-w-4xl pb-16"): + with ui.row().classes("items-center gap-2 mb-2"): + ui.icon("audiotrack", size="lg").classes("text-[#881c1c]") + ui.label("Transcribe — menu walkthrough").classes("text-4xl font-bold text-slate-800") render_guided_markdown_body(ui.column().classes("w-full min-w-0"), text) @@ -52,8 +54,9 @@ async def demo_transcribe_walkthrough_page(): with ui.row().classes("gap-4 flex-wrap items-center mt-8"): ui.button( "Back to Demo", + icon="arrow_back", on_click=lambda: ui.navigate.to(NAV_LINKS["demo"]), - ).classes("rb-brand-primary text-white") + ).classes("bg-slate-100 hover:bg-slate-200 text-slate-800 px-4 py-2 rounded-lg font-medium transition-colors border border-slate-200") schedule_hash_fragment_scroll() logger.debug("Transcribe walkthrough page rendered") diff --git a/frontend/pages/models.py b/frontend/pages/models.py index 06b92874..7fc63d40 100644 --- a/frontend/pages/models.py +++ b/frontend/pages/models.py @@ -56,7 +56,7 @@ async def render(self): """Render the models page UI. Creates the page layout with header, refresh button, loading indicator,""" logger.debug("Rendering models page") try: - with ui.column().classes("container mx-auto p-8"): + with ui.column().classes("container mx-auto px-4 sm:px-8 py-8 w-full max-w-6xl pb-16"): # Header logger.debug("Creating page header") try: @@ -309,7 +309,7 @@ async def model_details_page(model_uid: str): ) return - with ui.column().classes("container mx-auto p-8"): + with ui.column().classes("container mx-auto px-4 sm:px-8 py-8 w-full max-w-6xl pb-16"): # Two-column layout with ui.row().classes("gap-6 w-full"): # Left column - Documentation From d748b15a5caaa8440ee55d2dd5d823b4f25e410c Mon Sep 17 00:00:00 2001 From: jaik950 Date: Sat, 6 Jun 2026 09:43:02 -0400 Subject: [PATCH 10/11] fix format issues ruff check and pytest failure --- frontend/chatbot/tool_config.py | 2 + frontend/components/about.py | 30 +- frontend/components/chat/dialogs.py | 12 +- frontend/components/chat/rendering.py | 26 +- frontend/components/demo.py | 8 +- frontend/components/errors.py | 4 +- frontend/components/forms/dialogs.py | 6 +- frontend/components/forms/field_builders.py | 10 +- frontend/components/forms/form_generator.py | 6 +- frontend/components/jobs.py | 30 +- frontend/components/logs.py | 22 +- frontend/components/models.py | 24 +- frontend/components/results.py | 15 +- frontend/components/shared.py | 63 ++- frontend/database/case_db.py | 8 +- frontend/database/job_db.py | 1 + frontend/design_tokens.py | 12 +- frontend/main.py | 367 ++++++++++++------ frontend/pages/about.py | 100 +++-- frontend/pages/chatbot/coordinator.py | 9 +- frontend/pages/chatbot/handlers.py | 89 +++-- frontend/pages/chatbot/ui.py | 129 ++++-- frontend/pages/demo.py | 8 +- .../pages/demo_image_summary_walkthrough.py | 8 +- frontend/pages/demo_other_walkthrough.py | 8 +- frontend/pages/demo_quick_start.py | 12 +- frontend/pages/demo_transcribe_walkthrough.py | 12 +- frontend/pages/jobs/components.py | 17 +- frontend/pages/jobs/details.py | 6 +- frontend/pages/jobs/list.py | 8 +- frontend/pages/logs.py | 2 +- frontend/pages/models.py | 8 +- .../unit/test_job_background_submission.py | 8 +- frontend/utils/storage.py | 2 + frontend/utils/ui.py | 4 +- 35 files changed, 766 insertions(+), 310 deletions(-) diff --git a/frontend/chatbot/tool_config.py b/frontend/chatbot/tool_config.py index 02d05c4f..a36a8967 100644 --- a/frontend/chatbot/tool_config.py +++ b/frontend/chatbot/tool_config.py @@ -303,10 +303,12 @@ def create_advanced_granite_prompt(user_query: str) -> list[dict[str, str]]: if pipeline_job_id: try: from frontend.database import get_job_db + job = get_job_db().get_job_by_uid_sync(pipeline_job_id) if job and job.response: from frontend.chatbot.multi_tool_handler import extract_output_path from rb.api.models import ResponseBody + response_body = job.response if not isinstance(response_body, ResponseBody): response_body = ResponseBody(**response_body) diff --git a/frontend/components/about.py b/frontend/components/about.py index 49e6569b..6a03d28c 100644 --- a/frontend/components/about.py +++ b/frontend/components/about.py @@ -2,7 +2,7 @@ import logging from pathlib import Path from typing import List, Optional, Tuple -from urllib.parse import quote, unquote +from urllib.parse import unquote from nicegui import ui from starlette.requests import Request @@ -132,9 +132,7 @@ def render_license_documents_section( with ui.card().classes( "w-full max-w-3xl p-6 bg-white border border-slate-200 rounded-2xl shadow-md border-t-4 border-t-[#881c1c] flex flex-col gap-4" ): - ui.label("License & Copyright").classes( - "text-xl font-semibold text-slate-800" - ) + ui.label("License & Copyright").classes("text-xl font-semibold text-slate-800") ui.label( "Select a document below to view RescueBox LICENSE, COPYRIGHT, NOTICE, or bundled third-party notices." ).classes("text-sm text-zinc-600") @@ -143,7 +141,9 @@ def render_license_documents_section( ui.label(f"Folder not found: {root}").classes("text-red-600") return if not files: - ui.label("No license documents found in that folder.").classes("text-zinc-600") + ui.label("No license documents found in that folder.").classes( + "text-zinc-600" + ) return primary_entries, third_party_files = _primary_and_third_party_paths(files) @@ -173,15 +173,21 @@ def render_license_documents_section( third_select.visible = False # Closable & Scrollable Viewer Container (hidden by default) - with ui.card().classes("w-full p-4 bg-slate-50 border border-slate-200 rounded-xl shadow-sm flex flex-col gap-3") as viewer_card: + with ui.card().classes( + "w-full p-4 bg-slate-50 border border-slate-200 rounded-xl shadow-sm flex flex-col gap-3" + ) as viewer_card: viewer_card.visible = False - + # Viewer Header - with ui.row().classes("w-full justify-between items-center border-b pb-2 border-slate-200"): + with ui.row().classes( + "w-full justify-between items-center border-b pb-2 border-slate-200" + ): with ui.row().classes("items-center gap-2"): ui.icon("article", size="sm").classes("text-[#881c1c]") - viewer_title = ui.label("").classes("text-sm font-bold text-slate-700 font-mono") - + viewer_title = ui.label("").classes( + "text-sm font-bold text-slate-700 font-mono" + ) + # Close button ui.button( "Close", @@ -194,7 +200,9 @@ def render_license_documents_section( ) # Scrollable body - viewer_body = ui.column().classes("w-full max-h-[350px] overflow-y-auto pr-2") + viewer_body = ui.column().classes( + "w-full max-h-[350px] overflow-y-auto pr-2" + ) # Helper to render document content def _show_document(rel_path: str): diff --git a/frontend/components/chat/dialogs.py b/frontend/components/chat/dialogs.py index d71b82ad..118faf35 100644 --- a/frontend/components/chat/dialogs.py +++ b/frontend/components/chat/dialogs.py @@ -8,7 +8,9 @@ def show_help_dialog(help_text: str, title: Optional[str] = "RescueBox Help") -> with ui.dialog() as dialog, ui.card().classes(Design.PANEL_SHELL_CARD_WIDE): with ui.row().classes(Design.PANEL_SHELL_HEADER): ui.label(title or "Help").classes(Design.PANEL_SHELL_HEADER_TITLE) - ui.button(icon="close", color=None, on_click=dialog.close).props("flat round dense") + ui.button(icon="close", color=None, on_click=dialog.close).props( + "flat round dense" + ) with ui.column().classes("w-full flex-1 overflow-y-auto p-6"): ui.markdown(help_text or "No help available.") dialog.open() @@ -26,7 +28,9 @@ async def show_history_dialog( with ui.dialog() as dialog, ui.card().classes(Design.PANEL_SHELL_CARD_WIDE): with ui.row().classes(Design.PANEL_SHELL_HEADER): ui.label("Chat History").classes(Design.PANEL_SHELL_HEADER_TITLE) - ui.button(icon="close", color=None, on_click=dialog.close).props("flat round dense") + ui.button(icon="close", color=None, on_click=dialog.close).props( + "flat round dense" + ) with ui.column().classes( f"{Design.PANEL_SHELL_BODY} gap-3 overflow-y-auto max-h-[60vh] w-full" @@ -99,5 +103,7 @@ def _render_message_card(msg: Any) -> None: ): for msg in messages: _render_message_card(msg) - ui.button("Close", color=None, on_click=dialog.close).classes(Design.BTN_MEDIUM_GRAY) + ui.button("Close", color=None, on_click=dialog.close).classes( + Design.BTN_MEDIUM_GRAY + ) dialog.open() diff --git a/frontend/components/chat/rendering.py b/frontend/components/chat/rendering.py index 0927cf79..5605bac1 100644 --- a/frontend/components/chat/rendering.py +++ b/frontend/components/chat/rendering.py @@ -71,16 +71,28 @@ def render_conversation_card( container: ui.column, conversation: Any, view_callback, load_callback ) -> None: with container: - with card().classes("p-4 cursor-pointer hover:bg-slate-50 border border-slate-200 rounded-xl shadow-sm transition-all"): + with card().classes( + "p-4 cursor-pointer hover:bg-slate-50 border border-slate-200 rounded-xl shadow-sm transition-all" + ): with row().classes("items-center justify-between mb-2"): label(conversation.title).classes("font-semibold flex-1 text-slate-800") with row().classes("gap-2"): button( - "View", icon="visibility", color=None, on_click=lambda: view_callback(conversation.conversation_id) - ).classes("text-sm bg-slate-100 hover:bg-slate-200 text-slate-800 px-3 py-1 rounded transition-colors") + "View", + icon="visibility", + color=None, + on_click=lambda: view_callback(conversation.conversation_id), + ).classes( + "text-sm bg-slate-100 hover:bg-slate-200 text-slate-800 px-3 py-1 rounded transition-colors" + ) button( - "Load", icon="login", color=None, on_click=lambda: load_callback(conversation.conversation_id) - ).classes("text-sm rb-brand-primary text-white px-3 py-1 rounded transition-colors") + "Load", + icon="login", + color=None, + on_click=lambda: load_callback(conversation.conversation_id), + ).classes( + "text-sm rb-brand-primary text-white px-3 py-1 rounded transition-colors" + ) def render_message_in_dialog(message: Any) -> None: @@ -88,5 +100,7 @@ def render_message_in_dialog(message: Any) -> None: role = getattr(message, "role", "assistant") content = getattr(message, "content", "") with column().classes("w-full border-b border-slate-100 pb-2 mb-2"): - label(role.upper()).classes("text-xs font-bold text-slate-400 uppercase tracking-wider") + label(role.upper()).classes( + "text-xs font-bold text-slate-400 uppercase tracking-wider" + ) label(content).classes("text-sm text-slate-800 whitespace-pre-wrap") diff --git a/frontend/components/demo.py b/frontend/components/demo.py index fb25edcd..f0e226e6 100644 --- a/frontend/components/demo.py +++ b/frontend/components/demo.py @@ -194,7 +194,9 @@ def refresh() -> None: on_click=lambda: go_to(str(root)), ).classes( "text-xs bg-slate-100 hover:bg-slate-200 text-slate-800 px-2 py-1 rounded border border-slate-200 transition-colors" - ).props("dense") + ).props( + "dense" + ) if cur != root: parent = cur.parent if parent == root or _is_under_root(parent, root): @@ -204,7 +206,9 @@ def refresh() -> None: on_click=lambda: go_to(str(parent)), ).classes( "text-xs bg-slate-100 hover:bg-slate-200 text-slate-800 px-2 py-1 rounded border border-slate-200 transition-colors" - ).props("dense") + ).props( + "dense" + ) for path, is_dir in _list_entries( cur, diff --git a/frontend/components/errors.py b/frontend/components/errors.py index adfd91ec..caea0226 100644 --- a/frontend/components/errors.py +++ b/frontend/components/errors.py @@ -41,7 +41,9 @@ def render_error_boundary( try: # action should be a tuple (label, on_click_callable, classes) label, callback, classes = action - ui.button(label, color=None, on_click=callback).classes(classes) + ui.button(label, color=None, on_click=callback).classes( + classes + ) except Exception as e: logger.exception( "Error rendering error message component: %s", e diff --git a/frontend/components/forms/dialogs.py b/frontend/components/forms/dialogs.py index 39f81150..620f0957 100644 --- a/frontend/components/forms/dialogs.py +++ b/frontend/components/forms/dialogs.py @@ -21,9 +21,9 @@ async def show_case_notes_dialog() -> Optional[str]: ).classes(f"w-full min-h-24 {Design.INPUT_OUTLINED}") with ui.row().classes(f"{Design.PANEL_SHELL_FOOTER} justify-end flex-wrap"): - ui.button("Cancel", color=None, on_click=lambda: dialog.submit(None)).classes( - Design.BTN_MEDIUM_GRAY - ) + ui.button( + "Cancel", color=None, on_click=lambda: dialog.submit(None) + ).classes(Design.BTN_MEDIUM_GRAY) ui.button( "Submit Job", color=None, diff --git a/frontend/components/forms/field_builders.py b/frontend/components/forms/field_builders.py index 7bf4d1e3..cb056e3a 100644 --- a/frontend/components/forms/field_builders.py +++ b/frontend/components/forms/field_builders.py @@ -176,6 +176,7 @@ def create_directory_input( field_id, initial_value, form_widgets, autofill_output_key=None ): from frontend.utils import get_active_case + active_case = get_active_case() default_path = active_case.evidencePath if active_case else "" @@ -228,7 +229,9 @@ def validate(): ui.button( "Browse", on_click=lambda: browse_directory_simple( - dir_input, initial_path=default_path or None, on_after_select=validate + dir_input, + initial_path=default_path or None, + on_after_select=validate, ), ).classes(Design.BTN_MEDIUM_GRAY) form_widgets[field_id] = dir_input @@ -236,6 +239,7 @@ def validate(): def create_file_input(field_id, initial_value, form_widgets, autofill_mount_key=None): from frontend.utils import get_active_case + active_case = get_active_case() default_path = active_case.evidencePath if active_case else "" @@ -285,7 +289,9 @@ def validate(): ui.button( "Browse", on_click=lambda: browse_file_simple( - file_input, initial_path=default_path or None, on_after_select=validate + file_input, + initial_path=default_path or None, + on_after_select=validate, ), ).classes(Design.BTN_MEDIUM_GRAY) form_widgets[field_id] = file_input diff --git a/frontend/components/forms/form_generator.py b/frontend/components/forms/form_generator.py index b0f39eb7..29793163 100644 --- a/frontend/components/forms/form_generator.py +++ b/frontend/components/forms/form_generator.py @@ -59,9 +59,9 @@ async def _submit_wrapper(): finally: btn.props["loading"] = False - submit_btn = ui.button("▶ Submit Job", color=None, on_click=_submit_wrapper).classes( - "rb-brand-primary text-white rounded-xl" - ) + submit_btn = ui.button( + "▶ Submit Job", color=None, on_click=_submit_wrapper + ).classes("rb-brand-primary text-white rounded-xl") btn_ref[0] = submit_btn return submit_btn diff --git a/frontend/components/jobs.py b/frontend/components/jobs.py index 307164c5..200d782d 100644 --- a/frontend/components/jobs.py +++ b/frontend/components/jobs.py @@ -48,9 +48,9 @@ def _download() -> None: icon="download", color=None, on_click=_download, - ).classes( - Design.BTN_MEDIUM_GRAY - ).props("dense").tooltip("Download a JSON-LD fragment (UCO-oriented) for this job") + ).classes(Design.BTN_MEDIUM_GRAY).props("dense").tooltip( + "Download a JSON-LD fragment (UCO-oriented) for this job" + ) def render_compact_inputs_summary( @@ -427,7 +427,9 @@ def render_job_row( "Failed": "bg-rose-50 text-rose-700 border border-rose-200", "Canceled": "bg-slate-100 text-slate-600 border border-slate-200", } - pill_cls = status_pill_classes.get(status, "bg-slate-50 text-slate-500 border border-slate-200") + pill_cls = status_pill_classes.get( + status, "bg-slate-50 text-slate-500 border border-slate-200" + ) # Format timestamps start_time_str = "N/A" @@ -457,7 +459,9 @@ def render_job_row( ): # Job ID - truncated with ellipsis, full ID on hover with ui.element("div").classes("w-40 min-w-0 shrink-0"): - id_label = ui.label(job_uid).classes("font-mono text-sm truncate block text-slate-800") + id_label = ui.label(job_uid).classes( + "font-mono text-sm truncate block text-slate-800" + ) id_label.tooltip(job_uid) # Model name (and notes indicator) @@ -476,10 +480,14 @@ def render_job_row( # Times (start / end) with ui.column().classes("w-64 shrink-0 gap-0.5"): ui.label(start_time_str).classes("text-sm text-slate-700") - ui.label(f"Ended: {end_time_str}" if end_time_str != "N/A" else "Active").classes("text-xs text-slate-500") + ui.label( + f"Ended: {end_time_str}" if end_time_str != "N/A" else "Active" + ).classes("text-xs text-slate-500") # Status Pill Badge - with ui.row().classes(f"w-32 shrink-0 items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold {pill_cls}"): + with ui.row().classes( + f"w-32 shrink-0 items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold {pill_cls}" + ): if status == "Completed": ui.icon("check_circle", size="14px") elif status == "Running": @@ -506,14 +514,18 @@ def render_job_row( icon="cancel", color=None, on_click=lambda: on_cancel(job["uid"]) if on_cancel else None, - ).classes("bg-rose-50 hover:bg-rose-100 text-rose-700 px-3 py-1 rounded text-sm transition-colors border border-rose-200") + ).classes( + "bg-rose-50 hover:bg-rose-100 text-rose-700 px-3 py-1 rounded text-sm transition-colors border border-rose-200" + ) elif status != "Running" and on_delete: ui.button( "Delete", icon="delete", color=None, on_click=lambda: on_delete(job["uid"]) if on_delete else None, - ).classes("bg-rose-50 hover:bg-rose-100 text-[#881c1c] px-3 py-1 rounded text-sm transition-colors border border-rose-200") + ).classes( + "bg-rose-50 hover:bg-rose-100 text-[#881c1c] px-3 py-1 rounded text-sm transition-colors border border-rose-200" + ) # logger.debug("Delete button added") diff --git a/frontend/components/logs.py b/frontend/components/logs.py index 215bb4fa..82afbe0e 100644 --- a/frontend/components/logs.py +++ b/frontend/components/logs.py @@ -15,7 +15,9 @@ def render_log_viewer(container: ui.element, log_file: Path, max_lines: int = 10 try: with container: # Controls row with Refresh and Search Input (instant typing filter) - with ui.row().classes("gap-4 items-center mb-4 w-full flex-wrap sm:flex-nowrap"): + with ui.row().classes( + "gap-4 items-center mb-4 w-full flex-wrap sm:flex-nowrap" + ): refresh_btn = ( ui.button("Refresh") .props("icon=refresh") @@ -23,9 +25,13 @@ def render_log_viewer(container: ui.element, log_file: Path, max_lines: int = 10 ) # Search input with prepended search icon, clearable prop, and debounce - search_input = ui.input( - placeholder="Search/filter logs...", - ).props("outlined dense clearable debounce=300").classes("w-64 bg-white") + search_input = ( + ui.input( + placeholder="Search/filter logs...", + ) + .props("outlined dense clearable debounce=300") + .classes("w-64 bg-white") + ) with search_input.add_slot("prepend"): ui.icon("search").classes("text-slate-400") @@ -41,6 +47,7 @@ class LogDisplayLabel(ui.label): @property def content(self) -> str: return self.text + @content.setter def content(self, value: str): self.set_text(value) @@ -69,8 +76,10 @@ def _apply_filter(query: str = None): # Filter lines lines = log_display.raw_content.splitlines() - matching_lines = [line for line in lines if query.lower() in line.lower()] - + matching_lines = [ + line for line in lines if query.lower() in line.lower() + ] + if matching_lines: header = f"[Found {len(matching_lines)} matching lines for '{query}']\n\n" log_display.content = header + "\n".join(matching_lines) @@ -84,6 +93,7 @@ def _apply_filter(query: str = None): def _refresh(): try: from frontend.pages.logs import read_log_file + content = read_log_file(log_file, max_lines) log_display.raw_content = content _apply_filter(search_input.value) diff --git a/frontend/components/models.py b/frontend/components/models.py index 2dfd937d..1c5f4739 100644 --- a/frontend/components/models.py +++ b/frontend/components/models.py @@ -118,8 +118,10 @@ def render_model_card( ) ) logger.debug("Selected icon: %s for model category", icon_name) - ui.icon(icon_name, size='sm').classes('text-[#881c1c]') - ui.label(model["name"]).classes("text-2xl font-bold text-slate-800") + ui.icon(icon_name, size="sm").classes("text-[#881c1c]") + ui.label(model["name"]).classes( + "text-2xl font-bold text-slate-800" + ) logger.debug("Model name label added: %s", model["name"]) # Version, author, GPU info @@ -130,13 +132,21 @@ def render_model_card( ui.label("•") ui.label(model.get("author", "Unknown")) if model.get("gpu"): - ui.badge("GPU Required", color="orange").classes("text-xs font-semibold px-2 py-0.5 rounded") + ui.badge("GPU Required", color="orange").classes( + "text-xs font-semibold px-2 py-0.5 rounded" + ) # Right section - Status and actions with ui.column().classes("items-end gap-3"): # Status Badge - status_pill_cls = "bg-emerald-50 text-emerald-700 border border-emerald-200" if is_online else "bg-rose-50 text-rose-700 border border-rose-200" - with ui.row().classes(f"items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold {status_pill_cls}"): + status_pill_cls = ( + "bg-emerald-50 text-emerald-700 border border-emerald-200" + if is_online + else "bg-rose-50 text-rose-700 border border-rose-200" + ) + with ui.row().classes( + f"items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold {status_pill_cls}" + ): ui.icon("check_circle" if is_online else "error", size="14px") ui.label(status_text) @@ -162,7 +172,9 @@ def render_model_card( on_click=lambda: ( on_connect(model["uid"]) if on_connect else None ), - ).classes("bg-slate-100 hover:bg-slate-200 text-slate-800 px-4 py-2 rounded-lg font-medium transition-colors border border-slate-200") + ).classes( + "bg-slate-100 hover:bg-slate-200 text-slate-800 px-4 py-2 rounded-lg font-medium transition-colors border border-slate-200" + ) logger.debug("Connect button added (model is offline)") logger.debug("Model card rendered successfully") diff --git a/frontend/components/results.py b/frontend/components/results.py index 5c540166..7a9fa631 100644 --- a/frontend/components/results.py +++ b/frontend/components/results.py @@ -140,7 +140,9 @@ def open_file(path: str): color=None, on_click=lambda: open_folder(os.path.dirname(path)), ).classes(Design.BTN_SECONDARY_NEUTRAL) - ui.button("Close", color=None, on_click=d.close).classes(Design.BTN_MEDIUM_GRAY) + ui.button("Close", color=None, on_click=d.close).classes( + Design.BTN_MEDIUM_GRAY + ) d.open() else: ui.navigate.to(route) @@ -306,7 +308,9 @@ def open_image_bbox_preview_dialog( color=None, on_click=lambda: open_folder(os.path.dirname(abs_path)), ).classes(Design.BTN_SECONDARY_NEUTRAL) - ui.button("Close", color=None, on_click=dialog.close).classes(Design.BTN_MEDIUM_GRAY) + ui.button("Close", color=None, on_click=dialog.close).classes( + Design.BTN_MEDIUM_GRAY + ) dialog.open() @@ -903,12 +907,15 @@ def _open_image_summary_markdown_modal(file_info: Dict[str, Any]) -> None: with ui.row().classes("gap-2 mt-4 shrink-0 justify-end flex-wrap"): if path_full: ui.button( - "Open raw file", color=None, on_click=lambda: open_file(path_full) + "Open raw file", + color=None, + on_click=lambda: open_file(path_full), ).classes(Design.BTN_SECONDARY_NEUTRAL) - + def _download_raw(): try: import os + with open(path_full, "rb") as f: data = f.read() ui.download(data, os.path.basename(path_full)) diff --git a/frontend/components/shared.py b/frontend/components/shared.py index e50dd4d3..c34ceef3 100644 --- a/frontend/components/shared.py +++ b/frontend/components/shared.py @@ -4,7 +4,6 @@ from nicegui import ui from frontend.utils.ui import notify_success as _ns, notify_error as _ne from frontend.utils.ui import notify_info as _ni, notify_warning as _nw -from frontend.config import APP_TITLE, APP_VERSION import frontend.constants as constants from frontend.design_tokens import Design from frontend.utils import get_user_id_for_jobs @@ -136,6 +135,7 @@ def _nav_blocked_msg(): ) from frontend.utils import get_active_case + active_case = get_active_case() with ui.row().classes( @@ -145,24 +145,35 @@ def _nav_blocked_msg(): # logger.debug("Creating navbar container with responsive layout") with ui.row().classes("shrink-0 items-center gap-2 min-w-0"): - with ui.row().classes("items-center cursor-pointer").on("click", lambda _: ui.navigate.to("/")): - ui.html('', sanitize=False) + with ui.row().classes("items-center cursor-pointer").on( + "click", lambda _: ui.navigate.to("/") + ): + ui.html( + '', + sanitize=False, + ) if active_case: from frontend.database import get_case_db from frontend.utils import set_active_case_id, clear_active_case_id - + try: all_cases = get_case_db().get_all_cases_sync() - other_cases = [c for c in all_cases if c.caseId != active_case.caseId] + other_cases = [ + c for c in all_cases if c.caseId != active_case.caseId + ] except Exception: all_cases = [active_case] other_cases = [] if len(all_cases) <= 1: # Just show a clean static badge if there is only one case in the system - with ui.row().classes("items-center gap-1 bg-black/20 px-2.5 py-1 rounded-lg border border-white/20 ml-2 cursor-pointer").on("click", lambda _: ui.navigate.to("/case")): + with ui.row().classes( + "items-center gap-1 bg-black/20 px-2.5 py-1 rounded-lg border border-white/20 ml-2 cursor-pointer" + ).on("click", lambda _: ui.navigate.to("/case")): ui.icon("folder", size="xs").classes("text-white") - ui.label(f"Case: {active_case.caseNumber}").classes("text-xs font-semibold text-white") + ui.label(f"Case: {active_case.caseNumber}").classes( + "text-xs font-semibold text-white" + ) else: # Show the interactive dropdown if there are multiple cases to switch between with ui.dropdown_button( @@ -172,25 +183,45 @@ def _nav_blocked_msg(): auto_close=True, ).classes( "text-xs font-semibold text-white bg-black/20 px-2.5 py-1 rounded-lg border border-white/20 ml-2 cursor-pointer" - ).props("flat dense no-caps split").on("click", lambda _: ui.navigate.to("/case")): - ui.menu_item("Case Overview", on_click=lambda: ui.navigate.to("/case")).classes("font-semibold text-[#881c1c]") + ).props( + "flat dense no-caps split" + ).on( + "click", lambda _: ui.navigate.to("/case") + ): + ui.menu_item( + "Case Overview", + on_click=lambda: ui.navigate.to("/case"), + ).classes("font-semibold text-[#881c1c]") ui.separator() if other_cases: - ui.label("Switch Case:").classes("text-[10px] font-bold text-slate-400 px-3 py-1 uppercase tracking-wider") - for c in other_cases[:5]: # Show up to 5 other cases + ui.label("Switch Case:").classes( + "text-[10px] font-bold text-slate-400 px-3 py-1 uppercase tracking-wider" + ) + for c in other_cases[:5]: # Show up to 5 other cases + def _switch_case(cid=c.caseId): set_active_case_id(cid) - ui.notify(f"Switched to case {c.caseNumber}.", type="positive") - ui.timer(0.3, lambda: ui.navigate.to("/case"), once=True) + ui.notify( + f"Switched to case {c.caseNumber}.", + type="positive", + ) + ui.timer( + 0.3, + lambda: ui.navigate.to("/case"), + once=True, + ) + ui.menu_item(c.caseNumber, on_click=_switch_case) ui.separator() - + def _close_active_case(): clear_active_case_id() ui.notify("Case closed.", type="info") ui.timer(0.2, lambda: ui.navigate.to("/"), once=True) - - ui.menu_item("Close Case", on_click=_close_active_case).classes("text-rose-500 font-semibold") + + ui.menu_item( + "Close Case", on_click=_close_active_case + ).classes("text-rose-500 font-semibold") with ui.row().classes("min-w-0 flex-1 justify-end items-center"): with ui.row().classes( diff --git a/frontend/database/case_db.py b/frontend/database/case_db.py index 2cce0e05..ebf10d97 100644 --- a/frontend/database/case_db.py +++ b/frontend/database/case_db.py @@ -9,7 +9,7 @@ import sqlite3 from datetime import datetime from pathlib import Path -from typing import Dict, List, Optional, Any +from typing import List, Optional import uuid from pydantic import BaseModel, Field @@ -22,6 +22,7 @@ class CaseRecord(BaseModel): """Pydantic model for case records in the database.""" + caseId: str = Field(..., description="Unique case identifier") caseNumber: str = Field(..., description="Case number or ID") investigators: Optional[str] = Field(None, description="Names of investigators") @@ -47,7 +48,6 @@ def _create_schema(self) -> None: async def initialize_schema(self): """Initialize database schema (create cases table if it doesn't exist).""" - conn = self.connect() logger.info("Initializing database schema for cases") self._create_schema() @@ -119,7 +119,9 @@ def get_case_by_id_sync(self, case_id: str) -> Optional[CaseRecord]: async def get_case_by_number(self, case_number: str) -> Optional[CaseRecord]: """Get a case by its case number.""" conn = self.connect() - cursor = conn.execute("SELECT * FROM cases WHERE caseNumber = ?", (case_number.strip(),)) + cursor = conn.execute( + "SELECT * FROM cases WHERE caseNumber = ?", (case_number.strip(),) + ) row = cursor.fetchone() if row: return CaseRecord(**dict(row)) diff --git a/frontend/database/job_db.py b/frontend/database/job_db.py index a044e31e..520f19cb 100644 --- a/frontend/database/job_db.py +++ b/frontend/database/job_db.py @@ -705,6 +705,7 @@ def get_job_by_uid_sync(self, uid: str) -> Optional[JobRecord]: job_dict = self._row_to_dict(row) try: from frontend.utils import get_user_id_for_jobs + current_user_id = get_user_id_for_jobs() except Exception: current_user_id = None diff --git a/frontend/design_tokens.py b/frontend/design_tokens.py index 68937a50..38ff65b6 100644 --- a/frontend/design_tokens.py +++ b/frontend/design_tokens.py @@ -34,7 +34,9 @@ class Design: BTN_PRIMARY_TIGHT = ( "rb-brand-primary text-white px-3 py-1 rounded text-sm transition-colors" ) - BTN_GHOST = "text-slate-600 hover:bg-slate-100 px-4 py-2 rounded-lg transition-colors" + BTN_GHOST = ( + "text-slate-600 hover:bg-slate-100 px-4 py-2 rounded-lg transition-colors" + ) BTN_SECONDARY_NEUTRAL = ( "bg-slate-100 hover:bg-slate-200 text-slate-800 px-4 py-2 rounded-lg " "font-medium transition-colors border border-slate-200" @@ -80,8 +82,12 @@ class Design: ) # --- Tool invocation / result (chat tool cards) --- - CARD_TOOL_CALL = "p-4 my-2 bg-slate-50 border border-slate-200 rounded-xl text-slate-800" - CARD_TOOL_RESULT = "p-4 my-2 bg-slate-50 border border-slate-200 rounded-xl text-slate-800" + CARD_TOOL_CALL = ( + "p-4 my-2 bg-slate-50 border border-slate-200 rounded-xl text-slate-800" + ) + CARD_TOOL_RESULT = ( + "p-4 my-2 bg-slate-50 border border-slate-200 rounded-xl text-slate-800" + ) LABEL_TOOL_CALL_TITLE = "font-semibold text-slate-800 mt-3" LABEL_TOOL_CALL_ARGS = "font-medium text-slate-700 mt-3" LABEL_TOOL_RESULT_TITLE = "font-medium text-slate-800 mt-3" diff --git a/frontend/main.py b/frontend/main.py index 0bc43884..2cb6c5c0 100644 --- a/frontend/main.py +++ b/frontend/main.py @@ -21,19 +21,18 @@ API_TIMEOUT, APP_PORT, APP_TITLE, - APP_VERSION, BACKEND_URL, LOG_FILE, LOG_LEVEL, RECONNECT_TIMEOUT, ) -from frontend.constants import ( - HOME_USER_ID, - NAV_LINKS, - UI_BUTTONS, - UI_TITLES, - is_valid_explicit_user_id, +from frontend.utils import ( + apply_saved_theme, + browse_directory_simple, + get_active_case_id, + set_active_case_id, ) +from frontend.database import get_case_db from frontend.database import init_db from frontend.components.shared import create_navbar from frontend.utils import configure_logging_with_context @@ -41,11 +40,7 @@ from frontend import utils as _backend_integration from frontend.design_tokens import Design from frontend.utils import ( - clear_explicit_user_id, ensure_explicit_user_id_for_tests, - get_explicit_user_id, - set_explicit_user_id, - try_claim_explicit_user_id, ) logging.basicConfig(level=parse_log_level(LOG_LEVEL)) @@ -102,7 +97,9 @@ if hasattr(sys, "_MEIPASS"): APP_FAVICON = os.path.join(sys._MEIPASS, "icons", "favicon.png") else: - APP_FAVICON = os.path.join(os.path.abspath(os.path.dirname(__file__)), "icons", "favicon.png") + APP_FAVICON = os.path.join( + os.path.abspath(os.path.dirname(__file__)), "icons", "favicon.png" + ) try: @@ -122,17 +119,6 @@ async def index(): """Main dashboard / home page (Case Management Dashboard).""" logger.debug("Rendering main dashboard page (index route)") - from frontend.utils import ( - apply_saved_theme, - browse_directory_simple, - get_active_case_id, - set_active_case_id, - clear_active_case_id, - get_active_case, - ensure_explicit_user_id_for_tests, - ) - from frontend.database import get_case_db, get_job_db, JobStatus - apply_saved_theme() logger.debug("Theme preference applied") @@ -152,46 +138,69 @@ async def index(): logger.debug("Navigation bar added to page") ensure_explicit_user_id_for_tests() - active_case_id = get_active_case_id() - with ui.column().classes("container mx-auto px-4 sm:px-8 py-8 w-full max-w-6xl pb-16 gap-8"): + with ui.column().classes( + "container mx-auto px-4 sm:px-8 py-8 w-full max-w-6xl pb-16 gap-8" + ): # Main Header with ui.row().classes("w-full items-center gap-3 mb-2"): ui.icon("folder_shared", size="lg").classes("text-[#881c1c]") - ui.label("RescueBox Case Management").classes("text-4xl font-bold text-slate-800") - ui.label("Create a new investigative case or load an existing one to begin.").classes("text-lg text-slate-500 mb-8 pl-1") + ui.label("RescueBox Case Management").classes( + "text-4xl font-bold text-slate-800" + ) + ui.label( + "Create a new investigative case or load an existing one to begin." + ).classes("text-lg text-slate-500 mb-8 pl-1") # Unconditional Dual-pane Case Management setup with ui.row().classes("w-full gap-8 items-stretch flex-wrap md:flex-nowrap"): # Left Pane: Create New Case - with ui.card().classes("flex-1 p-6 border-t-4 border-t-[#881c1c] border-x border-b border-slate-200 shadow-md rounded-2xl bg-white"): + with ui.card().classes( + "flex-1 p-6 border-t-4 border-t-[#881c1c] border-x border-b border-slate-200 shadow-md rounded-2xl bg-white" + ): with ui.row().classes("items-center gap-2 mb-4"): ui.icon("create_new_folder", size="sm").classes("text-[#881c1c]") - ui.label("Create New Case").classes("text-2xl font-bold text-slate-800") - - case_num_input = ui.input( - "Case Number / ID (Required, Unique)", - placeholder="e.g., CASE-2026-0042", - ).classes("w-full mb-4").props("outlined dense") + ui.label("Create New Case").classes( + "text-2xl font-bold text-slate-800" + ) + + case_num_input = ( + ui.input( + "Case Number / ID (Required, Unique)", + placeholder="e.g., CASE-2026-0042", + ) + .classes("w-full mb-4") + .props("outlined dense") + ) with case_num_input.add_slot("prepend"): ui.icon("assignment").classes("text-slate-400") - - investigators_input = ui.input( - "Investigators", - placeholder="e.g., Det. Smith, Agent Jones", - ).classes("w-full mb-4").props("outlined dense") + + investigators_input = ( + ui.input( + "Investigators", + placeholder="e.g., Det. Smith, Agent Jones", + ) + .classes("w-full mb-4") + .props("outlined dense") + ) with investigators_input.add_slot("prepend"): ui.icon("people").classes("text-slate-400") - + with ui.column().classes("w-full mb-6 gap-1"): - ui.label("Evidence Directory / UFDR Path").classes("text-sm font-medium text-slate-700") + ui.label("Evidence Directory / UFDR Path").classes( + "text-sm font-medium text-slate-700" + ) with ui.row().classes("w-full items-center gap-2 flex-nowrap"): - path_input = ui.input( - placeholder="/path/to/evidence", - ).classes("flex-1").props("outlined dense") + path_input = ( + ui.input( + placeholder="/path/to/evidence", + ) + .classes("flex-1") + .props("outlined dense") + ) with path_input.add_slot("prepend"): ui.icon("folder").classes("text-slate-400") - + ui.button( "Browse", icon="folder_open", @@ -219,7 +228,10 @@ async def _create_case(): evidence_path=path, ) set_active_case_id(new_case.caseId) - ui.notify(f"Case {num} created and loaded successfully.", type="positive") + ui.notify( + f"Case {num} created and loaded successfully.", + type="positive", + ) ui.timer(0.5, lambda: ui.navigate.to("/case"), once=True) except ValueError as e: ui.notify(str(e), type="negative") @@ -234,13 +246,19 @@ async def _create_case(): ).classes(Design.BTN_PRIMARY + " w-full py-3 text-base") # Right Pane: Load Existing Case - with ui.card().classes("flex-1 p-6 border-t-4 border-t-[#881c1c] border-x border-b border-slate-200 shadow-md rounded-2xl bg-white flex flex-col"): + with ui.card().classes( + "flex-1 p-6 border-t-4 border-t-[#881c1c] border-x border-b border-slate-200 shadow-md rounded-2xl bg-white flex flex-col" + ): with ui.row().classes("items-center gap-2 mb-4"): ui.icon("folder_open", size="sm").classes("text-[#881c1c]") - ui.label("Load Existing Case").classes("text-2xl font-bold text-slate-800") - - cases_container = ui.column().classes("w-full flex-1 overflow-y-auto space-y-3 max-h-[400px]") - + ui.label("Load Existing Case").classes( + "text-2xl font-bold text-slate-800" + ) + + cases_container = ui.column().classes( + "w-full flex-1 overflow-y-auto space-y-3 max-h-[400px]" + ) + async def _load_cases(): cases_container.clear() try: @@ -248,40 +266,80 @@ async def _load_cases(): all_cases = await case_db.get_all_cases() if not all_cases: with cases_container: - ui.label("No existing cases found.").classes("text-slate-400 italic p-4 text-center w-full") + ui.label("No existing cases found.").classes( + "text-slate-400 italic p-4 text-center w-full" + ) return with cases_container: for c in all_cases: - with ui.card().classes("w-full p-4 border-l-4 border-l-[#881c1c] border-y border-r border-slate-200 hover:border-slate-300 hover:shadow-md transition-all bg-slate-50 rounded-xl"): - with ui.row().classes("w-full justify-between items-center"): - with ui.column().classes("gap-1 flex-1 min-w-0"): - with ui.row().classes("items-center gap-1.5"): - ui.icon("folder", size="xs").classes("text-[#881c1c]") - ui.label(c.caseNumber).classes("font-bold text-lg text-slate-800 truncate") + with ui.card().classes( + "w-full p-4 border-l-4 border-l-[#881c1c] border-y border-r border-slate-200 hover:border-slate-300 hover:shadow-md transition-all bg-slate-50 rounded-xl" + ): + with ui.row().classes( + "w-full justify-between items-center" + ): + with ui.column().classes( + "gap-1 flex-1 min-w-0" + ): + with ui.row().classes( + "items-center gap-1.5" + ): + ui.icon("folder", size="xs").classes( + "text-[#881c1c]" + ) + ui.label(c.caseNumber).classes( + "font-bold text-lg text-slate-800 truncate" + ) if c.investigators: - with ui.row().classes("items-center gap-1.5"): - ui.icon("people", size="xs").classes("text-slate-400") - ui.label(f"Investigators: {c.investigators}").classes("text-sm text-slate-600 truncate") - with ui.row().classes("items-center gap-1.5"): - ui.icon("link", size="xs").classes("text-slate-400") - ui.label(f"Path: {c.evidencePath}").classes("text-xs font-mono text-slate-500 truncate") - + with ui.row().classes( + "items-center gap-1.5" + ): + ui.icon( + "people", size="xs" + ).classes("text-slate-400") + ui.label( + f"Investigators: {c.investigators}" + ).classes( + "text-sm text-slate-600 truncate" + ) + with ui.row().classes( + "items-center gap-1.5" + ): + ui.icon("link", size="xs").classes( + "text-slate-400" + ) + ui.label( + f"Path: {c.evidencePath}" + ).classes( + "text-xs font-mono text-slate-500 truncate" + ) + def _load(cid=c.caseId, cnum=c.caseNumber): set_active_case_id(cid) - ui.notify(f"Loaded case {cnum}.", type="positive") - ui.timer(0.3, lambda: ui.navigate.to("/case"), once=True) + ui.notify( + f"Loaded case {cnum}.", type="positive" + ) + ui.timer( + 0.3, + lambda: ui.navigate.to("/case"), + once=True, + ) ui.button( "Load", icon="login", color=None, - on_click=lambda cid=c.caseId, cnum=c.caseNumber: _load(cid, cnum), + on_click=lambda cid=c.caseId, cnum=c.caseNumber: _load( + cid, cnum + ), ).classes(Design.BTN_PRIMARY_COMPACT) except Exception as e: logger.error("Error loading cases: %s", e) with cases_container: - ui.label(f"Error loading cases: {e}").classes("text-red-500") + ui.label(f"Error loading cases: {e}").classes( + "text-red-500" + ) await _load_cases() @@ -295,14 +353,9 @@ async def case_overview(): from frontend.utils import ( apply_saved_theme, - browse_directory_simple, - get_active_case_id, - set_active_case_id, clear_active_case_id, - get_active_case, - ensure_explicit_user_id_for_tests, ) - from frontend.database import get_case_db, get_job_db, JobStatus + from frontend.database import get_case_db, get_job_db apply_saved_theme() logger.debug("Theme preference applied") @@ -327,11 +380,15 @@ async def case_overview(): if not active_case_id: # If no active case, redirect to home page to create or load one - ui.notify("No active case loaded. Please create or load a case.", type="warning") + ui.notify( + "No active case loaded. Please create or load a case.", type="warning" + ) ui.timer(0.1, lambda: ui.navigate.to("/"), once=True) return - with ui.column().classes("container mx-auto px-4 sm:px-8 py-8 w-full max-w-6xl pb-16 gap-8"): + with ui.column().classes( + "container mx-auto px-4 sm:px-8 py-8 w-full max-w-6xl pb-16 gap-8" + ): # Active Case Dashboard case_db = get_case_db() case = await case_db.get_case_by_id(active_case_id) @@ -343,66 +400,116 @@ async def case_overview(): with ui.row().classes("items-center gap-3 mb-2"): ui.icon("folder_special", size="lg").classes("text-[#881c1c]") - ui.label(f"Case: {case.caseNumber}").classes("text-4xl font-bold text-slate-800") + ui.label(f"Case: {case.caseNumber}").classes( + "text-4xl font-bold text-slate-800" + ) if case.investigators: with ui.row().classes("items-center gap-2 mb-6 pl-1"): ui.icon("people", size="xs").classes("text-slate-500") - ui.label(f"Investigators: {case.investigators}").classes("text-lg text-slate-600") - + ui.label(f"Investigators: {case.investigators}").classes( + "text-lg text-slate-600" + ) + # Case Details Card - with ui.card().classes("w-full p-6 border-t-4 border-t-[#881c1c] border-x border-b border-slate-200 shadow-md rounded-2xl bg-white mb-8"): - with ui.row().classes("items-center gap-2 mb-4 border-b pb-2 border-slate-100"): + with ui.card().classes( + "w-full p-6 border-t-4 border-t-[#881c1c] border-x border-b border-slate-200 shadow-md rounded-2xl bg-white mb-8" + ): + with ui.row().classes( + "items-center gap-2 mb-4 border-b pb-2 border-slate-100" + ): ui.icon("info", size="sm").classes("text-[#881c1c]") ui.label("Case Information").classes("text-xl font-bold text-slate-800") with ui.column().classes("w-full gap-3"): with ui.row().classes("items-center gap-2.5"): ui.icon("fingerprint", size="xs").classes("text-slate-400") - ui.label("Case ID:").classes("font-semibold text-slate-700 w-24 shrink-0") - ui.label(case.caseId).classes("font-mono text-slate-600 truncate bg-slate-50 px-2 py-0.5 rounded border border-slate-100") + ui.label("Case ID:").classes( + "font-semibold text-slate-700 w-24 shrink-0" + ) + ui.label(case.caseId).classes( + "font-mono text-slate-600 truncate bg-slate-50 px-2 py-0.5 rounded border border-slate-100" + ) with ui.row().classes("items-center gap-2.5"): ui.icon("today", size="xs").classes("text-slate-400") - ui.label("Created:").classes("font-semibold text-slate-700 w-24 shrink-0") - ui.label(case.createdAt[:10] + " " + case.createdAt[11:16]).classes("text-slate-600") - with ui.row().classes("items-center gap-2.5 w-full flex-wrap sm:flex-nowrap"): + ui.label("Created:").classes( + "font-semibold text-slate-700 w-24 shrink-0" + ) + ui.label(case.createdAt[:10] + " " + case.createdAt[11:16]).classes( + "text-slate-600" + ) + with ui.row().classes( + "items-center gap-2.5 w-full flex-wrap sm:flex-nowrap" + ): ui.icon("folder", size="xs").classes("text-slate-400") - ui.label("Evidence Path:").classes("font-semibold text-slate-700 w-24 shrink-0") - path_display = ui.input(value=case.evidencePath).classes("flex-1 min-w-0").props("outlined dense readonly") + ui.label("Evidence Path:").classes( + "font-semibold text-slate-700 w-24 shrink-0" + ) + path_display = ( + ui.input(value=case.evidencePath) + .classes("flex-1 min-w-0") + .props("outlined dense readonly") + ) with path_display.add_slot("prepend"): ui.icon("folder", size="xs").classes("text-slate-400") - + async def _change_path(): - with ui.dialog() as d, ui.card().classes("p-6 w-full max-w-lg bg-white border-t-4 border-t-[#881c1c] border-x border-b border-slate-200 rounded-2xl shadow-xl"): + with ui.dialog() as d, ui.card().classes( + "p-6 w-full max-w-lg bg-white border-t-4 border-t-[#881c1c] border-x border-b border-slate-200 rounded-2xl shadow-xl" + ): with ui.row().classes("items-center gap-2 mb-4"): ui.icon("edit", size="sm").classes("text-[#881c1c]") - ui.label("Update Evidence Path").classes("text-xl font-bold text-slate-800") - new_path_input = ui.input("New Evidence Directory / UFDR Path", value=case.evidencePath).classes("w-full mb-6").props("outlined dense") + ui.label("Update Evidence Path").classes( + "text-xl font-bold text-slate-800" + ) + new_path_input = ( + ui.input( + "New Evidence Directory / UFDR Path", + value=case.evidencePath, + ) + .classes("w-full mb-6") + .props("outlined dense") + ) with new_path_input.add_slot("prepend"): ui.icon("folder").classes("text-slate-400") with ui.row().classes("w-full justify-end gap-2"): - ui.button("Cancel", icon="close", color=None, on_click=d.close).classes(Design.BTN_MEDIUM_GRAY) - + ui.button( + "Cancel", icon="close", color=None, on_click=d.close + ).classes(Design.BTN_MEDIUM_GRAY) + async def _save_path(): p = (new_path_input.value or "").strip() if not p: - ui.notify("Path cannot be empty.", type="warning") + ui.notify( + "Path cannot be empty.", type="warning" + ) return - await case_db.update_case_evidence_path(case.caseId, p) - ui.notify("Evidence path updated successfully.", type="positive") + await case_db.update_case_evidence_path( + case.caseId, p + ) + ui.notify( + "Evidence path updated successfully.", + type="positive", + ) d.close() - ui.timer(0.3, lambda: ui.navigate.reload(), once=True) + ui.timer( + 0.3, lambda: ui.navigate.reload(), once=True + ) - ui.button("Save", icon="save", color=None, on_click=_save_path).classes(Design.BTN_PRIMARY_COMPACT) + ui.button( + "Save", icon="save", color=None, on_click=_save_path + ).classes(Design.BTN_PRIMARY_COMPACT) d.open() - ui.button("Change Path", icon="edit", color=None, on_click=_change_path).classes(Design.BTN_MEDIUM_GRAY) + ui.button( + "Change Path", icon="edit", color=None, on_click=_change_path + ).classes(Design.BTN_MEDIUM_GRAY) # Case Results (Jobs) Table with ui.row().classes("items-center gap-2 mb-4"): ui.icon("view_list", size="sm").classes("text-[#881c1c]") ui.label("Case Results & Jobs").classes("text-2xl font-bold text-slate-800") - + jobs_container = ui.column().classes("w-full space-y-2") - + async def _load_case_jobs(): jobs_container.clear() try: @@ -410,12 +517,18 @@ async def _load_case_jobs(): jobs_data = await job_db.get_all_jobs() if not jobs_data: with jobs_container: - ui.label("No jobs or results associated with this case yet.").classes("text-slate-400 italic p-6 text-center w-full bg-slate-50 rounded-xl border border-dashed border-slate-200") + ui.label( + "No jobs or results associated with this case yet." + ).classes( + "text-slate-400 italic p-6 text-center w-full bg-slate-50 rounded-xl border border-dashed border-slate-200" + ) return with jobs_container: # Header Row - with ui.row().classes("bg-[#1c1c1c] text-white p-4 font-semibold w-full rounded-t-xl items-center"): + with ui.row().classes( + "bg-[#1c1c1c] text-white p-4 font-semibold w-full rounded-t-xl items-center" + ): ui.label("Job ID").classes("w-32 shrink-0") ui.label("Plugin / Task").classes("flex-1 min-w-0") ui.label("Start Time").classes("w-48 shrink-0") @@ -430,7 +543,7 @@ async def _load_case_jobs(): if "T" in start_time: start_time = start_time.replace("T", " ")[:16] status = job.get("status", "Unknown") - + # Status Pill Badges status_pill_classes = { "Completed": "bg-emerald-50 text-emerald-700 border border-emerald-200", @@ -438,15 +551,27 @@ async def _load_case_jobs(): "Failed": "bg-rose-50 text-rose-700 border border-rose-200", "Canceled": "bg-slate-100 text-slate-600 border border-slate-200", } - pill_cls = status_pill_classes.get(status, "bg-slate-50 text-slate-500 border border-slate-200") + pill_cls = status_pill_classes.get( + status, "bg-slate-50 text-slate-500 border border-slate-200" + ) + + with ui.row().classes( + "p-4 border-b border-slate-200 hover:bg-slate-50 items-center w-full flex-nowrap gap-2 bg-white" + ): + ui.label(uid).classes( + "font-mono text-sm w-32 shrink-0 truncate text-slate-800" + ).tooltip(uid) + ui.label(pname).classes( + "flex-1 min-w-0 truncate text-slate-800" + ) + ui.label(start_time).classes( + "w-48 shrink-0 text-sm text-slate-600" + ) - with ui.row().classes("p-4 border-b border-slate-200 hover:bg-slate-50 items-center w-full flex-nowrap gap-2 bg-white"): - ui.label(uid).classes("font-mono text-sm w-32 shrink-0 truncate text-slate-800").tooltip(uid) - ui.label(pname).classes("flex-1 min-w-0 truncate text-slate-800") - ui.label(start_time).classes("w-48 shrink-0 text-sm text-slate-600") - # Render status pill badge - with ui.row().classes(f"w-36 shrink-0 items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold {pill_cls}"): + with ui.row().classes( + f"w-36 shrink-0 items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold {pill_cls}" + ): if status == "Completed": ui.icon("check_circle", size="14px") elif status == "Running": @@ -456,18 +581,22 @@ async def _load_case_jobs(): else: ui.icon("cancel", size="14px") ui.label(status) - + with ui.row().classes("w-48 shrink-0 gap-2 flex-nowrap"): ui.button( "Open", icon="visibility", color=None, - on_click=lambda jid=uid: ui.navigate.to(f"/jobs/{jid}"), + on_click=lambda jid=uid: ui.navigate.to( + f"/jobs/{jid}" + ), ).classes(Design.BTN_PRIMARY_TIGHT) - + async def _remove_job(jid=uid): await get_job_db().disassociate_job_from_case(jid) - ui.notify(f"Job {jid} removed from case.", type="info") + ui.notify( + f"Job {jid} removed from case.", type="info" + ) await _load_case_jobs() ui.button( @@ -475,7 +604,9 @@ async def _remove_job(jid=uid): icon="delete", color=None, on_click=lambda jid=uid: _remove_job(jid), - ).classes("bg-rose-50 hover:bg-rose-100 text-[#881c1c] px-3 py-1 rounded text-sm transition-colors border border-rose-200") + ).classes( + "bg-rose-50 hover:bg-rose-100 text-[#881c1c] px-3 py-1 rounded text-sm transition-colors border border-rose-200" + ) except Exception as e: logger.error("Error loading case jobs: %s", e) diff --git a/frontend/pages/about.py b/frontend/pages/about.py index 657decc2..3e12437d 100644 --- a/frontend/pages/about.py +++ b/frontend/pages/about.py @@ -28,7 +28,9 @@ async def about_page(request: Request): apply_saved_theme() create_navbar() - with ui.column().classes("container mx-auto px-4 sm:px-8 py-8 w-full max-w-6xl pb-16 gap-6"): + with ui.column().classes( + "container mx-auto px-4 sm:px-8 py-8 w-full max-w-6xl pb-16 gap-6" + ): # Hero Header Card with ui.card().classes( "w-full p-6 sm:p-8 bg-gradient-to-br from-slate-900 via-[#1c1c1c] to-slate-900 " @@ -41,11 +43,15 @@ async def about_page(request: Request): with ui.row().classes("items-center gap-4 w-full relative z-10"): ui.icon("info", size="2.5rem").classes("text-[#881c1c]") with ui.column().classes("gap-1 flex-1"): - ui.label("About RescueBox").classes("text-2xl sm:text-3xl font-extrabold tracking-tight") + ui.label("About RescueBox").classes( + "text-2xl sm:text-3xl font-extrabold tracking-tight" + ) ui.label( "An advanced, AI-powered forensic and investigative platform designed for deep data analysis, " "media processing, and intelligence gathering." - ).classes("text-slate-300 text-sm sm:text-base max-w-3xl leading-relaxed") + ).classes( + "text-slate-300 text-sm sm:text-base max-w-3xl leading-relaxed" + ) # Two-Column Layout with ui.grid().classes("w-full grid-cols-1 lg:grid-cols-3 gap-6 items-start"): @@ -55,29 +61,37 @@ async def about_page(request: Request): with ui.card().classes( "w-full p-6 bg-white border border-slate-200 rounded-2xl shadow-sm border-t-4 border-t-[#881c1c]" ): - ui.label("System Information").classes("text-xl font-bold text-slate-800 mb-4") - + ui.label("System Information").classes( + "text-xl font-bold text-slate-800 mb-4" + ) + _system_rows = ( ("Application Name", APP_TITLE, "label", False), ("Software Version", f"v{APP_VERSION}", "tag", False), ("Core Developers", ABOUT_AUTHORS, "people", False), ("Official Repository", ABOUT_REPO_URL, "code", True), ) - + with ui.column().classes("w-full gap-3"): for label_text, val, icon_name, is_url in _system_rows: with ui.row().classes( "w-full gap-4 py-3 border-b border-slate-100 last:border-0 items-center hover:bg-slate-50/50 px-2 rounded-lg transition-colors" ): - ui.icon(icon_name, size="sm").classes("text-[#881c1c] shrink-0") + ui.icon(icon_name, size="sm").classes( + "text-[#881c1c] shrink-0" + ) with ui.column().classes("gap-0.5 flex-1 min-w-0"): - ui.label(label_text).classes("text-xs font-semibold text-slate-400 uppercase tracking-wider") + ui.label(label_text).classes( + "text-xs font-semibold text-slate-400 uppercase tracking-wider" + ) if is_url and val.startswith("http"): ui.link(val, val, new_tab=True).classes( "text-sm font-medium text-[#881c1c] hover:underline break-all min-w-0" ) else: - ui.label(val).classes("text-sm font-semibold text-slate-800 break-words") + ui.label(val).classes( + "text-sm font-semibold text-slate-800 break-words" + ) # Licenses Section with ui.column().classes("w-full"): @@ -90,17 +104,27 @@ async def about_page(request: Request): "w-full p-6 bg-white border border-slate-200 rounded-2xl shadow-sm " "flex flex-col items-center text-center gap-4 border-t-4 border-t-[#881c1c]" ): - ui.label("Research & Sponsorship").classes("text-sm font-bold text-slate-400 uppercase tracking-wider self-start") - ui.element("img").props("src=/icons/rb.webp alt=\"RescueLab Logo\"").classes("h-16 object-contain my-2") + ui.label("Research & Sponsorship").classes( + "text-sm font-bold text-slate-400 uppercase tracking-wider self-start" + ) + ui.element("img").props( + 'src=/icons/rb.webp alt="RescueLab Logo"' + ).classes("h-16 object-contain my-2") with ui.column().classes("gap-1 items-center"): - ui.label("RescueLab").classes("text-lg font-bold text-slate-800") - ui.label("University of Massachusetts Amherst").classes("text-xs font-semibold text-slate-500") + ui.label("RescueLab").classes( + "text-lg font-bold text-slate-800" + ) + ui.label("University of Massachusetts Amherst").classes( + "text-xs font-semibold text-slate-500" + ) ui.label( "RescueLab conducts cutting-edge research in systems, security, and digital forensics. " "RescueBox is developed and maintained as part of our commitment to open-source investigative tools." ).classes("text-slate-600 text-xs leading-relaxed") ui.separator().classes("w-full my-1") - ui.link("Visit RescueLab Website", RESCUE_LAB_URL, new_tab=True).classes( + ui.link( + "Visit RescueLab Website", RESCUE_LAB_URL, new_tab=True + ).classes( "text-sm font-bold text-[#881c1c] hover:underline flex items-center gap-1" ) @@ -108,23 +132,51 @@ async def about_page(request: Request): with ui.card().classes( "w-full p-6 bg-white border border-slate-200 rounded-2xl shadow-sm border-t-4 border-t-[#881c1c]" ): - ui.label("Quick Resources").classes("text-sm font-bold text-slate-400 uppercase tracking-wider mb-2") - + ui.label("Quick Resources").classes( + "text-sm font-bold text-slate-400 uppercase tracking-wider mb-2" + ) + _resources = ( - ("Case Dashboard", "folder_shared", "/", "Manage active cases and evidence"), - ("AI Assistant", "forum", "/chatbot", "Interact with Granite AI models"), - ("Jobs & Pipelines", "view_list", "/jobs", "Monitor background tasks"), - ("System Logs", "terminal", "/logs", "View real-time application logs"), + ( + "Case Dashboard", + "folder_shared", + "/", + "Manage active cases and evidence", + ), + ( + "AI Assistant", + "forum", + "/chatbot", + "Interact with Granite AI models", + ), + ( + "Jobs & Pipelines", + "view_list", + "/jobs", + "Monitor background tasks", + ), + ( + "System Logs", + "terminal", + "/logs", + "View real-time application logs", + ), ) - + with ui.column().classes("w-full gap-2"): for name, icon_name, path, desc in _resources: with ui.row().classes( "w-full p-2.5 rounded-xl border border-slate-100 hover:border-slate-200 hover:bg-slate-50 cursor-pointer items-center gap-3 transition-all" ).on("click", lambda _, p=path: ui.navigate.to(p)): - ui.icon(icon_name, size="sm").classes("text-[#881c1c] shrink-0") + ui.icon(icon_name, size="sm").classes( + "text-[#881c1c] shrink-0" + ) with ui.column().classes("gap-0.5 flex-1 min-w-0"): - ui.label(name).classes("text-sm font-bold text-slate-800") - ui.label(desc).classes("text-[11px] text-slate-500 truncate") + ui.label(name).classes( + "text-sm font-bold text-slate-800" + ) + ui.label(desc).classes( + "text-[11px] text-slate-500 truncate" + ) logger.debug("About page rendered") diff --git a/frontend/pages/chatbot/coordinator.py b/frontend/pages/chatbot/coordinator.py index d99cb3c3..3493cae5 100644 --- a/frontend/pages/chatbot/coordinator.py +++ b/frontend/pages/chatbot/coordinator.py @@ -2,6 +2,7 @@ import logging import asyncio from typing import Dict, Any, Callable, Optional, List +from frontend.design_tokens import Design from nicegui import ui from frontend.chatbot.config import ToolRegistry @@ -604,8 +605,12 @@ def _apply_filter(): dialog.close() with ui.row().classes("mt-4 gap-2"): - ui.button("Use all", on_click=_use_all, color=None).classes(Design.BTN_MEDIUM_GRAY) - ui.button("Apply filter", on_click=_apply_filter, color=None).classes(Design.BTN_PRIMARY_COMPACT) + ui.button("Use all", on_click=_use_all, color=None).classes( + Design.BTN_MEDIUM_GRAY + ) + ui.button( + "Apply filter", on_click=_apply_filter, color=None + ).classes(Design.BTN_PRIMARY_COMPACT) dialog.open() try: return await asyncio.wait_for(future, timeout=120.0) diff --git a/frontend/pages/chatbot/handlers.py b/frontend/pages/chatbot/handlers.py index ce569ce9..895ac5d6 100644 --- a/frontend/pages/chatbot/handlers.py +++ b/frontend/pages/chatbot/handlers.py @@ -3,7 +3,7 @@ import asyncio from typing import Any, Optional from nicegui import ui - +from frontend.utils.ui import _safe_ui_call from frontend.utils import get_user_id_for_jobs from .database_service import DatabaseService @@ -79,9 +79,7 @@ async def _execute_job( ) pipeline_total = (1 + len(remaining_calls)) if remaining_calls else None - db_kwargs = { - k: v for k, v in kwargs.items() if k not in ("form_element",) - } + db_kwargs = {k: v for k, v in kwargs.items() if k not in ("form_element",)} # Create and track job in the main thread (so we get the job_id and can redirect immediately) job_id = None @@ -100,7 +98,9 @@ async def _execute_job( if job_id: # Redirect immediately to the general jobs view so the user can see the list of jobs - ui.timer(0.1, lambda: ui.navigate.to("/jobs"), once=True) + _safe_ui_call( + ui.timer, 0.1, lambda: ui.navigate.to("/jobs"), once=True + ) async def do_submit(): try: @@ -136,7 +136,9 @@ async def do_submit(): {"job_id": job_id}, ) except Exception as ui_err: - self.logger.debug(f"UI update skipped (likely navigated away): {ui_err}") + self.logger.debug( + f"UI update skipped (likely navigated away): {ui_err}" + ) except Exception as e: self.logger.error(f"Job submission failed: {e}") message = str(e) @@ -146,20 +148,24 @@ async def do_submit(): job_uid=job_id, status="Failed", status_text=message ) except Exception as db_err: - self.logger.error(f"Failed to update job status to Failed in DB: {db_err}") + self.logger.error( + f"Failed to update job status to Failed in DB: {db_err}" + ) if conversation_id: try: await DatabaseService.save_error_to_history( conversation_id, endpoint, message ) except Exception as hist_err: - self.logger.error(f"Failed to save error to chat history: {hist_err}") + self.logger.error( + f"Failed to save error to chat history: {hist_err}" + ) if loading_row and hasattr(loading_row, "delete"): try: loading_row.delete() except Exception: pass - + try: if "demo_???" in message: from frontend.pages.chatbot.ui import UIOperations @@ -295,7 +301,9 @@ def get_tool_icon(name: str) -> str: return "extension" with self.container: - with ui.card().classes("w-full max-w-full bg-white border border-slate-200 shadow-md rounded-2xl overflow-hidden p-0"): + with ui.card().classes( + "w-full max-w-full bg-white border border-slate-200 shadow-md rounded-2xl overflow-hidden p-0" + ): with ui.row().classes(Design.PANEL_SHELL_HEADER): with ui.row().classes("items-center gap-2"): ui.icon("extension", size="sm").classes("text-[#881c1c]") @@ -329,24 +337,32 @@ def get_tool_icon(name: str) -> str: ) with row: # Left side: Icon and Text - with ui.row().classes("items-center gap-4 flex-1 min-w-0"): + with ui.row().classes( + "items-center gap-4 flex-1 min-w-0" + ): # Beautiful icon container with ui.element("div").classes( "w-12 h-12 rounded-xl bg-[#881c1c]/5 flex items-center justify-center shrink-0 border border-[#881c1c]/10" ): - ui.icon(get_tool_icon(tool["name"]), size="24px").classes("text-[#881c1c]") - + ui.icon( + get_tool_icon(tool["name"]), size="24px" + ).classes("text-[#881c1c]") + # Text column with ui.column().classes("flex-1 min-w-0 gap-0.5"): ui.label(f'{num}. {tool["name"]}').classes( "text-lg font-bold text-slate-800 leading-snug" ) - ui.label(tool.get("desc", "No description")).classes( + ui.label( + tool.get("desc", "No description") + ).classes( "text-sm sm:text-base text-slate-500 whitespace-normal break-words leading-relaxed" ) - + # Right side: Launch action indicator - with ui.row().classes("items-center gap-1 shrink-0 text-[#881c1c] font-semibold text-sm bg-[#881c1c]/5 hover:bg-[#881c1c]/10 px-3 py-1.5 rounded-lg transition-all"): + with ui.row().classes( + "items-center gap-1 shrink-0 text-[#881c1c] font-semibold text-sm bg-[#881c1c]/5 hover:bg-[#881c1c]/10 px-3 py-1.5 rounded-lg transition-all" + ): ui.label("Launch") ui.icon("arrow_forward", size="16px") @@ -364,11 +380,15 @@ async def show(self): self.logger.info("AnalysisPicker.show started") with self.container: - with ui.card().classes("w-full max-w-full bg-white border border-slate-200 shadow-md rounded-2xl overflow-hidden p-0"): + with ui.card().classes( + "w-full max-w-full bg-white border border-slate-200 shadow-md rounded-2xl overflow-hidden p-0" + ): with ui.row().classes(Design.PANEL_SHELL_HEADER): with ui.row().classes("items-center gap-2"): ui.icon("analytics", size="sm").classes("text-[#881c1c]") - ui.label("Analysis Mode").classes(Design.PANEL_SHELL_HEADER_TITLE) + ui.label("Analysis Mode").classes( + Design.PANEL_SHELL_HEADER_TITLE + ) with ui.column().classes("p-6 gap-3 w-full bg-slate-50"): ui.label("Select an analysis type:").classes( @@ -378,19 +398,22 @@ async def show(self): analysis_details = { "Surface Scan": { "desc": "Quickly analyze metadata, file headers, and basic structures", - "icon": "radar" + "icon": "radar", }, "Deep Forensic": { "desc": "Comprehensive, byte-level analysis of all partitions and hidden data", - "icon": "biotech" + "icon": "biotech", }, "AI Content Analysis": { "desc": "Leverage machine learning models to detect objects, faces, and transcribe media", - "icon": "psychology" - } + "icon": "psychology", + }, } for a_type in options: - details = analysis_details.get(a_type, {"desc": "Run automated analysis", "icon": "analytics"}) + details = analysis_details.get( + a_type, + {"desc": "Run automated analysis", "icon": "analytics"}, + ) self.logger.info(f"Adding analysis option: {a_type}") row = ui.row().classes( "w-full min-w-0 py-4 px-5 rounded-xl border border-slate-200 bg-white shadow-sm " @@ -407,8 +430,10 @@ async def show(self): with ui.element("div").classes( "w-12 h-12 rounded-xl bg-[#881c1c]/5 flex items-center justify-center shrink-0 border border-[#881c1c]/10" ): - ui.icon(details["icon"], size="24px").classes("text-[#881c1c]") - + ui.icon(details["icon"], size="24px").classes( + "text-[#881c1c]" + ) + # Text column with ui.column().classes("flex-1 min-w-0 gap-0.5"): ui.label(a_type).classes( @@ -417,9 +442,11 @@ async def show(self): ui.label(details["desc"]).classes( "text-sm sm:text-base text-slate-500 whitespace-normal break-words leading-relaxed" ) - + # Right side: Launch action indicator - with ui.row().classes("items-center gap-1 shrink-0 text-[#881c1c] font-semibold text-sm bg-[#881c1c]/5 hover:bg-[#881c1c]/10 px-3 py-1.5 rounded-lg transition-all"): + with ui.row().classes( + "items-center gap-1 shrink-0 text-[#881c1c] font-semibold text-sm bg-[#881c1c]/5 hover:bg-[#881c1c]/10 px-3 py-1.5 rounded-lg transition-all" + ): ui.label("Analyze") ui.icon("arrow_forward", size="16px") self.logger.info("AnalysisPicker.show finished building UI.") @@ -446,9 +473,13 @@ async def show_case_notes_dialog() -> Optional[str]: with ui.row().classes(Design.PANEL_SHELL_HEADER): with ui.row().classes("items-center gap-2"): ui.icon("rate_review", size="sm").classes("text-[#881c1c]") - ui.label("Job Submission Details").classes(Design.PANEL_SHELL_HEADER_TITLE) + ui.label("Job Submission Details").classes( + Design.PANEL_SHELL_HEADER_TITLE + ) ui.button( - icon="close", color=None, on_click=lambda: (future.set_result(None), dialog.close()) + icon="close", + color=None, + on_click=lambda: (future.set_result(None), dialog.close()), ).props("flat round dense").classes(Design.PANEL_SHELL_HEADER_ICON) with ui.column().classes(Design.PANEL_SHELL_BODY + " gap-4"): diff --git a/frontend/pages/chatbot/ui.py b/frontend/pages/chatbot/ui.py index 62f3eda8..2da46a0f 100644 --- a/frontend/pages/chatbot/ui.py +++ b/frontend/pages/chatbot/ui.py @@ -12,6 +12,7 @@ from frontend.design_tokens import Design from frontend.pages.chatbot.state import ChatbotStateManager, ChatMessage from frontend.utils import ( + app, get_conversation_to_load, handle_api_error as _handle_api_error, show_error_to_user as _show_error_to_user, @@ -131,9 +132,20 @@ def render_message(container: element, message: ChatMessage): """Render a message in the chat container.""" with container: if message.role == "user": - chat_message(message.content, name="You", sent=True, bg_color="blue-grey-1", text_color="dark") + chat_message( + message.content, + name="You", + sent=True, + bg_color="blue-grey-1", + text_color="dark", + ) else: - chat_message(message.content, name="RescueBox Assistant", bg_color="primary", text_color="white") + chat_message( + message.content, + name="RescueBox Assistant", + bg_color="primary", + text_color="white", + ) def _history_record_to_chat_message(msg: Any) -> ChatMessage: @@ -193,7 +205,9 @@ def render_merged_job_tool_results( "w-full max-w-3xl border border-slate-200 rounded-2xl p-5 bg-slate-50 " "shadow-sm space-y-2 border-l-4 border-l-[#881c1c]" ): - label("Assistant").classes("text-sm font-semibold text-slate-500 uppercase tracking-wider") + label("Assistant").classes( + "text-sm font-semibold text-slate-500 uppercase tracking-wider" + ) label((getattr(started_msg, "content", "") or "").strip()).classes( "text-base text-slate-800 whitespace-pre-wrap break-words font-medium" ) @@ -225,7 +239,10 @@ def _open_job() -> None: navigate.to(f"/jobs/{jid}") button( - "Open job details", icon="open_in_new", color=None, on_click=_open_job + "Open job details", + icon="open_in_new", + color=None, + on_click=_open_job, ).classes(f"mt-1 {Design.BTN_MEDIUM_GRAY}") @@ -277,7 +294,10 @@ def _open_job() -> None: navigate.to(f"/jobs/{jid}") button( - "Open job details", icon="open_in_new", color=None, on_click=_open_job + "Open job details", + icon="open_in_new", + color=None, + on_click=_open_job, ).classes(f"mt-1 {Design.BTN_MEDIUM_GRAY}") return @@ -287,7 +307,9 @@ def _open_job() -> None: "w-full max-w-3xl border border-slate-200 rounded-2xl p-5 " "bg-amber-50/80 space-y-2 border-l-4 border-l-[#881c1c]" ): - label("Tool call").classes("text-sm font-semibold text-[#881c1c] uppercase tracking-wider") + label("Tool call").classes( + "text-sm font-semibold text-[#881c1c] uppercase tracking-wider" + ) tcalls = getattr(msg, "tool_calls", None) or [] if tcalls: code(json.dumps(tcalls, indent=2, default=str)).classes( @@ -302,9 +324,9 @@ def _open_job() -> None: async def _do_rerun(mid: str = message_id) -> None: await rerun_tool_call(mid) - button("Re-run Job", icon="replay", color=None, on_click=_do_rerun).classes( - f"mt-1 {Design.BTN_MEDIUM_GRAY}" - ) + button( + "Re-run Job", icon="replay", color=None, on_click=_do_rerun + ).classes(f"mt-1 {Design.BTN_MEDIUM_GRAY}") return if mt == "error": @@ -318,9 +340,20 @@ async def _do_rerun(mid: str = message_id) -> None: with container: if role == "user": - chat_message(content, name="You", sent=True, bg_color="blue-grey-1", text_color="dark") + chat_message( + content, + name="You", + sent=True, + bg_color="blue-grey-1", + text_color="dark", + ) else: - chat_message(content, name="RescueBox Assistant", bg_color="primary", text_color="white") + chat_message( + content, + name="RescueBox Assistant", + bg_color="primary", + text_color="white", + ) def show_error_message(container: element, message: str): @@ -374,16 +407,19 @@ async def load_and_show_form( if pipeline_job_id: try: from frontend.database import get_job_db + job = get_job_db().get_job_by_uid_sync(pipeline_job_id) if job and job.response: from frontend.chatbot.multi_tool_handler import extract_output_path from rb.api.models import ResponseBody + response_body = job.response if not isinstance(response_body, ResponseBody): response_body = ResponseBody(**response_body) output_path = extract_output_path(response_body) if output_path: from rb.api.models import InputType + input_dir_key = None for input_schema in task_schema.inputs: if input_schema.input_type == InputType.DIRECTORY: @@ -399,7 +435,11 @@ async def load_and_show_form( if input_dir_key: arguments = arguments.copy() if arguments else {} arguments[input_dir_key] = output_path - logger.info("Pipelining: injected output path '%s' into '%s'", output_path, input_dir_key) + logger.info( + "Pipelining: injected output path '%s' into '%s'", + output_path, + input_dir_key, + ) except Exception as e: logger.error("Error auto-injecting pipeline path: %s", e) @@ -499,7 +539,6 @@ def __init__( def build_ui(self): from frontend.components.chat import ( - create_chat_header, create_chat_window, create_input_area, ) @@ -519,6 +558,7 @@ def build_ui(self): ): # Page Header (Matches Jobs, Logs, Models pages) from frontend.constants import UI_TITLES + with row().classes("items-center gap-2 mb-6"): icon("forum", size="lg").classes("text-[#881c1c]") label(UI_TITLES.get("chatbot", "RescueBox Assistant")).classes( @@ -527,12 +567,16 @@ def build_ui(self): if pipeline_job_id: from frontend.database import get_job_db + job = get_job_db().get_job_by_uid_sync(pipeline_job_id) if job: endpoint = job.endpoint or "Unknown" pname = job.plugin_name or endpoint - from frontend.chatbot.multi_tool_handler import extract_output_path + from frontend.chatbot.multi_tool_handler import ( + extract_output_path, + ) from rb.api.models import ResponseBody + response_body = job.response if response_body: if not isinstance(response_body, ResponseBody): @@ -541,50 +585,75 @@ def build_ui(self): else: output_path = "N/A" - with row().classes("w-full bg-rose-50 border border-rose-200 p-3 rounded-xl items-center justify-between mb-4 shadow-sm"): + with row().classes( + "w-full bg-rose-50 border border-rose-200 p-3 rounded-xl items-center justify-between mb-4 shadow-sm" + ): with row().classes("items-center gap-2"): icon("link").classes("text-[#881c1c]") with column().classes("gap-0.5"): - label(f"Pipelining from Job {pipeline_job_id} ({pname})").classes("font-bold text-rose-900 text-sm") - label(f"Output Path: {output_path}").classes("font-mono text-xs text-rose-700") - + label( + f"Pipelining from Job {pipeline_job_id} ({pname})" + ).classes("font-bold text-rose-900 text-sm") + label(f"Output Path: {output_path}").classes( + "font-mono text-xs text-rose-700" + ) + def _clear_pipeline(): app.storage.user.pop("pipeline_job_id", None) ui.notify("Pipeline cleared.", type="info") ui.timer(0.1, lambda: ui.navigate.reload(), once=True) - button("Clear Pipeline", on_click=_clear_pipeline).classes("bg-red-50 hover:bg-red-100 text-[#881c1c] px-3 py-1 rounded text-xs transition-colors") + button("Clear Pipeline", on_click=_clear_pipeline).classes( + "bg-red-50 hover:bg-red-100 text-[#881c1c] px-3 py-1 rounded text-xs transition-colors" + ) with card().classes(Design.PANEL_SHELL_CHAT_CARD): with row().classes(Design.PANEL_SHELL_HEADER): with row().classes("items-center gap-3"): - icon("settings_suggest", size="sm").classes("text-[#881c1c]") + icon("settings_suggest", size="sm").classes( + "text-[#881c1c]" + ) label("Active Mode:").classes( "text-base font-bold text-slate-700" ) - self.mode_indicator = badge("Chat mode", color=None).classes( + self.mode_indicator = badge( + "Chat mode", color=None + ).classes( "text-sm font-semibold rb-chat-mode-badge px-3 py-1 rounded-full" ) with row().classes("items-center gap-2"): self.analyze_btn = ( button("Chat", icon="chat", color=None) - .classes("rb-chatbot-tab-btn px-4 py-2 rounded-lg font-semibold transition-all text-base") + .classes( + "rb-chatbot-tab-btn px-4 py-2 rounded-lg font-semibold transition-all text-base" + ) .props("unelevated no-caps") ) self.models_btn = ( button("Menu", icon="menu", color=None) - .classes("rb-chatbot-tab-btn px-4 py-2 rounded-lg font-semibold transition-all text-base") + .classes( + "rb-chatbot-tab-btn px-4 py-2 rounded-lg font-semibold transition-all text-base" + ) .props("unelevated no-caps") ) self.history_btn = ( - button("History", icon="history", color=None, on_click=self._show_history_dialog) - .classes("rb-chatbot-tab-btn px-4 py-2 rounded-lg font-semibold transition-all text-base") + button( + "History", + icon="history", + color=None, + on_click=self._show_history_dialog, + ) + .classes( + "rb-chatbot-tab-btn px-4 py-2 rounded-lg font-semibold transition-all text-base" + ) .props("unelevated no-caps") ) chat_container = create_chat_window() - self.input_area = create_input_area(self.status_text_ref, self.on_send) + self.input_area = create_input_area( + self.status_text_ref, self.on_send + ) self.input_field = self.input_area.input_field below_input_area = column().classes( @@ -611,11 +680,11 @@ async def handle_models_click(): self.models_btn.classes("rb-tab-active") self.analyze_btn.classes(remove="rb-tab-active") chat_container.clear() - + # Hide the chat input area completely in Menu Mode if hasattr(self, "input_area") and self.input_area: self.input_area.classes("hidden") - + await asyncio.sleep(0.01) # Give NiceGUI a moment from .handlers import ToolPicker @@ -629,13 +698,13 @@ async def handle_analyze_click(): self.analyze_btn.classes("rb-tab-active") self.models_btn.classes(remove="rb-tab-active") chat_container.clear() - + # Show and enable the chat input area in Chat Mode if hasattr(self, "input_area") and self.input_area: self.input_area.classes(remove="hidden") if self.state_manager: self.state_manager.set_input_enabled(True) - + from frontend.components.chat import render_welcome_message render_welcome_message(chat_container) diff --git a/frontend/pages/demo.py b/frontend/pages/demo.py index dff30cd8..9a7d64c3 100644 --- a/frontend/pages/demo.py +++ b/frontend/pages/demo.py @@ -52,12 +52,16 @@ async def demo_page(walkthrough: Optional[str] = None): preset = normalize_demo_walkthrough_query(walkthrough) samples_only = preset != "all" - with ui.column().classes("container mx-auto px-4 sm:px-8 py-8 w-full max-w-6xl pb-16"): + with ui.column().classes( + "container mx-auto px-4 sm:px-8 py-8 w-full max-w-6xl pb-16" + ): if samples_only: with ui.column().props("id=sample-inputs").classes("scroll-mt-24 w-full"): with ui.row().classes("items-center gap-2 mb-1"): ui.icon("folder_zip", size="sm").classes("text-[#881c1c]") - ui.label("Sample inputs & outputs").classes("text-2xl font-bold text-slate-800") + ui.label("Sample inputs & outputs").classes( + "text-2xl font-bold text-slate-800" + ) if preset in _SAMPLE_FILTER_BLURB: ui.label(_SAMPLE_FILTER_BLURB[preset]).classes( "text-zinc-600 text-sm mb-3" diff --git a/frontend/pages/demo_image_summary_walkthrough.py b/frontend/pages/demo_image_summary_walkthrough.py index ede6493d..68d0e8ca 100644 --- a/frontend/pages/demo_image_summary_walkthrough.py +++ b/frontend/pages/demo_image_summary_walkthrough.py @@ -42,7 +42,9 @@ async def demo_image_search_walkthrough_page(): text = load_markdown_file(_MD_FILE, _fallback_markdown) - with ui.column().classes("container mx-auto px-4 sm:px-8 py-8 w-full max-w-4xl pb-16"): + with ui.column().classes( + "container mx-auto px-4 sm:px-8 py-8 w-full max-w-4xl pb-16" + ): with ui.row().classes("items-center gap-2 mb-2"): ui.icon("image", size="lg").classes("text-[#881c1c]") ui.label("Search Image — Assistant prompt walkthrough").classes( @@ -58,7 +60,9 @@ async def demo_image_search_walkthrough_page(): "Back to Demo", icon="arrow_back", on_click=lambda: ui.navigate.to(NAV_LINKS["demo"]), - ).classes("bg-slate-100 hover:bg-slate-200 text-slate-800 px-4 py-2 rounded-lg font-medium transition-colors border border-slate-200") + ).classes( + "bg-slate-100 hover:bg-slate-200 text-slate-800 px-4 py-2 rounded-lg font-medium transition-colors border border-slate-200" + ) # ui.link("Open Assistant", NAV_LINKS["chatbot"]).classes("text-[#881c1c] hover:underline") # ui.link(UI_TITLES["jobs"], NAV_LINKS["jobs"]).classes("text-[#881c1c] hover:underline") diff --git a/frontend/pages/demo_other_walkthrough.py b/frontend/pages/demo_other_walkthrough.py index 2ec9d355..3d5d8a24 100644 --- a/frontend/pages/demo_other_walkthrough.py +++ b/frontend/pages/demo_other_walkthrough.py @@ -42,7 +42,9 @@ async def demo_other_walkthrough_page(): text = load_markdown_file(_MD_FILE, _fallback_markdown) - with ui.column().classes("container mx-auto px-4 sm:px-8 py-8 w-full max-w-4xl pb-16"): + with ui.column().classes( + "container mx-auto px-4 sm:px-8 py-8 w-full max-w-4xl pb-16" + ): with ui.row().classes("items-center gap-2 mb-2"): ui.icon("extension", size="lg").classes("text-[#881c1c]") ui.label("Interesting plugins & pipeline walkthrough").classes( @@ -58,7 +60,9 @@ async def demo_other_walkthrough_page(): "Back to Demo", icon="arrow_back", on_click=lambda: ui.navigate.to(NAV_LINKS["demo"]), - ).classes("bg-slate-100 hover:bg-slate-200 text-slate-800 px-4 py-2 rounded-lg font-medium transition-colors border border-slate-200") + ).classes( + "bg-slate-100 hover:bg-slate-200 text-slate-800 px-4 py-2 rounded-lg font-medium transition-colors border border-slate-200" + ) ui.link("Open Assistant", NAV_LINKS["chatbot"]).classes( "text-[#881c1c] hover:underline font-medium" diff --git a/frontend/pages/demo_quick_start.py b/frontend/pages/demo_quick_start.py index 94888015..65b97c4b 100644 --- a/frontend/pages/demo_quick_start.py +++ b/frontend/pages/demo_quick_start.py @@ -43,10 +43,14 @@ async def demo_quick_start_page(): text = load_markdown_file(_QUICK_START_MD, _fallback_markdown) - with ui.column().classes("container mx-auto px-4 sm:px-8 py-8 w-full max-w-4xl pb-16"): + with ui.column().classes( + "container mx-auto px-4 sm:px-8 py-8 w-full max-w-4xl pb-16" + ): with ui.row().classes("items-center gap-2 mb-2"): ui.icon("rocket_launch", size="lg").classes("text-[#881c1c]") - ui.label("RescueBox quick start").classes("text-4xl font-bold text-slate-800") + ui.label("RescueBox quick start").classes( + "text-4xl font-bold text-slate-800" + ) render_guided_markdown_body(ui.column().classes("w-full min-w-0"), text) @@ -57,7 +61,9 @@ async def demo_quick_start_page(): "Back to Demo", icon="arrow_back", on_click=lambda: ui.navigate.to(NAV_LINKS["demo"]), - ).classes("bg-slate-100 hover:bg-slate-200 text-slate-800 px-4 py-2 rounded-lg font-medium transition-colors border border-slate-200") + ).classes( + "bg-slate-100 hover:bg-slate-200 text-slate-800 px-4 py-2 rounded-lg font-medium transition-colors border border-slate-200" + ) ui.link("Demo samples", demo_samples_url("quick_start")).classes( "text-[#881c1c] hover:underline text-sm font-medium" diff --git a/frontend/pages/demo_transcribe_walkthrough.py b/frontend/pages/demo_transcribe_walkthrough.py index e5645c82..1ee32ff9 100644 --- a/frontend/pages/demo_transcribe_walkthrough.py +++ b/frontend/pages/demo_transcribe_walkthrough.py @@ -42,10 +42,14 @@ async def demo_transcribe_walkthrough_page(): text = load_markdown_file(_MD_FILE, _fallback_markdown) - with ui.column().classes("container mx-auto px-4 sm:px-8 py-8 w-full max-w-4xl pb-16"): + with ui.column().classes( + "container mx-auto px-4 sm:px-8 py-8 w-full max-w-4xl pb-16" + ): with ui.row().classes("items-center gap-2 mb-2"): ui.icon("audiotrack", size="lg").classes("text-[#881c1c]") - ui.label("Transcribe — menu walkthrough").classes("text-4xl font-bold text-slate-800") + ui.label("Transcribe — menu walkthrough").classes( + "text-4xl font-bold text-slate-800" + ) render_guided_markdown_body(ui.column().classes("w-full min-w-0"), text) @@ -56,7 +60,9 @@ async def demo_transcribe_walkthrough_page(): "Back to Demo", icon="arrow_back", on_click=lambda: ui.navigate.to(NAV_LINKS["demo"]), - ).classes("bg-slate-100 hover:bg-slate-200 text-slate-800 px-4 py-2 rounded-lg font-medium transition-colors border border-slate-200") + ).classes( + "bg-slate-100 hover:bg-slate-200 text-slate-800 px-4 py-2 rounded-lg font-medium transition-colors border border-slate-200" + ) schedule_hash_fragment_scroll() logger.debug("Transcribe walkthrough page rendered") diff --git a/frontend/pages/jobs/components.py b/frontend/pages/jobs/components.py index 016c5651..b6381d82 100644 --- a/frontend/pages/jobs/components.py +++ b/frontend/pages/jobs/components.py @@ -32,7 +32,12 @@ async def export_audit(): logger.error("Error exporting audit trail: %s", e) notify_error(f"Error exporting audit trail: {str(e)}") - return ui.button("Export Audit Trail", icon="assignment_turned_in", color=None, on_click=export_audit).classes( + return ui.button( + "Export Audit Trail", + icon="assignment_turned_in", + color=None, + on_click=export_audit, + ).classes( "bg-slate-100 hover:bg-slate-200 text-slate-800 px-4 py-2 rounded-lg font-medium transition-colors border border-slate-200" ) @@ -49,7 +54,7 @@ def render_job_action_buttons(job_fields: Dict[str, Any]): ui.button( "Run Model", color=None, - on_click=lambda: ui.navigate.to(f"/models/{model_uid}/run") + on_click=lambda: ui.navigate.to(f"/models/{model_uid}/run"), ).classes("rb-brand-primary text-white rounded-xl") @@ -76,13 +81,17 @@ def render_readonly_form(task_schema, request_body): def render_error_status(status: str, status_text: Optional[str] = None): - with ui.card().classes("bg-rose-50 border border-rose-200 p-6 rounded-2xl shadow-sm border-t-4 border-t-rose-500"): + with ui.card().classes( + "bg-rose-50 border border-rose-200 p-6 rounded-2xl shadow-sm border-t-4 border-t-rose-500" + ): with ui.row().classes("items-center gap-2 mb-2"): ui.icon("error", size="md").classes("text-rose-600") ui.label("Job Failed").classes("text-2xl font-bold text-rose-800") ui.label(f"Status: {status}").classes("text-lg text-rose-700 font-medium") if status_text: - ui.label(status_text).classes("text-sm text-rose-600 mt-2 bg-white/50 p-3 rounded-lg border border-rose-100 whitespace-pre-wrap") + ui.label(status_text).classes( + "text-sm text-rose-600 mt-2 bg-white/50 p-3 rounded-lg border border-rose-100 whitespace-pre-wrap" + ) async def render_model_info(api_client, job_fields: Dict[str, Any]): diff --git a/frontend/pages/jobs/details.py b/frontend/pages/jobs/details.py index 784de234..f9158d72 100644 --- a/frontend/pages/jobs/details.py +++ b/frontend/pages/jobs/details.py @@ -52,13 +52,15 @@ async def job_details_page_route(job_id: str): return jf = extract_job_fields(job) - + # Auto-refresh if the job is running or pending status = str(jf.get("status", "")).lower() if status in ("running", "pending"): ui.timer(3.0, lambda: ui.navigate.reload(), once=True) - with ui.column().classes("container mx-auto px-4 sm:px-8 py-8 w-full max-w-6xl pb-16 items-stretch"): + with ui.column().classes( + "container mx-auto px-4 sm:px-8 py-8 w-full max-w-6xl pb-16 items-stretch" + ): create_breadcrumbs( [{"label": "Jobs", "link": "/jobs"}, {"label": f"Job {job_id}"}] ) diff --git a/frontend/pages/jobs/list.py b/frontend/pages/jobs/list.py index 9f7df23f..3b77d89f 100644 --- a/frontend/pages/jobs/list.py +++ b/frontend/pages/jobs/list.py @@ -29,7 +29,9 @@ def __init__(self): self.jobs = [] async def render(self): - with ui.column().classes("container mx-auto px-4 sm:px-8 py-8 w-full max-w-6xl pb-16"): + with ui.column().classes( + "container mx-auto px-4 sm:px-8 py-8 w-full max-w-6xl pb-16" + ): with ui.row().classes("items-center gap-2 mb-6"): ui.icon("view_list", size="lg").classes("text-[#881c1c]") ui.label(UI_TITLES["jobs"]).classes("text-4xl font-bold text-slate-800") @@ -118,10 +120,10 @@ async def jobs_page_route(): return apply_saved_theme() create_navbar() - + page = JobsPage() await page.render() - + # Auto-refresh if there are any running or pending jobs in the list has_active_jobs = any( str(job.get("status", "")).lower() in ("running", "pending") diff --git a/frontend/pages/logs.py b/frontend/pages/logs.py index 9a8acbc9..b4a9f965 100644 --- a/frontend/pages/logs.py +++ b/frontend/pages/logs.py @@ -63,7 +63,7 @@ async def render(self): async def _load_logs(self): """Load and display log file contents. Reads the log file, limits to max_lines, and displays in the UI.""" self.log_content = read_log_file(LOG_FILE, self.max_lines) - + # Cache raw content in log_display and apply search filter if available if hasattr(self, "log_display") and self.log_display is not None: self.log_display.raw_content = self.log_content diff --git a/frontend/pages/models.py b/frontend/pages/models.py index 7fc63d40..3c3fee40 100644 --- a/frontend/pages/models.py +++ b/frontend/pages/models.py @@ -56,7 +56,9 @@ async def render(self): """Render the models page UI. Creates the page layout with header, refresh button, loading indicator,""" logger.debug("Rendering models page") try: - with ui.column().classes("container mx-auto px-4 sm:px-8 py-8 w-full max-w-6xl pb-16"): + with ui.column().classes( + "container mx-auto px-4 sm:px-8 py-8 w-full max-w-6xl pb-16" + ): # Header logger.debug("Creating page header") try: @@ -309,7 +311,9 @@ async def model_details_page(model_uid: str): ) return - with ui.column().classes("container mx-auto px-4 sm:px-8 py-8 w-full max-w-6xl pb-16"): + with ui.column().classes( + "container mx-auto px-4 sm:px-8 py-8 w-full max-w-6xl pb-16" + ): # Two-column layout with ui.row().classes("gap-6 w-full"): # Left column - Documentation diff --git a/frontend/tests/unit/test_job_background_submission.py b/frontend/tests/unit/test_job_background_submission.py index 930a9677..80410b85 100644 --- a/frontend/tests/unit/test_job_background_submission.py +++ b/frontend/tests/unit/test_job_background_submission.py @@ -139,20 +139,20 @@ def fake_create_store_coro(coroutine, name=None, handle_exceptions=False): core = MagicMock() core.config = MagicMock() core.config.RESCUEBOX_HOST = "http://localhost" + core.submit_job = AsyncMock(side_effect=Exception("Simulated API failure")) with patch( - "frontend.pages.chatbot.api_helpers.post_job", new_callable=AsyncMock - ) as mock_post, patch( "frontend.pages.chatbot.DatabaseService.create_and_track_job", new_callable=AsyncMock, return_value={"job_id": "job1"}, ), patch( "frontend.pages.chatbot.DatabaseService.save_user_prompt_if_missing_from_form_submission", new_callable=AsyncMock, + ), patch( + "frontend.pages.chatbot.DatabaseService.update_job_status", + new_callable=AsyncMock, ): - mock_post.side_effect = Exception("Simulated API failure") - request_body = MagicMock() request_body.inputs = {} request_body.parameters = {} diff --git a/frontend/utils/storage.py b/frontend/utils/storage.py index 165971b8..3d1d786d 100644 --- a/frontend/utils/storage.py +++ b/frontend/utils/storage.py @@ -136,9 +136,11 @@ def get_active_case() -> Optional[Any]: return None try: from frontend.database.case_db import get_case_db + case = get_case_db().get_case_by_id_sync(case_id) if not case and _runs_under_pytest(): from frontend.database.case_db import CaseRecord + return CaseRecord( caseId=case_id, caseNumber="TEST-CASE", diff --git a/frontend/utils/ui.py b/frontend/utils/ui.py index 58a72c23..f5101213 100644 --- a/frontend/utils/ui.py +++ b/frontend/utils/ui.py @@ -139,7 +139,9 @@ def require_demo_user_session(): if get_user_id_for_jobs(): return True - with ui.column().classes("container mx-auto px-4 sm:px-8 py-8 max-w-2xl w-full pb-16"): + with ui.column().classes( + "container mx-auto px-4 sm:px-8 py-8 max-w-2xl w-full pb-16" + ): ui.label(HOME_USER_ID["title"]).classes("text-2xl font-semibold mb-2") ui.label(HOME_USER_ID["blurb"]).classes("text-zinc-600 mb-4") ui.link("Go to Home", NAV_LINKS["home"]).classes( From 56a8e43ca43a2f537b3cdd7ead9a9f209b7b8ea8 Mon Sep 17 00:00:00 2001 From: jaik950 Date: Sat, 6 Jun 2026 09:47:01 -0400 Subject: [PATCH 11/11] black format --- frontend/pages/chatbot/handlers.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/frontend/pages/chatbot/handlers.py b/frontend/pages/chatbot/handlers.py index 895ac5d6..f27fc205 100644 --- a/frontend/pages/chatbot/handlers.py +++ b/frontend/pages/chatbot/handlers.py @@ -98,9 +98,7 @@ async def _execute_job( if job_id: # Redirect immediately to the general jobs view so the user can see the list of jobs - _safe_ui_call( - ui.timer, 0.1, lambda: ui.navigate.to("/jobs"), once=True - ) + _safe_ui_call(ui.timer, 0.1, lambda: ui.navigate.to("/jobs"), once=True) async def do_submit(): try: