Local run viewer: email reports, run history, and the platform suite (#813)

This commit is contained in:
yoni-at-strix
2026-07-21 08:13:03 -07:00
committed by GitHub
parent 6a3e0597ce
commit f600f99103
93 changed files with 17164 additions and 1482 deletions
+10
View File
@@ -67,6 +67,16 @@ Toast.-information .toast--title {
display: none;
}
#viewer_cta {
height: auto;
background: transparent;
border: round #333333;
color: #60a5fa;
padding: 0 1;
margin-bottom: 1;
text-align: center;
}
#agents_tree {
height: 1fr;
background: transparent;
+69 -2
View File
@@ -7,6 +7,7 @@ import argparse
import asyncio
import shutil
import sys
import time
from datetime import UTC, datetime
from pathlib import Path
@@ -720,7 +721,9 @@ def _load_resume_state(args: argparse.Namespace, parser: argparse.ArgumentParser
args.scan_mode = persisted_scan_mode
def display_completion_message(args: argparse.Namespace, results_path: Path) -> None:
def display_completion_message(
args: argparse.Namespace, results_path: Path, web_url: str | None = None
) -> None:
console = Console()
report_state = get_global_report_state()
@@ -759,6 +762,29 @@ def display_completion_message(args: argparse.Namespace, results_path: Path) ->
results_text.append(str(results_path), style="#60a5fa")
panel_parts.extend(["\n", results_text])
if web_url:
web_text = Text()
web_text.append("\n")
web_text.append("View in web", style="dim")
web_text.append(" ")
# OSC-8 hyperlink: clickable in modern terminals, falls back to the URL.
web_text.append(web_url, style=f"#60a5fa link {web_url}")
panel_parts.extend(["\n", web_text])
reopen_text = Text()
reopen_text.append("\n")
reopen_text.append("Reopen", style="dim")
reopen_text.append(" ")
reopen_text.append(f"strix view {args.run_name}", style="#22c55e")
panel_parts.extend(["\n", reopen_text])
else:
view_text = Text()
view_text.append("\n")
view_text.append("View", style="dim")
view_text.append(" ")
view_text.append(f"strix view {args.run_name}", style="#22c55e")
panel_parts.extend(["\n", view_text])
if not scan_completed:
resume_text = Text()
resume_text.append("\n")
@@ -846,6 +872,14 @@ def main() -> None:
if sys.platform == "win32":
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
# `strix view [<run>]` is a viewer-only subcommand, dispatched before the
# scan argument parser (which requires a target) and before any scan setup.
if len(sys.argv) > 1 and sys.argv[1] == "view":
from strix.viewer.cli import run_view
run_view(sys.argv[2:])
return
args = parse_arguments()
if args.config:
@@ -940,7 +974,40 @@ def main() -> None:
scarf.end(report_state, exit_reason=exit_reason)
results_path = run_dir_for(args.run_name)
display_completion_message(args, results_path)
# For an interactive run, host the local viewer so the completion panel can
# show a clickable "View in web" link. Skipped in non-interactive/CI runs
# (no TTY to serve and it would block the process).
viewer_httpd = None
web_url = None
if not args.non_interactive and sys.stdout.isatty():
from strix.viewer.server import authorized_url, bundle_is_built, serve
if bundle_is_built():
try:
viewer_httpd, base_url, token = serve(results_path, open_browser=False)
# The completion panel's "View in web" link must authorize the
# browser, so hand it the tokened URL rather than the bare host.
web_url = authorized_url(base_url, token)
posthog.viewer_opened(source="post_scan", live=False)
except Exception:
logger.debug("could not start local viewer", exc_info=True)
viewer_httpd, web_url = None, None
display_completion_message(args, results_path, web_url=web_url)
if viewer_httpd is not None:
console = Console()
console.print("[dim]Hosting the local viewer. Press Ctrl-C to stop.[/]")
console.print()
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
console.print("\n[dim]Viewer stopped.[/]")
finally:
viewer_httpd.shutdown()
viewer_httpd.server_close()
if args.non_interactive:
report_state = get_global_report_state()
+75 -1
View File
@@ -6,6 +6,7 @@ import logging
import signal
import sys
import threading
import webbrowser
from collections.abc import Callable
from importlib.metadata import PackageNotFoundError
from importlib.metadata import version as pkg_version
@@ -768,6 +769,7 @@ class StrixTUIApp(App): # type: ignore[misc]
Binding("ctrl+q", "request_quit", "Quit", priority=True),
Binding("ctrl+c", "request_quit", "Quit", priority=True),
Binding("escape", "stop_selected_agent", "Stop Agent", priority=True),
Binding("ctrl+o", "open_viewer", "Open Viewer", priority=True),
]
def __init__(self, args: argparse.Namespace):
@@ -794,6 +796,8 @@ class StrixTUIApp(App): # type: ignore[misc]
self._displayed_events: list[str] = []
self._scan_thread: threading.Thread | None = None
self._viewer_httpd: Any = None
self._viewer_url: str | None = None
self._scan_loop: asyncio.AbstractEventLoop | None = None
self._scan_stop_event = threading.Event()
self._scan_completed = threading.Event()
@@ -903,7 +907,12 @@ class StrixTUIApp(App): # type: ignore[misc]
vulnerabilities_panel = VulnerabilitiesPanel(id="vulnerabilities_panel")
sidebar = Vertical(agents_tree, vulnerabilities_panel, stats_scroll, id="sidebar")
viewer_cta = Static(self._viewer_cta_markup(), id="viewer_cta")
viewer_cta.ALLOW_SELECT = False
sidebar = Vertical(
viewer_cta, agents_tree, vulnerabilities_panel, stats_scroll, id="sidebar"
)
content_container.mount(chat_area_container)
content_container.mount(sidebar)
@@ -1805,6 +1814,7 @@ class StrixTUIApp(App): # type: ignore[misc]
async def action_custom_quit(self) -> None:
self._fire_sandbox_cleanup()
self._shutdown_viewer()
if self._scan_thread and self._scan_thread.is_alive():
self._scan_stop_event.set()
@@ -1813,6 +1823,70 @@ class StrixTUIApp(App): # type: ignore[misc]
self.exit()
def _viewer_cta_markup(self, url: str | None = None) -> str:
if url:
return f"[@click=app.open_viewer][#22c55e]● Viewer running[/][/]\n[dim]{url}[/]"
return "[@click=app.open_viewer]▶ Watch live in browser[/]"
def _set_viewer_cta(self, markup: str) -> None:
with contextlib.suppress(Exception):
self.query_one("#viewer_cta", Static).update(markup)
def action_open_viewer(self) -> None:
if self._viewer_url:
with contextlib.suppress(Exception):
webbrowser.open(self._viewer_url)
return
try:
from strix.viewer.server import authorized_url, bundle_is_built, serve
if not bundle_is_built():
self._set_viewer_cta("[#eab308]Viewer UI not built[/]")
return
run_dir = self.report_state.get_run_dir()
def _viewer_steer(agent_id: str, message: str) -> bool:
# Reuse the exact TUI delivery path, but target the agent the
# web graph selected (not the TUI's current selection).
return send_user_message_to_agent(
coordinator=self.coordinator,
loop=self._scan_loop,
live_view=self.live_view,
target_agent_id=agent_id,
message=message,
)
httpd, url, token = serve(run_dir, open_browser=True, steer_handler=_viewer_steer)
except Exception:
logger.debug("failed to start local viewer", exc_info=True)
self._set_viewer_cta("[red]Viewer failed to start[/]")
return
self._viewer_httpd = httpd
# Store the tokened URL so reopening the CTA re-authorizes the browser
# (this viewer carries a steer handler, so the session is required).
self._viewer_url = authorized_url(url, token)
self._set_viewer_cta(self._viewer_cta_markup(self._viewer_url))
with contextlib.suppress(Exception):
from strix.telemetry import posthog
live = self.report_state.run_record.get("status") not in {
"completed",
"stopped",
"failed",
"interrupted",
}
posthog.viewer_opened(source="tui", live=live)
def _shutdown_viewer(self) -> None:
httpd = self._viewer_httpd
if httpd is None:
return
self._viewer_httpd = None
with contextlib.suppress(Exception):
httpd.shutdown()
httpd.server_close()
def _fire_sandbox_cleanup(self) -> None:
self.coordinator.mark_shutting_down()
loop = self._scan_loop
+16 -4
View File
@@ -24,14 +24,26 @@ def load_session_history(run_dir: Path, agent_ids: Any) -> list[tuple[str, dict[
if not agents_db.exists() or not session_ids:
return []
session_id_set = set(session_ids)
# Open read-only: the scan process may be actively writing this WAL database
# from another process (the local viewer tails it live), and a reader must
# never lock or mutate it. mode=ro (not immutable=1) still reads the latest
# committed WAL state; WAL permits concurrent readers alongside the writer.
conn: sqlite3.Connection | None = None
try:
with sqlite3.connect(agents_db) as conn:
rows = conn.execute(
"select id, session_id, message_data, created_at from agent_messages order by id"
).fetchall()
conn = sqlite3.connect(
f"file:{agents_db}?mode=ro",
uri=True,
check_same_thread=False,
)
rows = conn.execute(
"select id, session_id, message_data, created_at from agent_messages order by id"
).fetchall()
except sqlite3.Error:
logger.exception("Failed to hydrate TUI history from %s", agents_db)
return []
finally:
if conn is not None:
conn.close()
items: list[tuple[str, dict[str, Any], str]] = []
for row_id, agent_id, message_data, created_at in rows: