mirror of
https://github.com/usestrix/strix.git
synced 2026-11-09 18:08:59 -10:00
Compare commits
47
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4c7555c24 | ||
|
|
e246745e4e | ||
|
|
9ba8e0f219 | ||
|
|
68f1d19504 | ||
|
|
dd1dab5d95 | ||
|
|
09ff9aa783 | ||
|
|
47ded9b93c | ||
|
|
01c02d6d97 | ||
|
|
4465617134 | ||
|
|
8f4ca2aedc | ||
|
|
de1c36d7e6 | ||
|
|
8264eee2f5 | ||
|
|
2351bf9323 | ||
|
|
10d6136352 | ||
|
|
8b98457041 | ||
|
|
4505ef451c | ||
|
|
05e8848332 | ||
|
|
f3a956f5df | ||
|
|
ea0c107559 | ||
|
|
cfe1073650 | ||
|
|
2a85e27dc0 | ||
|
|
f91a831761 | ||
|
|
5600650e26 | ||
|
|
9d4033d950 | ||
|
|
d73f319be5 | ||
|
|
2cd953b54e | ||
|
|
7710a970b2 | ||
|
|
54e9c0239b | ||
|
|
dfc672c947 | ||
|
|
e32bfcde4a | ||
|
|
a12b9c634c | ||
|
|
4c337f93ba | ||
|
|
8d9f785dc7 | ||
|
|
5dee36d30a | ||
|
|
f9a966df59 | ||
|
|
391df1b38c | ||
|
|
f7cfd0d47d | ||
|
|
98c2e0be4e | ||
|
|
da9c606c67 | ||
|
|
11c7eef082 | ||
|
|
8bbcafcf1a | ||
|
|
1e4db0098b | ||
|
|
1ce37d7b12 | ||
|
|
b41abc9e58 | ||
|
|
9a9b5cc1c2 | ||
|
|
db29c77b30 | ||
|
|
46e4b16167 |
No files matched your search
+12
-4
@@ -1,17 +1,25 @@
|
||||
# Node / local-viewer SPA source (the built bundle in
|
||||
# strix/viewer/viewer_dist/ is committed and shipped; do not ignore it)
|
||||
node_modules/
|
||||
strix/viewer_src/node_modules/
|
||||
strix/viewer_src/.vite/
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
# Anchored to the repo root: these are Python build-artifact dir names, but
|
||||
# unanchored they also match nested source dirs (e.g. the viewer's src/lib).
|
||||
/build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
/lib/
|
||||
/lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
@@ -46,7 +54,7 @@ pip-delete-this-directory.txt
|
||||
.env.production.local
|
||||
|
||||
# MongoDB
|
||||
data/
|
||||
/data/
|
||||
mongod.log
|
||||
*.mongodb
|
||||
*.mongorc.js
|
||||
|
||||
@@ -99,6 +99,20 @@ We welcome feature ideas! Please:
|
||||
- Consider implementation approach
|
||||
- Be open to discussion
|
||||
|
||||
## 🖥️ Local viewer SPA
|
||||
|
||||
`strix view` serves a prebuilt web UI whose source lives in `strix/viewer_src/`
|
||||
(a Vite + React project) and whose built output is committed to
|
||||
`strix/viewer/viewer_dist/` and shipped in the package. End users never run a
|
||||
JS build. If you change anything under `strix/viewer_src/`, rebuild and commit
|
||||
the output:
|
||||
|
||||
```bash
|
||||
make viewer # or: cd strix/viewer_src && npm ci && npm run build
|
||||
```
|
||||
|
||||
Commit both the source change and the regenerated `strix/viewer/viewer_dist/`.
|
||||
|
||||
## 🤝 Community
|
||||
|
||||
- **Discord**: [Join our community](https://discord.gg/strix-ai)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.PHONY: help install dev-install format lint type-check security check-all clean pre-commit setup-dev dev
|
||||
.PHONY: help install dev-install format lint type-check security check-all clean pre-commit setup-dev dev viewer
|
||||
|
||||
help:
|
||||
@echo "Available commands:"
|
||||
@@ -15,6 +15,7 @@ help:
|
||||
@echo ""
|
||||
@echo "Development:"
|
||||
@echo " pre-commit - Run pre-commit hooks on all files"
|
||||
@echo " viewer - Rebuild the local-viewer SPA (commit the output)"
|
||||
@echo " clean - Clean up cache files and artifacts"
|
||||
|
||||
install:
|
||||
@@ -66,5 +67,10 @@ clean:
|
||||
find . -name "*.pyc" -delete 2>/dev/null || true
|
||||
@echo "✅ Cleanup complete!"
|
||||
|
||||
viewer:
|
||||
@echo "🖥️ Building the local-viewer SPA..."
|
||||
cd strix/viewer_src && npm ci && npm run build
|
||||
@echo "✅ Viewer built to strix/viewer/viewer_dist/ (commit the changes)."
|
||||
|
||||
dev: format lint type-check
|
||||
@echo "✅ Development cycle complete!"
|
||||
@@ -44,6 +44,9 @@ dependencies = [
|
||||
"requests>=2.32.0",
|
||||
"cvss>=3.2",
|
||||
"caido-sdk-client>=0.2.0",
|
||||
"reportlab>=4.0",
|
||||
"pypdf>=5.0",
|
||||
"cryptography>=42",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
@@ -74,6 +77,10 @@ build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["strix"]
|
||||
# The prebuilt viewer bundle under strix/viewer/viewer_dist/ ships automatically
|
||||
# (hatchling includes non-.py files under the package). The Vite SOURCE lives
|
||||
# under the package dir too but must never ship in the wheel.
|
||||
exclude = ["strix/viewer_src", "strix/viewer_src/**"]
|
||||
|
||||
# ============================================================================
|
||||
# Type Checking Configuration
|
||||
@@ -111,6 +118,8 @@ module = [
|
||||
"docker.*",
|
||||
"caido_sdk_client.*",
|
||||
"pydantic_settings.*",
|
||||
"reportlab.*",
|
||||
"pypdf.*",
|
||||
]
|
||||
ignore_missing_imports = true
|
||||
disable_error_code = ["import-untyped"]
|
||||
@@ -201,6 +210,15 @@ ignore = [
|
||||
]
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
# Test doubles use fixture tokens/passwords and match a callee signature whose
|
||||
# args they intentionally ignore.
|
||||
"tests/test_viewer_auth.py" = ["S105", "S106", "ARG001"]
|
||||
"tests/test_report_pdf.py" = ["S105", "S106"]
|
||||
# Stdlib HTTP handler overrides (do_GET/do_POST) and lazy imports that avoid a
|
||||
# circular dependency with strix.telemetry / strix.viewer.report_pdf.
|
||||
"strix/viewer/server.py" = ["N802", "PLC0415"]
|
||||
# Lazy telemetry import to avoid importing PostHog before the viewer starts.
|
||||
"strix/viewer/cli.py" = ["PLC0415"]
|
||||
# Lazy imports inside functions to avoid circular dependency with
|
||||
# strix.telemetry / strix.report.dedupe / cvss.
|
||||
"strix/tools/notes/tools.py" = ["PLC0415", "TC002"]
|
||||
|
||||
+32
-1
@@ -25,6 +25,13 @@ for tcss_file in strix_root.rglob('*.tcss'):
|
||||
rel_path = tcss_file.relative_to(project_root)
|
||||
datas.append((str(tcss_file), str(rel_path.parent)))
|
||||
|
||||
# Prebuilt local-viewer SPA (served by `strix view`).
|
||||
viewer_dist = strix_root / 'viewer' / 'viewer_dist'
|
||||
for asset in viewer_dist.rglob('*'):
|
||||
if asset.is_file():
|
||||
rel_path = asset.relative_to(project_root)
|
||||
datas.append((str(asset), str(rel_path.parent)))
|
||||
|
||||
datas += collect_data_files('textual')
|
||||
|
||||
datas += collect_data_files('tiktoken')
|
||||
@@ -151,6 +158,21 @@ hiddenimports = [
|
||||
'strix.report.dedupe',
|
||||
'strix.report.state',
|
||||
'strix.report.writer',
|
||||
'strix.viewer',
|
||||
'strix.viewer.auth',
|
||||
'strix.viewer.cli',
|
||||
'strix.viewer.report_pdf',
|
||||
'strix.viewer.server',
|
||||
'strix.viewer.transcript',
|
||||
|
||||
# PDF report generation + encryption
|
||||
'reportlab',
|
||||
'reportlab.pdfgen',
|
||||
'reportlab.pdfbase',
|
||||
'reportlab.lib',
|
||||
'reportlab.platypus',
|
||||
'pypdf',
|
||||
'cryptography',
|
||||
'strix.runtime',
|
||||
'strix.runtime.backends',
|
||||
'strix.runtime.caido_bootstrap',
|
||||
@@ -178,6 +200,16 @@ hiddenimports += collect_submodules('textual')
|
||||
hiddenimports += collect_submodules('rich')
|
||||
hiddenimports += collect_submodules('pydantic')
|
||||
hiddenimports += collect_submodules('pygments')
|
||||
# reportlab loads renderers/fonts dynamically, so pull its whole tree in.
|
||||
hiddenimports += collect_submodules('reportlab')
|
||||
|
||||
# reportlab ships bundled fonts (.pfb/.afm) it needs at runtime.
|
||||
datas += collect_data_files('reportlab')
|
||||
|
||||
# reportlab imports PIL (pillow) lazily for image handling, so it must be
|
||||
# bundled explicitly and kept out of the excludes list below.
|
||||
hiddenimports += collect_submodules('PIL')
|
||||
datas += collect_data_files('PIL')
|
||||
|
||||
excludes = [
|
||||
# Sandbox-only packages
|
||||
@@ -225,7 +257,6 @@ excludes = [
|
||||
'numpy',
|
||||
'pandas',
|
||||
'scipy',
|
||||
'PIL',
|
||||
'cv2',
|
||||
]
|
||||
|
||||
|
||||
@@ -72,6 +72,15 @@ class IntegrationSettings(BaseSettings):
|
||||
perplexity_api_key: str | None = Field(default=None, alias="PERPLEXITY_API_KEY")
|
||||
|
||||
|
||||
class ViewerSettings(BaseSettings):
|
||||
model_config = _BASE_CONFIG
|
||||
|
||||
# Base URL of the Strix relay the local viewer proxies to for email
|
||||
# verification and encrypted report delivery. The browser never talks to
|
||||
# the relay directly; the local server is the only caller.
|
||||
app_url: str = Field(default="https://app.strix.ai", alias="STRIX_APP_URL")
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = _BASE_CONFIG
|
||||
|
||||
@@ -79,3 +88,4 @@ class Settings(BaseSettings):
|
||||
runtime: RuntimeSettings = Field(default_factory=RuntimeSettings)
|
||||
telemetry: TelemetrySettings = Field(default_factory=TelemetrySettings)
|
||||
integrations: IntegrationSettings = Field(default_factory=IntegrationSettings)
|
||||
viewer: ViewerSettings = Field(default_factory=ViewerSettings)
|
||||
@@ -21,3 +21,20 @@ def runtime_state_dir(run_dir: Path) -> Path:
|
||||
|
||||
def run_record_path(run_dir: Path) -> Path:
|
||||
return run_dir / RUN_RECORD_FILENAME
|
||||
|
||||
|
||||
def runs_base_dir(*, cwd: Path | None = None) -> Path:
|
||||
base = cwd or Path.cwd()
|
||||
return base / RUNS_DIR_NAME
|
||||
|
||||
|
||||
def latest_run_dir(*, cwd: Path | None = None) -> Path | None:
|
||||
base = runs_base_dir(cwd=cwd)
|
||||
if not base.is_dir():
|
||||
return None
|
||||
candidates = [child for child in base.iterdir() if run_record_path(child).is_file()]
|
||||
if not candidates:
|
||||
return None
|
||||
# run.json is rewritten on status/end changes, so its mtime tracks activity
|
||||
# more reliably than the directory mtime (a live run sorts to the top).
|
||||
return max(candidates, key=lambda child: run_record_path(child).stat().st_mtime)
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -142,6 +142,44 @@ def end(report_state: "ReportState", exit_reason: str = "completed") -> None:
|
||||
)
|
||||
|
||||
|
||||
def viewer_opened(source: str, live: bool) -> None:
|
||||
_send(
|
||||
"viewer_opened",
|
||||
{
|
||||
**base_props(),
|
||||
"source": source,
|
||||
"live": live,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def viewer_cta_clicked(cta: str, surface: str | None = None) -> None:
|
||||
props = {
|
||||
**base_props(),
|
||||
"cta": cta[:64],
|
||||
}
|
||||
if surface:
|
||||
props["surface"] = surface[:64]
|
||||
_send("viewer_cta_clicked", props)
|
||||
|
||||
|
||||
_VIEWER_EMAIL_STEPS = frozenset(
|
||||
{"email_submitted", "email_verified", "report_sent", "work_email_required"}
|
||||
)
|
||||
|
||||
|
||||
def viewer_email_event(step: str, purpose: str | None = None) -> None:
|
||||
if step not in _VIEWER_EMAIL_STEPS:
|
||||
return
|
||||
_send(
|
||||
f"viewer_{step}",
|
||||
{
|
||||
**base_props(),
|
||||
**({"purpose": purpose} if purpose else {}),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def error(error_type: str) -> None:
|
||||
props = {**base_props(), "error_type": error_type}
|
||||
_send("error", props)
|
||||
@@ -0,0 +1,12 @@
|
||||
"""Local web viewer for Strix runs.
|
||||
|
||||
Serves a prebuilt single-page app that renders a run (live or finished) read
|
||||
directly from the run's on-disk files. No cloud dependency, no file picker.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from strix.viewer.server import serve
|
||||
|
||||
|
||||
__all__ = ["serve"]
|
||||
@@ -0,0 +1,221 @@
|
||||
"""Viewer email verification state and the relay client.
|
||||
|
||||
The local viewer proxies email verification and encrypted-report delivery to
|
||||
the Strix relay (``STRIX_APP_URL``). The browser never talks to the relay
|
||||
directly, and the report password generated locally is never sent to it.
|
||||
|
||||
State lives in ``~/.strix/viewer-auth.json`` (0600). ``is_verified`` is a local
|
||||
flag that unlocks browsing the run history list; the relay still enforces token
|
||||
expiry when a report is actually sent.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import contextlib
|
||||
import json
|
||||
import logging
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from strix.config.loader import load_settings
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
AUTH_PATH = Path.home() / ".strix" / "viewer-auth.json"
|
||||
|
||||
_OTP_TIMEOUT = 15
|
||||
_SEND_TIMEOUT = 30
|
||||
|
||||
|
||||
class RelayError(Exception):
|
||||
"""A relay call failed. ``code`` is a stable, machine-readable reason."""
|
||||
|
||||
def __init__(self, code: str, message: str | None = None) -> None:
|
||||
self.code = code
|
||||
super().__init__(message or code)
|
||||
|
||||
|
||||
# --- local state ------------------------------------------------------------
|
||||
|
||||
|
||||
def read_auth() -> dict[str, Any] | None:
|
||||
"""Return the stored ``{email, token, verified_at}`` record, or None."""
|
||||
try:
|
||||
data = json.loads(AUTH_PATH.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
email = data.get("email")
|
||||
token = data.get("token")
|
||||
if not isinstance(email, str) or not email or not isinstance(token, str) or not token:
|
||||
return None
|
||||
return data
|
||||
|
||||
|
||||
def _expiry(record: dict[str, Any]) -> datetime | None:
|
||||
"""Parse ``verified_at`` (the relay's ``expires_at``) into an aware UTC datetime.
|
||||
|
||||
Returns None when it is absent or unparseable, in which case expiry cannot be
|
||||
enforced locally (the relay still rejects an expired token on report send).
|
||||
"""
|
||||
raw = record.get("verified_at")
|
||||
if not isinstance(raw, str) or not raw:
|
||||
return None
|
||||
try:
|
||||
parsed = datetime.fromisoformat(raw.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return None
|
||||
return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC)
|
||||
|
||||
|
||||
def is_verified() -> bool:
|
||||
"""True when a usable, unexpired email + token record exists locally.
|
||||
|
||||
The expiry returned by OTP verification is enforced here so history stops
|
||||
unlocking once the token lapses, keeping the local gate in step with the
|
||||
relay (which rejects an expired token on report send).
|
||||
"""
|
||||
record = read_auth()
|
||||
if record is None:
|
||||
return False
|
||||
expiry = _expiry(record)
|
||||
return expiry is None or expiry > datetime.now(UTC)
|
||||
|
||||
|
||||
def write_auth(email: str, token: str, verified_at: str) -> None:
|
||||
"""Atomically persist the auth record with 0600 permissions."""
|
||||
AUTH_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
payload = json.dumps({"email": email, "token": token, "verified_at": verified_at})
|
||||
tmp = AUTH_PATH.with_suffix(".json.tmp")
|
||||
tmp.write_text(payload, encoding="utf-8")
|
||||
with contextlib.suppress(OSError):
|
||||
tmp.chmod(0o600)
|
||||
tmp.replace(AUTH_PATH)
|
||||
with contextlib.suppress(OSError):
|
||||
AUTH_PATH.chmod(0o600)
|
||||
|
||||
|
||||
def forget() -> None:
|
||||
"""Delete the stored auth record. No-op if it is absent."""
|
||||
with contextlib.suppress(OSError):
|
||||
AUTH_PATH.unlink()
|
||||
|
||||
|
||||
# --- relay client -----------------------------------------------------------
|
||||
|
||||
|
||||
def _app_url() -> str:
|
||||
return load_settings().viewer.app_url.rstrip("/")
|
||||
|
||||
|
||||
def _post_json(path: str, payload: dict[str, Any], *, timeout: int) -> tuple[int, dict[str, Any]]:
|
||||
"""POST JSON to the relay. Returns (status, parsed body).
|
||||
|
||||
Raises RelayError("unavailable") for network/transport failures. HTTP
|
||||
error responses (4xx/5xx) are returned as (status, body) for the caller to
|
||||
map, not raised.
|
||||
"""
|
||||
url = f"{_app_url()}{path}"
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
request = urllib.request.Request( # noqa: S310 - fixed https relay URL
|
||||
url,
|
||||
data=body,
|
||||
headers={"Content-Type": "application/json", "Accept": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310
|
||||
return response.status, _parse_body(response.read())
|
||||
except urllib.error.HTTPError as exc:
|
||||
return exc.code, _parse_body(exc.read())
|
||||
except (urllib.error.URLError, TimeoutError, OSError) as exc:
|
||||
logger.warning("relay request to %s failed: %s", path, exc)
|
||||
raise RelayError("unavailable") from exc
|
||||
|
||||
|
||||
def _parse_body(raw: bytes) -> dict[str, Any]:
|
||||
try:
|
||||
data = json.loads(raw or b"{}")
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def otp_start(email: str) -> None:
|
||||
"""Ask the relay to email a verification code. Raises RelayError on failure."""
|
||||
status, data = _post_json("/api/oss/otp/start", {"email": email}, timeout=_OTP_TIMEOUT)
|
||||
if status == 200:
|
||||
return
|
||||
if status == 429:
|
||||
raise RelayError("rate_limited")
|
||||
if status == 400:
|
||||
# The relay uses 400 both for a malformed address and, separately, to
|
||||
# reject a free/personal email domain (it wants a work email).
|
||||
if data.get("error") == "work_email_required":
|
||||
raise RelayError("work_email_required")
|
||||
raise RelayError("invalid_email")
|
||||
raise RelayError("unavailable")
|
||||
|
||||
|
||||
def otp_verify(email: str, code: str) -> dict[str, Any]:
|
||||
"""Verify a code. Returns ``{token, email, expires_at}`` or raises RelayError."""
|
||||
status, data = _post_json(
|
||||
"/api/oss/otp/verify",
|
||||
{"email": email, "code": code},
|
||||
timeout=_OTP_TIMEOUT,
|
||||
)
|
||||
if status == 200 and isinstance(data.get("token"), str):
|
||||
return data
|
||||
if status == 403:
|
||||
raise RelayError("invalid_code")
|
||||
raise RelayError("unavailable")
|
||||
|
||||
|
||||
def report_send(
|
||||
token: str,
|
||||
pdf_bytes: bytes,
|
||||
filename: str,
|
||||
run_name: str,
|
||||
target: str,
|
||||
) -> None:
|
||||
"""Forward the encrypted PDF to the relay for delivery.
|
||||
|
||||
The report password is NEVER part of this payload; only the encrypted PDF
|
||||
bytes travel to the relay.
|
||||
"""
|
||||
payload = {
|
||||
"token": token,
|
||||
"pdf_base64": base64.b64encode(pdf_bytes).decode("ascii"),
|
||||
"filename": filename,
|
||||
"run_name": run_name,
|
||||
"target": target,
|
||||
}
|
||||
status, _ = _post_json("/api/oss/report/send", payload, timeout=_SEND_TIMEOUT)
|
||||
if status == 200:
|
||||
return
|
||||
if status == 401:
|
||||
raise RelayError("reverify")
|
||||
if status == 413:
|
||||
raise RelayError("too_large")
|
||||
if status == 403:
|
||||
raise RelayError("forbidden")
|
||||
raise RelayError("unavailable")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AUTH_PATH",
|
||||
"RelayError",
|
||||
"forget",
|
||||
"is_verified",
|
||||
"otp_start",
|
||||
"otp_verify",
|
||||
"read_auth",
|
||||
"report_send",
|
||||
"write_auth",
|
||||
]
|
||||
@@ -0,0 +1,139 @@
|
||||
"""`strix view [<run>]` command: serve a run's viewer UI locally."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from rich.console import Console
|
||||
|
||||
from strix.core.paths import (
|
||||
RUNS_DIR_NAME,
|
||||
latest_run_dir,
|
||||
run_dir_for,
|
||||
run_record_path,
|
||||
runs_base_dir,
|
||||
)
|
||||
from strix.viewer.server import authorized_url, bundle_is_built, serve
|
||||
from strix.viewer.transcript import read_run_summary
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
from typing import NoReturn
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def run_view(argv: list[str]) -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="strix view",
|
||||
description="Open a local web view of a Strix run (live or finished).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"run",
|
||||
nargs="?",
|
||||
default=None,
|
||||
help=f"Run name under ./{RUNS_DIR_NAME} (defaults to the most recent run).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--port",
|
||||
type=int,
|
||||
default=0,
|
||||
help="Port to serve on (default: an available ephemeral port).",
|
||||
)
|
||||
parser.add_argument("--host", default="127.0.0.1", help=argparse.SUPPRESS)
|
||||
parser.add_argument(
|
||||
"--no-open",
|
||||
action="store_true",
|
||||
help="Do not open the browser automatically.",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
console = Console()
|
||||
|
||||
if not bundle_is_built():
|
||||
console.print(
|
||||
"[bold red]Viewer UI is not built.[/]\n"
|
||||
"Build it with: [cyan]cd strix/viewer_src && npm ci && npm run build[/]"
|
||||
)
|
||||
raise SystemExit(1)
|
||||
|
||||
run_dir = _resolve_run_dir(args.run, console)
|
||||
|
||||
httpd, url, token = serve(
|
||||
run_dir,
|
||||
host=args.host,
|
||||
port=args.port,
|
||||
open_browser=not args.no_open,
|
||||
)
|
||||
# The tokened URL is what authorizes the browser (steering, report sending,
|
||||
# history). Print it rather than the bare URL so the operator -- and only
|
||||
# the operator -- can open or share an authorized link.
|
||||
open_url = authorized_url(url, token)
|
||||
|
||||
run_name = run_dir.name
|
||||
summary = read_run_summary(run_dir)
|
||||
live = not summary.get("finished", False)
|
||||
|
||||
from strix.telemetry import posthog
|
||||
|
||||
posthog.viewer_opened(source="cli", live=live)
|
||||
|
||||
state_label = "[#eab308]live[/]" if live else "[#22c55e]finished[/]"
|
||||
console.print()
|
||||
console.print(f"Serving [bold white]{run_name}[/] ({state_label}) at [#60a5fa]{open_url}[/]")
|
||||
console.print("[dim]This link authorizes the browser; anyone you share it with can steer[/]")
|
||||
console.print("[dim]a live scan and browse history. Press Ctrl-C to stop the viewer.[/]")
|
||||
console.print()
|
||||
|
||||
try:
|
||||
while True:
|
||||
time.sleep(1.0)
|
||||
except KeyboardInterrupt:
|
||||
console.print("\n[dim]Viewer stopped.[/]")
|
||||
finally:
|
||||
httpd.shutdown()
|
||||
httpd.server_close()
|
||||
|
||||
|
||||
def _resolve_run_dir(run: str | None, console: Console) -> Path:
|
||||
if run:
|
||||
run_dir = run_dir_for(run)
|
||||
if not run_record_path(run_dir).is_file():
|
||||
_fail_no_run(console, requested=run)
|
||||
return run_dir
|
||||
|
||||
latest = latest_run_dir()
|
||||
if latest is None:
|
||||
_fail_no_run(console, requested=None)
|
||||
return latest
|
||||
|
||||
|
||||
def _fail_no_run(console: Console, *, requested: str | None) -> NoReturn:
|
||||
base = runs_base_dir()
|
||||
available = (
|
||||
sorted(
|
||||
(child.name for child in base.iterdir() if run_record_path(child).is_file()),
|
||||
reverse=True,
|
||||
)
|
||||
if base.is_dir()
|
||||
else []
|
||||
)
|
||||
|
||||
if requested:
|
||||
console.print(f"[bold red]No run named '{requested}' under ./{RUNS_DIR_NAME}.[/]")
|
||||
else:
|
||||
console.print(f"[bold red]No runs found under ./{RUNS_DIR_NAME}.[/]")
|
||||
|
||||
if available:
|
||||
console.print("Available runs:")
|
||||
for name in available[:20]:
|
||||
console.print(f" [cyan]{name}[/]")
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
__all__ = ["run_view"]
|
||||
@@ -0,0 +1,618 @@
|
||||
"""Build and encrypt a branded PDF report for a run.
|
||||
|
||||
The layout mirrors the Strix cloud pentest report (cover page, executive
|
||||
severity grid, per-finding detail with colored severity badges) but is rendered
|
||||
entirely locally with reportlab, so it ships without a browser or heavy system
|
||||
deps and keeps the report on the user's machine.
|
||||
|
||||
The PDF carries FULL finding detail, including proof-of-concept scripts, so it
|
||||
is encrypted end to end with AES-256. The password is generated locally with a
|
||||
CSPRNG, shown only to the local browser, and never leaves the machine except in
|
||||
the user's own hands. Strix cannot read the delivered report.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import re
|
||||
import secrets
|
||||
from datetime import datetime
|
||||
from io import BytesIO
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from pypdf import PdfReader, PdfWriter
|
||||
from reportlab.lib import colors
|
||||
from reportlab.lib.enums import TA_CENTER
|
||||
from reportlab.lib.pagesizes import A4
|
||||
from reportlab.lib.styles import ParagraphStyle
|
||||
from reportlab.lib.units import mm
|
||||
from reportlab.pdfgen import canvas as pdfcanvas
|
||||
from reportlab.platypus import (
|
||||
Flowable,
|
||||
KeepTogether,
|
||||
PageBreak,
|
||||
Paragraph,
|
||||
SimpleDocTemplate,
|
||||
Spacer,
|
||||
Table,
|
||||
TableStyle,
|
||||
)
|
||||
|
||||
from strix.viewer.transcript import (
|
||||
primary_target,
|
||||
read_run_summary,
|
||||
read_vulnerabilities,
|
||||
severity_counts,
|
||||
)
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# Palette lifted from the cloud report theme (styles/base.ts, docx/theme.ts).
|
||||
_INK = colors.HexColor("#000000")
|
||||
_TEXT = colors.HexColor("#1a1a1a")
|
||||
_MUTED = colors.HexColor("#666666")
|
||||
_FAINT = colors.HexColor("#999999")
|
||||
_BORDER = colors.HexColor("#e5e5e5")
|
||||
_LIGHT_BG = colors.HexColor("#f7f7f7")
|
||||
|
||||
_SEVERITY_ORDER = ("critical", "high", "medium", "low")
|
||||
_SEVERITY_COLORS = {
|
||||
"critical": colors.HexColor("#dc2626"),
|
||||
"high": colors.HexColor("#ea580c"),
|
||||
"medium": colors.HexColor("#ca8a04"),
|
||||
"low": colors.HexColor("#2563eb"),
|
||||
}
|
||||
|
||||
# Helvetica stands in for Geist: a clean sans with no font file to ship.
|
||||
_SANS = "Helvetica"
|
||||
_SANS_BOLD = "Helvetica-Bold"
|
||||
_MONO = "Courier"
|
||||
|
||||
_PAGE_W, _PAGE_H = A4
|
||||
|
||||
|
||||
def _esc(value: Any) -> str:
|
||||
"""Escape a value for reportlab's Paragraph markup."""
|
||||
return html.escape(str(value)).replace("\n", "<br/>")
|
||||
|
||||
|
||||
class _NumberedCanvas(pdfcanvas.Canvas): # type: ignore[misc] # reportlab base is untyped
|
||||
"""Two-pass canvas that prints 'Page X of Y' on every page after the cover."""
|
||||
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
self._saved_states: list[dict[str, Any]] = []
|
||||
|
||||
def showPage(self) -> None: # noqa: N802 - reportlab API
|
||||
self._saved_states.append(dict(self.__dict__))
|
||||
self._startPage()
|
||||
|
||||
def save(self) -> None:
|
||||
total = len(self._saved_states)
|
||||
for index, state in enumerate(self._saved_states):
|
||||
self.__dict__.update(state)
|
||||
if index > 0: # skip the cover page
|
||||
self._draw_footer(index + 1, total)
|
||||
super().showPage()
|
||||
super().save()
|
||||
|
||||
def _draw_footer(self, page: int, total: int) -> None:
|
||||
self.setFont(_SANS, 8)
|
||||
self.setFillColor(_FAINT)
|
||||
self.drawCentredString(_PAGE_W / 2, 14 * mm, f"Page {page} of {total}")
|
||||
|
||||
|
||||
class _LogoMark(Flowable): # type: ignore[misc] # reportlab base is untyped
|
||||
"""The rounded-square Strix mark drawn inline (no raster asset to ship)."""
|
||||
|
||||
def __init__(self, size: float = 30) -> None:
|
||||
super().__init__()
|
||||
self.size = size
|
||||
self.width = size
|
||||
self.height = size
|
||||
|
||||
def draw(self) -> None:
|
||||
c = self.canv
|
||||
s = self.size
|
||||
c.setFillColor(_INK)
|
||||
c.roundRect(0, 0, s, s, s * 0.28, fill=1, stroke=0)
|
||||
c.setFillColor(colors.white)
|
||||
c.setFont(_SANS_BOLD, s * 0.56)
|
||||
c.drawCentredString(s / 2, s * 0.27, "S")
|
||||
|
||||
|
||||
def _styles() -> dict[str, ParagraphStyle]:
|
||||
styles: dict[str, ParagraphStyle] = {}
|
||||
styles["wordmark"] = ParagraphStyle(
|
||||
"Wordmark", fontName=_SANS_BOLD, fontSize=17, leading=20, textColor=_INK
|
||||
)
|
||||
styles["badge_label"] = ParagraphStyle(
|
||||
"BadgeLabel", fontName=_SANS_BOLD, fontSize=9, leading=12, textColor=_MUTED
|
||||
)
|
||||
styles["cover_title"] = ParagraphStyle(
|
||||
"CoverTitle", fontName=_SANS_BOLD, fontSize=34, leading=38, textColor=_INK
|
||||
)
|
||||
styles["cover_org"] = ParagraphStyle(
|
||||
"CoverOrg", fontName=_SANS, fontSize=13, leading=18, textColor=_MUTED
|
||||
)
|
||||
styles["meta_label"] = ParagraphStyle(
|
||||
"MetaLabel", fontName=_SANS_BOLD, fontSize=8, leading=12, textColor=_MUTED
|
||||
)
|
||||
styles["meta_value"] = ParagraphStyle(
|
||||
"MetaValue", fontName=_SANS, fontSize=10.5, leading=14, textColor=_TEXT
|
||||
)
|
||||
styles["section"] = ParagraphStyle(
|
||||
"Section", fontName=_SANS_BOLD, fontSize=18, leading=22, textColor=_INK, spaceAfter=6
|
||||
)
|
||||
styles["finding"] = ParagraphStyle(
|
||||
"Finding", fontName=_SANS_BOLD, fontSize=13, leading=17, textColor=_INK, spaceBefore=6
|
||||
)
|
||||
styles["field_label"] = ParagraphStyle(
|
||||
"FieldLabel", fontName=_SANS_BOLD, fontSize=8.5, leading=12, textColor=_MUTED,
|
||||
spaceBefore=10, spaceAfter=2,
|
||||
)
|
||||
styles["body"] = ParagraphStyle(
|
||||
"Body", fontName=_SANS, fontSize=10, leading=15, textColor=_TEXT, spaceAfter=8
|
||||
)
|
||||
styles["md_heading"] = ParagraphStyle(
|
||||
"MdHeading", fontName=_SANS_BOLD, fontSize=11, leading=15, textColor=_INK,
|
||||
spaceBefore=10, spaceAfter=4,
|
||||
)
|
||||
styles["bullet"] = ParagraphStyle(
|
||||
"Bullet", fontName=_SANS, fontSize=10, leading=15, textColor=_TEXT,
|
||||
leftIndent=16, firstLineIndent=-11, spaceAfter=3,
|
||||
)
|
||||
styles["meta_inline"] = ParagraphStyle(
|
||||
"MetaInline", fontName=_SANS, fontSize=9, leading=13, textColor=_MUTED, spaceBefore=4
|
||||
)
|
||||
# spaceBefore/spaceAfter must exceed borderPadding: reportlab does not reserve
|
||||
# a bordered paragraph's top padding, so too small a gap lets the background
|
||||
# box bleed up over the field label above it.
|
||||
styles["code"] = ParagraphStyle(
|
||||
"Code", fontName=_MONO, fontSize=8, leading=11, textColor=_TEXT,
|
||||
backColor=_LIGHT_BG, borderColor=_BORDER, borderWidth=0.5, borderPadding=8,
|
||||
spaceBefore=12, spaceAfter=12,
|
||||
)
|
||||
styles["count"] = ParagraphStyle(
|
||||
"Count", fontName=_SANS_BOLD, fontSize=30, leading=32, alignment=TA_CENTER
|
||||
)
|
||||
styles["count_label"] = ParagraphStyle(
|
||||
"CountLabel", fontName=_SANS_BOLD, fontSize=8, leading=12, textColor=_MUTED,
|
||||
alignment=TA_CENTER, spaceBefore=4,
|
||||
)
|
||||
styles["badge"] = ParagraphStyle(
|
||||
"Badge", fontName=_SANS_BOLD, fontSize=9, leading=11, textColor=colors.white,
|
||||
alignment=TA_CENTER,
|
||||
)
|
||||
styles["confidential"] = ParagraphStyle(
|
||||
"Confidential", fontName=_SANS_BOLD, fontSize=9, leading=12, textColor=colors.white,
|
||||
alignment=TA_CENTER,
|
||||
)
|
||||
return styles
|
||||
|
||||
|
||||
def _parse_time(raw: Any) -> datetime | None:
|
||||
if not isinstance(raw, str) or not raw:
|
||||
return None
|
||||
text = raw.strip().replace(" UTC", "Z").replace(" ", "T")
|
||||
if text.endswith("Z"):
|
||||
text = text[:-1] + "+00:00"
|
||||
try:
|
||||
return datetime.fromisoformat(text)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _fmt_time(raw: Any) -> str:
|
||||
parsed = _parse_time(raw)
|
||||
return parsed.strftime("%Y-%m-%d %H:%M UTC") if parsed else "n/a"
|
||||
|
||||
|
||||
def _duration(start: Any, end: Any) -> str:
|
||||
start_dt = _parse_time(start)
|
||||
end_dt = _parse_time(end)
|
||||
if not start_dt or not end_dt:
|
||||
return "n/a"
|
||||
seconds = int((end_dt - start_dt).total_seconds())
|
||||
if seconds < 0:
|
||||
return "n/a"
|
||||
hours, remainder = divmod(seconds, 3600)
|
||||
minutes, secs = divmod(remainder, 60)
|
||||
if hours:
|
||||
return f"{hours}h {minutes}m {secs}s"
|
||||
if minutes:
|
||||
return f"{minutes}m {secs}s"
|
||||
return f"{secs}s"
|
||||
|
||||
|
||||
def _severity_badge(styles: dict[str, ParagraphStyle], severity: str) -> Table:
|
||||
"""A colored pill matching .severity-badge in the cloud report."""
|
||||
color = _SEVERITY_COLORS.get(severity, _MUTED)
|
||||
cell = Paragraph(severity.upper(), styles["badge"])
|
||||
table = Table([[cell]], colWidths=[len(severity) * 6.5 + 20])
|
||||
table.setStyle(
|
||||
TableStyle(
|
||||
[
|
||||
("BACKGROUND", (0, 0), (-1, -1), color),
|
||||
("TOPPADDING", (0, 0), (-1, -1), 4),
|
||||
("BOTTOMPADDING", (0, 0), (-1, -1), 4),
|
||||
("LEFTPADDING", (0, 0), (-1, -1), 8),
|
||||
("RIGHTPADDING", (0, 0), (-1, -1), 8),
|
||||
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
||||
]
|
||||
)
|
||||
)
|
||||
table.hAlign = "LEFT"
|
||||
return table
|
||||
|
||||
|
||||
def _severity_grid(styles: dict[str, ParagraphStyle], counts: dict[str, int]) -> Table:
|
||||
"""The four-card severity grid from the executive summary."""
|
||||
cells: list[list[Flowable]] = []
|
||||
for name in _SEVERITY_ORDER:
|
||||
color = _SEVERITY_COLORS[name]
|
||||
count_style = ParagraphStyle(f"Count{name}", parent=styles["count"], textColor=color)
|
||||
cells.append(
|
||||
[Paragraph(str(counts.get(name, 0)), count_style),
|
||||
Paragraph(name.upper(), styles["count_label"])]
|
||||
)
|
||||
col = (_PAGE_W - 40 * mm) / 4
|
||||
table = Table([cells], colWidths=[col] * 4)
|
||||
style = [
|
||||
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
||||
("TOPPADDING", (0, 0), (-1, -1), 16),
|
||||
("BOTTOMPADDING", (0, 0), (-1, -1), 16),
|
||||
("GRID", (0, 0), (-1, -1), 0.5, _BORDER),
|
||||
]
|
||||
for index, name in enumerate(_SEVERITY_ORDER):
|
||||
style.append(("LINEABOVE", (index, 0), (index, 0), 3, _SEVERITY_COLORS[name]))
|
||||
table.setStyle(TableStyle(style))
|
||||
return table
|
||||
|
||||
|
||||
def _section(styles: dict[str, ParagraphStyle], title: str) -> Table:
|
||||
"""Section title with the underline rule from h2.section-title."""
|
||||
table = Table([[Paragraph(_esc(title), styles["section"])]], colWidths=[_PAGE_W - 40 * mm])
|
||||
table.setStyle(
|
||||
TableStyle(
|
||||
[
|
||||
("LINEBELOW", (0, 0), (-1, -1), 1, _BORDER),
|
||||
("BOTTOMPADDING", (0, 0), (-1, -1), 10),
|
||||
("LEFTPADDING", (0, 0), (-1, -1), 0),
|
||||
("RIGHTPADDING", (0, 0), (-1, -1), 0),
|
||||
("TOPPADDING", (0, 0), (-1, -1), 0),
|
||||
]
|
||||
)
|
||||
)
|
||||
return table
|
||||
|
||||
|
||||
def _cover(
|
||||
styles: dict[str, ParagraphStyle], record: dict[str, Any], run_name: str
|
||||
) -> list[Flowable]:
|
||||
header = Table(
|
||||
[[_LogoMark(30), Paragraph("Strix", styles["wordmark"])]],
|
||||
colWidths=[38, _PAGE_W - 40 * mm - 38],
|
||||
)
|
||||
header.setStyle(
|
||||
TableStyle(
|
||||
[
|
||||
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
||||
("LEFTPADDING", (0, 0), (-1, -1), 0),
|
||||
("RIGHTPADDING", (0, 0), (-1, -1), 0),
|
||||
("TOPPADDING", (0, 0), (-1, -1), 0),
|
||||
("BOTTOMPADDING", (0, 0), (-1, -1), 0),
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
target = primary_target(record) or "Target"
|
||||
meta_rows = [
|
||||
("TARGET", primary_target(record) or "unknown target"),
|
||||
("RUN", run_name),
|
||||
("SCAN MODE", str(record.get("scan_mode") or "n/a")),
|
||||
("STATUS", str(record.get("status") or "n/a")),
|
||||
("STARTED", _fmt_time(record.get("start_time"))),
|
||||
("COMPLETED", _fmt_time(record.get("end_time"))),
|
||||
("DURATION", _duration(record.get("start_time"), record.get("end_time"))),
|
||||
]
|
||||
meta_table = Table(
|
||||
[[Paragraph(label, styles["meta_label"]), Paragraph(_esc(value), styles["meta_value"])]
|
||||
for label, value in meta_rows],
|
||||
colWidths=[38 * mm, _PAGE_W - 40 * mm - 38 * mm],
|
||||
)
|
||||
meta_table.setStyle(
|
||||
TableStyle(
|
||||
[
|
||||
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
||||
("LEFTPADDING", (0, 0), (-1, -1), 0),
|
||||
("TOPPADDING", (0, 0), (-1, -1), 6),
|
||||
("BOTTOMPADDING", (0, 0), (-1, -1), 6),
|
||||
("LINEBELOW", (0, 0), (-1, -2), 0.5, _BORDER),
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
confidential = Table([[Paragraph("CONFIDENTIAL", styles["confidential"])]], colWidths=[120])
|
||||
confidential.setStyle(
|
||||
TableStyle(
|
||||
[
|
||||
("BACKGROUND", (0, 0), (-1, -1), _INK),
|
||||
("TOPPADDING", (0, 0), (-1, -1), 8),
|
||||
("BOTTOMPADDING", (0, 0), (-1, -1), 8),
|
||||
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
||||
]
|
||||
)
|
||||
)
|
||||
confidential.hAlign = "CENTER"
|
||||
|
||||
return [
|
||||
header,
|
||||
Spacer(1, 150),
|
||||
Paragraph("PENETRATION TEST REPORT", styles["badge_label"]),
|
||||
Spacer(1, 20),
|
||||
Paragraph("Security Assessment", styles["cover_title"]),
|
||||
Paragraph(_esc(target), styles["cover_org"]),
|
||||
Spacer(1, 28),
|
||||
meta_table,
|
||||
Spacer(1, 90),
|
||||
confidential,
|
||||
PageBreak(),
|
||||
]
|
||||
|
||||
|
||||
def _inline_md(text: str) -> str:
|
||||
"""Convert inline markdown (bold, italic, `code`) to reportlab markup.
|
||||
|
||||
Code spans are stashed as placeholders before bold/italic run, so bold that
|
||||
wraps a code span (``**`x`**``) works and code contents are never mangled.
|
||||
"""
|
||||
codes: list[str] = []
|
||||
|
||||
def _stash(match: re.Match[str]) -> str:
|
||||
codes.append(match.group(1))
|
||||
return f"\x00{len(codes) - 1}\x00"
|
||||
|
||||
seg = html.escape(re.sub(r"`([^`]+)`", _stash, text))
|
||||
seg = re.sub(r"\*\*(.+?)\*\*", r"<b>\1</b>", seg)
|
||||
seg = re.sub(r"__(.+?)__", r"<b>\1</b>", seg)
|
||||
seg = re.sub(r"\*(.+?)\*", r"<i>\1</i>", seg)
|
||||
|
||||
def _restore(match: re.Match[str]) -> str:
|
||||
inner = html.escape(codes[int(match.group(1))])
|
||||
return f'<font face="{_MONO}" color="#b31d28">{inner}</font>'
|
||||
|
||||
return re.sub(r"\x00(\d+)\x00", _restore, seg)
|
||||
|
||||
|
||||
def _strip_leading_heading(md: str) -> str:
|
||||
"""Drop a single leading markdown heading (each section adds its own title)."""
|
||||
lines = md.lstrip("\n").split("\n")
|
||||
if lines and re.match(r"^#{1,6}\s+", lines[0].strip()):
|
||||
return "\n".join(lines[1:]).lstrip("\n")
|
||||
return md
|
||||
|
||||
|
||||
def _markdown_flowables( # noqa: PLR0915 - cohesive block parser, splitting hurts clarity
|
||||
md: str, styles: dict[str, ParagraphStyle]
|
||||
) -> list[Flowable]:
|
||||
"""Render a markdown block (headings, lists, fenced code, prose) to flowables."""
|
||||
flow: list[Flowable] = []
|
||||
para: list[str] = []
|
||||
bullets: list[tuple[str, str]] = []
|
||||
|
||||
def flush_para() -> None:
|
||||
if para:
|
||||
flow.append(Paragraph(_inline_md(" ".join(para)), styles["body"]))
|
||||
para.clear()
|
||||
|
||||
def flush_bullets() -> None:
|
||||
for marker, item in bullets:
|
||||
flow.append(Paragraph(f"{marker} {_inline_md(item)}", styles["bullet"]))
|
||||
bullets.clear()
|
||||
|
||||
lines = md.replace("\r\n", "\n").split("\n")
|
||||
i = 0
|
||||
while i < len(lines):
|
||||
stripped = lines[i].strip()
|
||||
if stripped.startswith("```"):
|
||||
flush_para()
|
||||
flush_bullets()
|
||||
i += 1
|
||||
code: list[str] = []
|
||||
while i < len(lines) and not lines[i].strip().startswith("```"):
|
||||
code.append(lines[i])
|
||||
i += 1
|
||||
i += 1 # closing fence
|
||||
flow.append(Paragraph(_esc("\n".join(code)) or " ", styles["code"]))
|
||||
continue
|
||||
if not stripped:
|
||||
flush_para()
|
||||
flush_bullets()
|
||||
i += 1
|
||||
continue
|
||||
heading = re.match(r"^(#{1,6})\s+(.*)$", stripped)
|
||||
if heading:
|
||||
flush_para()
|
||||
flush_bullets()
|
||||
flow.append(Paragraph(_inline_md(heading.group(2)), styles["md_heading"]))
|
||||
i += 1
|
||||
continue
|
||||
ordered = re.match(r"^(\d+)\.\s+(.*)$", stripped)
|
||||
unordered = re.match(r"^[-*+]\s+(.*)$", stripped)
|
||||
if ordered:
|
||||
flush_para()
|
||||
bullets.append((f"{ordered.group(1)}.", ordered.group(2)))
|
||||
i += 1
|
||||
continue
|
||||
if unordered:
|
||||
flush_para()
|
||||
bullets.append(("•", unordered.group(1)))
|
||||
i += 1
|
||||
continue
|
||||
flush_bullets()
|
||||
para.append(stripped)
|
||||
i += 1
|
||||
|
||||
flush_para()
|
||||
flush_bullets()
|
||||
return flow
|
||||
|
||||
|
||||
def _field_block(
|
||||
styles: dict[str, ParagraphStyle], label: str, value: Any, *, code: bool = False
|
||||
) -> list[Flowable]:
|
||||
if value is None or (isinstance(value, str) and not value.strip()):
|
||||
return []
|
||||
flow: list[Flowable] = [Paragraph(label.upper(), styles["field_label"])]
|
||||
if code:
|
||||
flow.append(Paragraph(_esc(value), styles["code"]))
|
||||
else:
|
||||
flow.extend(_markdown_flowables(str(value), styles))
|
||||
return flow
|
||||
|
||||
|
||||
def _finding_flowables(
|
||||
styles: dict[str, ParagraphStyle], index: int, vuln: dict[str, Any]
|
||||
) -> list[Flowable]:
|
||||
title = vuln.get("title") or "Untitled finding"
|
||||
severity = str(vuln.get("severity") or "").lower().strip() or "low"
|
||||
|
||||
meta_bits = []
|
||||
if vuln.get("cvss") is not None:
|
||||
meta_bits.append(f"<b>CVSS</b> {_esc(vuln.get('cvss'))}")
|
||||
meta_bits.extend(
|
||||
f"<b>{key.title()}</b> {_esc(vuln.get(key))}"
|
||||
for key in ("target", "endpoint", "method")
|
||||
if vuln.get(key)
|
||||
)
|
||||
|
||||
header: list[Flowable] = [
|
||||
Paragraph(f"{index}. {_esc(title)}", styles["finding"]),
|
||||
Spacer(1, 4),
|
||||
_severity_badge(styles, severity),
|
||||
]
|
||||
if meta_bits:
|
||||
header.append(Paragraph(" ".join(meta_bits), styles["meta_inline"]))
|
||||
|
||||
story: list[Flowable] = [KeepTogether(header)]
|
||||
story.extend(_field_block(styles, "Description", vuln.get("description")))
|
||||
story.extend(_field_block(styles, "Impact", vuln.get("impact")))
|
||||
story.extend(_field_block(styles, "Technical analysis", vuln.get("technical_analysis")))
|
||||
story.extend(_field_block(styles, "Proof of concept", vuln.get("poc_description")))
|
||||
story.extend(_field_block(styles, "PoC script", vuln.get("poc_script_code"), code=True))
|
||||
story.extend(_field_block(styles, "Evidence", vuln.get("evidence"), code=True))
|
||||
|
||||
remediation = vuln.get("remediation_steps")
|
||||
if isinstance(remediation, list):
|
||||
remediation = "\n".join(str(step) for step in remediation)
|
||||
story.extend(_field_block(styles, "Remediation", remediation))
|
||||
|
||||
story.append(Spacer(1, 22))
|
||||
return story
|
||||
|
||||
|
||||
def _overview_flowables(
|
||||
styles: dict[str, ParagraphStyle], record: dict[str, Any], total: int, counts: dict[str, int]
|
||||
) -> list[Flowable]:
|
||||
story: list[Flowable] = [
|
||||
_section(styles, "Executive Summary"),
|
||||
Spacer(1, 16),
|
||||
_severity_grid(styles, counts),
|
||||
Spacer(1, 10),
|
||||
Paragraph(f"<b>{total}</b> total findings across this assessment.", styles["body"]),
|
||||
]
|
||||
scan_results = record.get("scan_results")
|
||||
if not isinstance(scan_results, dict):
|
||||
return story
|
||||
summary = scan_results.get("executive_summary")
|
||||
if isinstance(summary, str) and summary.strip():
|
||||
story.append(Spacer(1, 16))
|
||||
story.extend(_markdown_flowables(_strip_leading_heading(summary), styles))
|
||||
for label, key in (
|
||||
("Methodology", "methodology"),
|
||||
("Technical Analysis", "technical_analysis"),
|
||||
("Recommendations", "recommendations"),
|
||||
):
|
||||
value = scan_results.get(key)
|
||||
if isinstance(value, str) and value.strip():
|
||||
story.append(Spacer(1, 20))
|
||||
story.append(_section(styles, label))
|
||||
story.append(Spacer(1, 12))
|
||||
story.extend(_markdown_flowables(_strip_leading_heading(value), styles))
|
||||
return story
|
||||
|
||||
|
||||
def generate_report_pdf(run_dir: Path) -> bytes:
|
||||
"""Render a branded, full-detail PDF report for the run at ``run_dir``."""
|
||||
record = read_run_summary(run_dir)
|
||||
vulns = [v for v in read_vulnerabilities(run_dir) if isinstance(v, dict)]
|
||||
counts = severity_counts(vulns)
|
||||
run_name = str(record.get("run_name") or run_dir.name)
|
||||
|
||||
styles = _styles()
|
||||
buffer = BytesIO()
|
||||
doc = SimpleDocTemplate(
|
||||
buffer,
|
||||
pagesize=A4,
|
||||
title="Strix Security Report",
|
||||
author="Strix",
|
||||
leftMargin=20 * mm,
|
||||
rightMargin=20 * mm,
|
||||
topMargin=22 * mm,
|
||||
bottomMargin=24 * mm,
|
||||
)
|
||||
|
||||
story: list[Flowable] = []
|
||||
story.extend(_cover(styles, record, run_name))
|
||||
story.extend(_overview_flowables(styles, record, len(vulns), counts))
|
||||
|
||||
story.append(PageBreak())
|
||||
story.append(_section(styles, "Findings"))
|
||||
story.append(Spacer(1, 16))
|
||||
if vulns:
|
||||
for index, vuln in enumerate(vulns, start=1):
|
||||
story.extend(_finding_flowables(styles, index, vuln))
|
||||
else:
|
||||
story.append(Paragraph("No findings were recorded for this run.", styles["body"]))
|
||||
|
||||
doc.build(story, canvasmaker=_NumberedCanvas)
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def generate_password() -> str:
|
||||
"""Return a >=20 character URL-safe password from a CSPRNG."""
|
||||
return secrets.token_urlsafe(16)
|
||||
|
||||
|
||||
def encrypt_pdf(pdf_bytes: bytes, password: str) -> bytes:
|
||||
"""Encrypt a PDF with AES-256 using ``password`` as the user password."""
|
||||
reader = PdfReader(BytesIO(pdf_bytes))
|
||||
writer = PdfWriter()
|
||||
writer.append(reader)
|
||||
writer.encrypt(user_password=password, algorithm="AES-256")
|
||||
out = BytesIO()
|
||||
writer.write(out)
|
||||
return out.getvalue()
|
||||
|
||||
|
||||
def build_encrypted_report(run_dir: Path) -> tuple[bytes, str, str]:
|
||||
"""Build, encrypt, and name the report. Returns (pdf_bytes, password, filename)."""
|
||||
record = read_run_summary(run_dir)
|
||||
run_name = str(record.get("run_name") or run_dir.name)
|
||||
pdf_bytes = generate_report_pdf(run_dir)
|
||||
password = generate_password()
|
||||
encrypted = encrypt_pdf(pdf_bytes, password)
|
||||
filename = f"strix-report-{run_name}.pdf"
|
||||
return encrypted, password, filename
|
||||
|
||||
|
||||
__all__ = [
|
||||
"build_encrypted_report",
|
||||
"encrypt_pdf",
|
||||
"generate_password",
|
||||
"generate_report_pdf",
|
||||
]
|
||||
@@ -0,0 +1,532 @@
|
||||
"""Local HTTP server that serves the viewer SPA and a run's data from disk.
|
||||
|
||||
Design notes:
|
||||
- Uses only the standard library (no new runtime dependency). The workload is
|
||||
serving static files plus a handful of JSON reads off disk, so an async stack
|
||||
buys nothing here.
|
||||
- The browser polls the JSON endpoints (~1s) rather than using SSE: a finished
|
||||
run stops polling, and short-lived polls survive sleep/network blips without
|
||||
server-side connection state, which suits a stdlib ThreadingHTTPServer.
|
||||
- All reads happen per-request straight from disk, so the same server serves a
|
||||
live in-progress run and a finished one identically; the SPA distinguishes
|
||||
them via the ``finished`` flag on /api/run.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import mimetypes
|
||||
import secrets
|
||||
import threading
|
||||
import webbrowser
|
||||
from http import HTTPStatus
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from urllib.parse import parse_qs, unquote, urlencode, urlsplit
|
||||
|
||||
from strix.core.paths import run_record_path
|
||||
from strix.viewer import auth
|
||||
from strix.viewer.transcript import (
|
||||
build_run_state,
|
||||
primary_target,
|
||||
read_report_markdown,
|
||||
read_run_summary,
|
||||
read_vulnerabilities,
|
||||
severity_counts,
|
||||
)
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def bundle_dir() -> Path:
|
||||
"""Directory holding the committed, prebuilt SPA (index.html + assets)."""
|
||||
return Path(__file__).resolve().parent / "viewer_dist"
|
||||
|
||||
|
||||
def bundle_is_built() -> bool:
|
||||
return (bundle_dir() / "index.html").is_file()
|
||||
|
||||
|
||||
def _iter_run_dirs(base_dir: Path) -> list[Path]:
|
||||
"""Every run directory under ``base_dir``, newest first by record mtime."""
|
||||
if not base_dir.is_dir():
|
||||
return []
|
||||
run_dirs = [child for child in base_dir.iterdir() if run_record_path(child).is_file()]
|
||||
run_dirs.sort(key=lambda child: run_record_path(child).stat().st_mtime, reverse=True)
|
||||
return run_dirs
|
||||
|
||||
|
||||
def run_list_entry(run_dir: Path) -> dict[str, Any]:
|
||||
"""Compact summary of a single run for the history list."""
|
||||
record = read_run_summary(run_dir)
|
||||
return {
|
||||
"name": record.get("run_name") or run_dir.name,
|
||||
"target": primary_target(record),
|
||||
"scan_mode": record.get("scan_mode"),
|
||||
"status": record.get("status"),
|
||||
"start_time": record.get("start_time"),
|
||||
"end_time": record.get("end_time"),
|
||||
"finished": bool(record.get("finished")),
|
||||
"severity_counts": severity_counts(read_vulnerabilities(run_dir)),
|
||||
}
|
||||
|
||||
|
||||
def build_runs_payload(base_dir: Path, *, verified: bool) -> dict[str, Any]:
|
||||
"""The /api/runs payload. Gates the run list behind email verification.
|
||||
|
||||
The count is always advertised so the UI can tease the history, but the
|
||||
entries only appear once the viewer is verified.
|
||||
"""
|
||||
run_dirs = _iter_run_dirs(base_dir)
|
||||
count = len(run_dirs)
|
||||
if not verified:
|
||||
return {"locked": True, "count": count, "runs": []}
|
||||
return {"locked": False, "count": count, "runs": [run_list_entry(d) for d in run_dirs]}
|
||||
|
||||
|
||||
def resolve_run_dir(base_dir: Path, run_param: str | None, default_run_dir: Path) -> Path | None:
|
||||
"""Resolve a ``?run=`` value to a real run directory under ``base_dir``.
|
||||
|
||||
Returns ``default_run_dir`` when no run is requested. Rejects traversal and
|
||||
unknown runs (returns None) so the caller can answer 404.
|
||||
"""
|
||||
if not run_param:
|
||||
return default_run_dir
|
||||
base = base_dir.resolve()
|
||||
candidate = (base / run_param).resolve()
|
||||
# Only direct children of the runs base that actually hold a run record.
|
||||
if candidate.parent != base or not run_record_path(candidate).is_file():
|
||||
return None
|
||||
return candidate
|
||||
|
||||
|
||||
# Name of the cookie carrying the per-process session capability.
|
||||
SESSION_COOKIE = "strix_viewer_session"
|
||||
|
||||
|
||||
class _ViewerState:
|
||||
def __init__(
|
||||
self,
|
||||
run_dir: Path,
|
||||
assets_dir: Path,
|
||||
steer_handler: Callable[[str, str], bool] | None = None,
|
||||
) -> None:
|
||||
self.run_dir = run_dir
|
||||
self.assets_dir = assets_dir
|
||||
# The strix_runs directory that holds the launched run; used to
|
||||
# enumerate and resolve other runs for the history list.
|
||||
self.base_dir = run_dir.parent
|
||||
# Set only when the viewer runs inside a live scan process (the TUI
|
||||
# launcher), which can deliver a message to a running agent. Absent for
|
||||
# standalone ``strix view`` / finished runs, so steering is unavailable.
|
||||
self.steer_handler = steer_handler
|
||||
# Unguessable per-process capability. It is minted here, printed/opened
|
||||
# for the operator who started the server (see ``authorized_url``), and
|
||||
# exchanged for a session cookie only when presented on the initial page
|
||||
# load. It is the request-level authorization the review asked for:
|
||||
# reachability of the port (e.g. when bound with ``--host``) is not
|
||||
# enough to steer a live scan, trigger a report, or browse history --
|
||||
# the token is never handed to a caller who merely reaches ``/``.
|
||||
self.session_token = secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
def _make_handler(state: _ViewerState) -> type[BaseHTTPRequestHandler]:
|
||||
class ViewerHandler(BaseHTTPRequestHandler):
|
||||
server_version = "StrixViewer/1.0"
|
||||
|
||||
def log_message(self, format: str, *args: Any) -> None: # noqa: A002
|
||||
logger.debug("viewer %s - %s", self.address_string(), format % args)
|
||||
|
||||
def do_GET(self) -> None:
|
||||
parts = urlsplit(self.path)
|
||||
path = parts.path
|
||||
try:
|
||||
if path.startswith("/api/"):
|
||||
self._handle_api(path, parse_qs(parts.query))
|
||||
else:
|
||||
self._handle_static(path, parse_qs(parts.query))
|
||||
except BrokenPipeError:
|
||||
# The browser closed the connection mid-response (e.g. it
|
||||
# navigated away between polls). Not an error.
|
||||
logger.debug("viewer client disconnected during %s", path)
|
||||
except Exception:
|
||||
# A bad request must never kill the worker thread.
|
||||
logger.exception("viewer request failed: %s", path)
|
||||
self._send_json(HTTPStatus.INTERNAL_SERVER_ERROR, {"error": "internal error"})
|
||||
|
||||
def do_POST(self) -> None:
|
||||
path = urlsplit(self.path).path
|
||||
try:
|
||||
if path == "/api/event":
|
||||
self._handle_event()
|
||||
elif path == "/api/auth/otp/start":
|
||||
self._handle_otp_start()
|
||||
elif path == "/api/auth/otp/verify":
|
||||
self._handle_otp_verify()
|
||||
elif path == "/api/auth/forget":
|
||||
self._handle_forget()
|
||||
elif path == "/api/report/send":
|
||||
self._handle_report_send()
|
||||
elif path == "/api/agents/steer":
|
||||
self._handle_steer()
|
||||
else:
|
||||
self._send_json(HTTPStatus.NOT_FOUND, {"error": "unknown endpoint"})
|
||||
except BrokenPipeError:
|
||||
logger.debug("viewer client disconnected during POST %s", path)
|
||||
except Exception:
|
||||
# A bad request must never kill the worker thread.
|
||||
logger.exception("viewer request failed: POST %s", path)
|
||||
self._send_json(HTTPStatus.INTERNAL_SERVER_ERROR, {"error": "internal error"})
|
||||
|
||||
def _read_body(self) -> dict[str, Any]:
|
||||
length = int(self.headers.get("Content-Length") or 0)
|
||||
raw = self.rfile.read(length) if length else b""
|
||||
try:
|
||||
body = json.loads(raw or b"{}")
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
return body if isinstance(body, dict) else {}
|
||||
|
||||
# Funnel events the viewer is allowed to forward. This handler is the
|
||||
# trust boundary: only these event names, with only their known props,
|
||||
# ever reach PostHog. Everything else (including any PII) is dropped.
|
||||
_EMAIL_EVENTS = frozenset(
|
||||
{"email_submitted", "email_verified", "report_sent", "work_email_required"}
|
||||
)
|
||||
|
||||
def _handle_event(self) -> None:
|
||||
body = self._read_body()
|
||||
# Forwarded as anonymous PostHog events that respect the global
|
||||
# telemetry opt-out. Never forward the email, code, or report body:
|
||||
# only the whitelisted event names and their known props are passed.
|
||||
event = body.get("event")
|
||||
if event == "cta_clicked":
|
||||
from strix.telemetry import posthog
|
||||
|
||||
cta = str(body.get("cta") or "unknown")
|
||||
surface = body.get("surface")
|
||||
posthog.viewer_cta_clicked(cta, surface=str(surface) if surface else None)
|
||||
elif event in self._EMAIL_EVENTS:
|
||||
from strix.telemetry import posthog
|
||||
|
||||
purpose = body.get("purpose")
|
||||
posthog.viewer_email_event(str(event), purpose=str(purpose) if purpose else None)
|
||||
self.send_response(HTTPStatus.NO_CONTENT)
|
||||
self.end_headers()
|
||||
|
||||
def _handle_api(self, path: str, query: dict[str, list[str]]) -> None:
|
||||
# The launched run is always viewable with no verification. Only the
|
||||
# cross-run history list (/api/runs) is gated.
|
||||
if path == "/api/runs":
|
||||
payload = build_runs_payload(state.base_dir, verified=auth.is_verified())
|
||||
self._send_json(HTTPStatus.OK, payload)
|
||||
return
|
||||
if path == "/api/capabilities":
|
||||
# Steering is only possible when the viewer shares a live scan's
|
||||
# coordinator + event loop (the TUI launcher wires a handler).
|
||||
self._send_json(HTTPStatus.OK, {"can_steer": state.steer_handler is not None})
|
||||
return
|
||||
if path == "/api/auth/status":
|
||||
# Report verification through is_verified() so an expired record
|
||||
# is advertised as unverified -- otherwise the SPA would suppress
|
||||
# re-verification while history stays locked, stranding the user.
|
||||
record = auth.read_auth()
|
||||
self._send_json(
|
||||
HTTPStatus.OK,
|
||||
{
|
||||
"verified": auth.is_verified(),
|
||||
"email": record.get("email") if record else None,
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
run_values = query.get("run")
|
||||
run_param = run_values[0] if run_values else None
|
||||
run_dir = resolve_run_dir(state.base_dir, run_param, state.run_dir)
|
||||
if run_dir is None:
|
||||
self._send_json(HTTPStatus.NOT_FOUND, {"error": "unknown run"})
|
||||
return
|
||||
|
||||
# The launched run is always viewable. Any *other* run's data is part
|
||||
# of the gated history, so it requires the same email verification as
|
||||
# the /api/runs list — otherwise knowing a run name would leak its
|
||||
# metadata, vulnerabilities, report, and transcript.
|
||||
if run_dir.resolve() != state.run_dir.resolve() and not auth.is_verified():
|
||||
self._send_json(HTTPStatus.UNAUTHORIZED, {"error": "unverified"})
|
||||
return
|
||||
|
||||
if path == "/api/run":
|
||||
self._send_json(HTTPStatus.OK, read_run_summary(run_dir))
|
||||
elif path == "/api/vulnerabilities":
|
||||
self._send_json(HTTPStatus.OK, read_vulnerabilities(run_dir))
|
||||
elif path == "/api/report":
|
||||
self._send_json(HTTPStatus.OK, {"markdown": read_report_markdown(run_dir)})
|
||||
elif path == "/api/transcript":
|
||||
self._send_json(HTTPStatus.OK, build_run_state(run_dir))
|
||||
else:
|
||||
self._send_json(HTTPStatus.NOT_FOUND, {"error": "unknown endpoint"})
|
||||
|
||||
def _handle_otp_start(self) -> None:
|
||||
email = str(self._read_body().get("email") or "").strip()
|
||||
if not email:
|
||||
self._send_json(HTTPStatus.BAD_REQUEST, {"error": "invalid_email"})
|
||||
return
|
||||
try:
|
||||
auth.otp_start(email)
|
||||
except auth.RelayError as exc:
|
||||
self._send_relay_error(exc)
|
||||
return
|
||||
self._send_json(HTTPStatus.OK, {"ok": True})
|
||||
|
||||
def _handle_otp_verify(self) -> None:
|
||||
body = self._read_body()
|
||||
email = str(body.get("email") or "").strip()
|
||||
code = str(body.get("code") or "").strip()
|
||||
if not email or not code:
|
||||
self._send_json(HTTPStatus.BAD_REQUEST, {"error": "invalid_code"})
|
||||
return
|
||||
try:
|
||||
result = auth.otp_verify(email, code)
|
||||
except auth.RelayError as exc:
|
||||
self._send_relay_error(exc)
|
||||
return
|
||||
auth.write_auth(
|
||||
email=result.get("email") or email,
|
||||
token=result["token"],
|
||||
verified_at=result.get("expires_at") or "",
|
||||
)
|
||||
verified_email = result.get("email") or email
|
||||
self._send_json(HTTPStatus.OK, {"verified": True, "email": verified_email})
|
||||
|
||||
def _handle_forget(self) -> None:
|
||||
auth.forget()
|
||||
self._send_json(HTTPStatus.OK, {"ok": True})
|
||||
|
||||
def _handle_report_send(self) -> None:
|
||||
if not self._has_session():
|
||||
self._send_json(HTTPStatus.FORBIDDEN, {"error": "forbidden"})
|
||||
return
|
||||
record = auth.read_auth()
|
||||
if record is None:
|
||||
self._send_json(HTTPStatus.UNAUTHORIZED, {"error": "unverified"})
|
||||
return
|
||||
run_param = str(self._read_body().get("run") or "") or None
|
||||
run_dir = resolve_run_dir(state.base_dir, run_param, state.run_dir)
|
||||
if run_dir is None:
|
||||
self._send_json(HTTPStatus.NOT_FOUND, {"error": "unknown run"})
|
||||
return
|
||||
|
||||
from strix.viewer.report_pdf import build_encrypted_report
|
||||
|
||||
pdf_bytes, password, filename = build_encrypted_report(run_dir)
|
||||
summary = read_run_summary(run_dir)
|
||||
run_name = str(summary.get("run_name") or run_dir.name)
|
||||
target = primary_target(summary) or "unknown target"
|
||||
try:
|
||||
# The password is intentionally NOT passed here; only the
|
||||
# encrypted PDF bytes reach the relay.
|
||||
auth.report_send(record["token"], pdf_bytes, filename, run_name, target)
|
||||
except auth.RelayError as exc:
|
||||
self._send_relay_error(exc)
|
||||
return
|
||||
# The password is returned only to the local (127.0.0.1) browser.
|
||||
self._send_json(
|
||||
HTTPStatus.OK,
|
||||
{"ok": True, "password": password, "filename": filename},
|
||||
)
|
||||
|
||||
# Cap on a steering message so a runaway client cannot flood the agent.
|
||||
_STEER_MESSAGE_MAX = 4000
|
||||
|
||||
def _handle_steer(self) -> None:
|
||||
if not self._has_session():
|
||||
self._send_json(HTTPStatus.FORBIDDEN, {"error": "forbidden"})
|
||||
return
|
||||
body = self._read_body()
|
||||
agent_id = body.get("agent_id")
|
||||
message = body.get("message")
|
||||
if not isinstance(agent_id, str) or not agent_id.strip():
|
||||
self._send_json(HTTPStatus.BAD_REQUEST, {"error": "invalid_agent_id"})
|
||||
return
|
||||
if (
|
||||
not isinstance(message, str)
|
||||
or not message.strip()
|
||||
or len(message) > self._STEER_MESSAGE_MAX
|
||||
):
|
||||
self._send_json(HTTPStatus.BAD_REQUEST, {"error": "invalid_message"})
|
||||
return
|
||||
if state.steer_handler is None:
|
||||
# Standalone / finished-run viewing has no live scan to steer.
|
||||
self._send_json(HTTPStatus.FORBIDDEN, {"error": "steering_unavailable"})
|
||||
return
|
||||
delivered = state.steer_handler(agent_id, message)
|
||||
if delivered:
|
||||
self._send_json(HTTPStatus.OK, {"ok": True})
|
||||
else:
|
||||
self._send_json(HTTPStatus.OK, {"ok": False, "error": "not_delivered"})
|
||||
|
||||
def _send_relay_error(self, exc: auth.RelayError) -> None:
|
||||
status_by_code = {
|
||||
"rate_limited": HTTPStatus.TOO_MANY_REQUESTS,
|
||||
"invalid_email": HTTPStatus.BAD_REQUEST,
|
||||
"work_email_required": HTTPStatus.BAD_REQUEST,
|
||||
"invalid_code": HTTPStatus.FORBIDDEN,
|
||||
"reverify": HTTPStatus.UNAUTHORIZED,
|
||||
"forbidden": HTTPStatus.FORBIDDEN,
|
||||
"too_large": HTTPStatus.REQUEST_ENTITY_TOO_LARGE,
|
||||
"unavailable": HTTPStatus.BAD_GATEWAY,
|
||||
}
|
||||
status = status_by_code.get(exc.code, HTTPStatus.BAD_GATEWAY)
|
||||
self._send_json(status, {"error": exc.code})
|
||||
|
||||
def _cookies(self) -> dict[str, str]:
|
||||
jar: dict[str, str] = {}
|
||||
for chunk in (self.headers.get("Cookie") or "").split(";"):
|
||||
name, sep, value = chunk.strip().partition("=")
|
||||
if sep:
|
||||
jar[name] = value
|
||||
return jar
|
||||
|
||||
def _has_session(self) -> bool:
|
||||
"""True when the request carries this process's session capability.
|
||||
|
||||
The cookie is set only when the SPA is served (index.html), so only
|
||||
the browser this process handed the page to can pass. A direct
|
||||
caller on an exposed port has no cookie and is rejected.
|
||||
"""
|
||||
supplied = self._cookies().get(SESSION_COOKIE, "")
|
||||
return bool(supplied) and secrets.compare_digest(supplied, state.session_token)
|
||||
|
||||
def _token_presented(self, query: dict[str, list[str]]) -> bool:
|
||||
"""True when the request carries the correct bootstrap token.
|
||||
|
||||
The token reaches the operator's browser through the URL printed /
|
||||
opened by the process that started the server, a channel an
|
||||
arbitrary network caller on an exposed port cannot observe.
|
||||
"""
|
||||
supplied = (query.get("token") or [""])[0]
|
||||
return bool(supplied) and secrets.compare_digest(supplied, state.session_token)
|
||||
|
||||
def _handle_static(self, path: str, query: dict[str, list[str]]) -> None:
|
||||
target = self._resolve_asset(path)
|
||||
if target is None:
|
||||
# SPA fallback: unknown non-asset routes render index.html so
|
||||
# client-side deep links work.
|
||||
target = state.assets_dir / "index.html"
|
||||
is_index = target.name == "index.html"
|
||||
if not target.is_file():
|
||||
self._send_json(HTTPStatus.NOT_FOUND, {"error": "not found"})
|
||||
return
|
||||
content = target.read_bytes()
|
||||
content_type, _ = mimetypes.guess_type(str(target))
|
||||
self.send_response(HTTPStatus.OK)
|
||||
self.send_header("Content-Type", content_type or "application/octet-stream")
|
||||
self.send_header("Content-Length", str(len(content)))
|
||||
if is_index and self._token_presented(query):
|
||||
# Exchange the bootstrap token for the per-process session
|
||||
# capability. Issued only when the correct token is presented,
|
||||
# so a caller who merely reaches ``/`` never obtains it.
|
||||
# HttpOnly (JS never needs it; fetch sends it automatically) and
|
||||
# SameSite=Strict (never sent from a cross-site context).
|
||||
self.send_header(
|
||||
"Set-Cookie",
|
||||
f"{SESSION_COOKIE}={state.session_token}; Path=/; HttpOnly; SameSite=Strict",
|
||||
)
|
||||
self.end_headers()
|
||||
self.wfile.write(content)
|
||||
|
||||
def _resolve_asset(self, path: str) -> Path | None:
|
||||
rel = unquote(path).lstrip("/")
|
||||
if not rel or rel.endswith("/"):
|
||||
return None
|
||||
root = state.assets_dir.resolve()
|
||||
candidate = (root / rel).resolve()
|
||||
# Path-traversal guard: never serve outside the bundle root.
|
||||
if root != candidate and root not in candidate.parents:
|
||||
logger.warning("viewer rejected traversal attempt: %s", path)
|
||||
return None
|
||||
return candidate if candidate.is_file() else None
|
||||
|
||||
def _send_json(self, status: HTTPStatus, payload: Any) -> None:
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
return ViewerHandler
|
||||
|
||||
|
||||
def authorized_url(base_url: str, token: str) -> str:
|
||||
"""URL that bootstraps the viewer session for the operator.
|
||||
|
||||
Presenting ``token`` on the initial page load is what mints the session
|
||||
cookie, so this URL is printed / opened only for the operator who started
|
||||
the server. Sharing it (rather than the bare ``base_url``) is what lets a
|
||||
trusted remote user authorize when the viewer is exposed with ``--host``.
|
||||
"""
|
||||
return f"{base_url}/?{urlencode({'token': token})}"
|
||||
|
||||
|
||||
def serve(
|
||||
run_dir: Path,
|
||||
*,
|
||||
host: str = "127.0.0.1",
|
||||
port: int = 0,
|
||||
open_browser: bool = True,
|
||||
steer_handler: Callable[[str, str], bool] | None = None,
|
||||
) -> tuple[ThreadingHTTPServer, str, str]:
|
||||
"""Start the viewer server on a background thread; return (server, url, token).
|
||||
|
||||
``url`` is the bare base; pass it through ``authorized_url(url, token)`` to
|
||||
build the operator link that authorizes the browser.
|
||||
|
||||
Binds an ephemeral port by default. If a fixed ``port`` is requested but in
|
||||
use, falls back to an ephemeral port. Reused by both the ``strix view``
|
||||
command and the in-TUI launcher; callers own the server's lifetime.
|
||||
|
||||
``steer_handler`` is supplied only by the in-TUI launcher, which runs inside
|
||||
the live scan process and can forward a message to a running agent. Left
|
||||
``None`` (standalone ``strix view``), steering is reported unavailable.
|
||||
"""
|
||||
assets_dir = bundle_dir()
|
||||
state = _ViewerState(run_dir=run_dir, assets_dir=assets_dir, steer_handler=steer_handler)
|
||||
handler = _make_handler(state)
|
||||
|
||||
try:
|
||||
httpd = ThreadingHTTPServer((host, port), handler)
|
||||
except OSError:
|
||||
if port == 0:
|
||||
raise
|
||||
logger.info("viewer port %s unavailable, falling back to an ephemeral port", port)
|
||||
httpd = ThreadingHTTPServer((host, 0), handler)
|
||||
|
||||
httpd.daemon_threads = True
|
||||
bound_port = int(httpd.server_address[1])
|
||||
url = f"http://{host}:{bound_port}"
|
||||
|
||||
thread = threading.Thread(target=httpd.serve_forever, name="strix-viewer", daemon=True)
|
||||
thread.start()
|
||||
|
||||
if open_browser:
|
||||
_open_browser(authorized_url(url, state.session_token))
|
||||
|
||||
return httpd, url, state.session_token
|
||||
|
||||
|
||||
def _open_browser(url: str) -> None:
|
||||
try:
|
||||
webbrowser.open(url)
|
||||
except Exception: # noqa: BLE001 - launching the browser is best-effort
|
||||
logger.debug("could not open browser for %s", url, exc_info=True)
|
||||
|
||||
|
||||
__all__ = ["authorized_url", "bundle_dir", "bundle_is_built", "serve"]
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Build the JSON payloads the viewer SPA consumes from a run directory."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from strix.core.paths import run_record_path
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_TERMINAL_STATUSES = {"completed", "stopped", "failed", "interrupted"}
|
||||
|
||||
_KNOWN_SEVERITIES = ("critical", "high", "medium", "low")
|
||||
|
||||
|
||||
def severity_counts(vulns: list[Any]) -> dict[str, int]:
|
||||
"""Bucket vulnerabilities into critical/high/medium/low counts.
|
||||
|
||||
Mirrors the SPA's ``severityCounts``: severities are lowercased and
|
||||
trimmed, and anything outside the four known buckets (``info``,
|
||||
``informational``, ``unknown``, missing, ...) folds into ``low`` so the
|
||||
shared UI renders cleanly.
|
||||
"""
|
||||
counts = dict.fromkeys(_KNOWN_SEVERITIES, 0)
|
||||
for vuln in vulns:
|
||||
raw = vuln.get("severity") if isinstance(vuln, dict) else None
|
||||
severity = str(raw or "").lower().strip()
|
||||
if severity not in counts:
|
||||
severity = "low"
|
||||
counts[severity] += 1
|
||||
return counts
|
||||
|
||||
|
||||
def build_run_state(run_dir: Path) -> dict[str, Any]:
|
||||
"""Agent graph + full per-agent event/message stream.
|
||||
|
||||
Reuses the Textual-free ``TuiLiveView`` projection so the viewer and the TUI
|
||||
share one parser for ``agents.json`` + ``agents.db`` and never drift.
|
||||
"""
|
||||
# Imported lazily so importing strix.viewer does not eagerly pull the TUI.
|
||||
from strix.interface.tui.live_view import TuiLiveView # noqa: PLC0415
|
||||
|
||||
view = TuiLiveView()
|
||||
view.hydrate_from_run_dir(run_dir)
|
||||
return {"agents": list(view.agents.values()), "events": view.events}
|
||||
|
||||
|
||||
def read_run_summary(run_dir: Path) -> dict[str, Any]:
|
||||
"""The ``run.json`` record plus a computed ``finished`` flag."""
|
||||
record = _load_json(run_record_path(run_dir), default={})
|
||||
if not isinstance(record, dict):
|
||||
record = {}
|
||||
status = record.get("status")
|
||||
finished = status in _TERMINAL_STATUSES and bool(record.get("end_time"))
|
||||
return {**record, "finished": finished}
|
||||
|
||||
|
||||
def primary_target(record: dict[str, Any]) -> str | None:
|
||||
"""The first target's original string from a run record, or None."""
|
||||
targets = record.get("targets_info")
|
||||
if isinstance(targets, list):
|
||||
for entry in targets:
|
||||
if isinstance(entry, dict):
|
||||
original = entry.get("original")
|
||||
if isinstance(original, str) and original:
|
||||
return original
|
||||
return None
|
||||
|
||||
|
||||
def read_vulnerabilities(run_dir: Path) -> list[Any]:
|
||||
"""The ``vulnerabilities.json`` list (empty until a scan writes it)."""
|
||||
data = _load_json(run_dir / "vulnerabilities.json", default=[])
|
||||
return data if isinstance(data, list) else []
|
||||
|
||||
|
||||
def read_report_markdown(run_dir: Path) -> str:
|
||||
"""The executive report markdown (empty until a scan writes it)."""
|
||||
report_path = run_dir / "penetration_test_report.md"
|
||||
try:
|
||||
return report_path.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
|
||||
def _load_json(path: Path, *, default: Any) -> Any:
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return default
|
||||
|
||||
|
||||
__all__ = [
|
||||
"build_run_state",
|
||||
"primary_target",
|
||||
"read_report_markdown",
|
||||
"read_run_summary",
|
||||
"read_vulnerabilities",
|
||||
"severity_counts",
|
||||
]
|
||||
@@ -0,0 +1,10 @@
|
||||
pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}/*!
|
||||
Theme: GitHub Dark
|
||||
Description: Dark theme as seen on github.com
|
||||
Author: github.com
|
||||
Maintainer: @Hirse
|
||||
Updated: 2021-05-15
|
||||
|
||||
Outdated base version: https://github.com/primer/github-syntax-dark
|
||||
Current colors taken from GitHub's CSS
|
||||
*/.hljs{color:#c9d1d9;background:#0d1117}.hljs-doctag,.hljs-keyword,.hljs-meta .hljs-keyword,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable.language_{color:#ff7b72}.hljs-title,.hljs-title.class_,.hljs-title.class_.inherited__,.hljs-title.function_{color:#d2a8ff}.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-variable,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id{color:#79c0ff}.hljs-regexp,.hljs-string,.hljs-meta .hljs-string{color:#a5d6ff}.hljs-built_in,.hljs-symbol{color:#ffa657}.hljs-comment,.hljs-code,.hljs-formula{color:#8b949e}.hljs-name,.hljs-quote,.hljs-selector-tag,.hljs-selector-pseudo{color:#7ee787}.hljs-subst{color:#c9d1d9}.hljs-section{color:#1f6feb;font-weight:700}.hljs-bullet{color:#f2cc60}.hljs-emphasis{color:#c9d1d9;font-style:italic}.hljs-strong{color:#c9d1d9;font-weight:700}.hljs-addition{color:#aff5b4;background-color:#033a16}.hljs-deletion{color:#ffdcd7;background-color:#67060c}.react-flow{direction:ltr;--xy-edge-stroke-default: #b1b1b7;--xy-edge-stroke-width-default: 1;--xy-edge-stroke-selected-default: #555;--xy-connectionline-stroke-default: #b1b1b7;--xy-connectionline-stroke-width-default: 1;--xy-attribution-background-color-default: rgba(255, 255, 255, .5);--xy-minimap-background-color-default: #fff;--xy-minimap-mask-background-color-default: rgba(240, 240, 240, .6);--xy-minimap-mask-stroke-color-default: transparent;--xy-minimap-mask-stroke-width-default: 1;--xy-minimap-node-background-color-default: #e2e2e2;--xy-minimap-node-stroke-color-default: transparent;--xy-minimap-node-stroke-width-default: 2;--xy-background-color-default: transparent;--xy-background-pattern-dots-color-default: #91919a;--xy-background-pattern-lines-color-default: #eee;--xy-background-pattern-cross-color-default: #e2e2e2;background-color:var(--xy-background-color, var(--xy-background-color-default));--xy-node-color-default: inherit;--xy-node-border-default: 1px solid #1a192b;--xy-node-background-color-default: #fff;--xy-node-group-background-color-default: rgba(240, 240, 240, .25);--xy-node-boxshadow-hover-default: 0 1px 4px 1px rgba(0, 0, 0, .08);--xy-node-boxshadow-selected-default: 0 0 0 .5px #1a192b;--xy-node-border-radius-default: 3px;--xy-handle-background-color-default: #1a192b;--xy-handle-border-color-default: #fff;--xy-selection-background-color-default: rgba(0, 89, 220, .08);--xy-selection-border-default: 1px dotted rgba(0, 89, 220, .8);--xy-controls-button-background-color-default: #fefefe;--xy-controls-button-background-color-hover-default: #f4f4f4;--xy-controls-button-color-default: inherit;--xy-controls-button-color-hover-default: inherit;--xy-controls-button-border-color-default: #eee;--xy-controls-box-shadow-default: 0 0 2px 1px rgba(0, 0, 0, .08);--xy-edge-label-background-color-default: #ffffff;--xy-edge-label-color-default: inherit;--xy-resize-background-color-default: #3367d9}.react-flow.dark{--xy-edge-stroke-default: #3e3e3e;--xy-edge-stroke-width-default: 1;--xy-edge-stroke-selected-default: #727272;--xy-connectionline-stroke-default: #b1b1b7;--xy-connectionline-stroke-width-default: 1;--xy-attribution-background-color-default: rgba(150, 150, 150, .25);--xy-minimap-background-color-default: #141414;--xy-minimap-mask-background-color-default: rgba(60, 60, 60, .6);--xy-minimap-mask-stroke-color-default: transparent;--xy-minimap-mask-stroke-width-default: 1;--xy-minimap-node-background-color-default: #2b2b2b;--xy-minimap-node-stroke-color-default: transparent;--xy-minimap-node-stroke-width-default: 2;--xy-background-color-default: #141414;--xy-background-pattern-dots-color-default: #555;--xy-background-pattern-lines-color-default: #333;--xy-background-pattern-cross-color-default: #333;--xy-node-color-default: #f8f8f8;--xy-node-border-default: 1px solid #3c3c3c;--xy-node-background-color-default: #1e1e1e;--xy-node-group-background-color-default: rgba(240, 240, 240, .25);--xy-node-boxshadow-hover-default: 0 1px 4px 1px rgba(255, 255, 255, .08);--xy-node-boxshadow-selected-default: 0 0 0 .5px #999;--xy-handle-background-color-default: #bebebe;--xy-handle-border-color-default: #1e1e1e;--xy-selection-background-color-default: rgba(200, 200, 220, .08);--xy-selection-border-default: 1px dotted rgba(200, 200, 220, .8);--xy-controls-button-background-color-default: #2b2b2b;--xy-controls-button-background-color-hover-default: #3e3e3e;--xy-controls-button-color-default: #f8f8f8;--xy-controls-button-color-hover-default: #fff;--xy-controls-button-border-color-default: #5b5b5b;--xy-controls-box-shadow-default: 0 0 2px 1px rgba(0, 0, 0, .08);--xy-edge-label-background-color-default: #141414;--xy-edge-label-color-default: #f8f8f8}.react-flow__background{background-color:var(--xy-background-color-props, var(--xy-background-color, var(--xy-background-color-default)));pointer-events:none;z-index:-1}.react-flow__container{position:absolute;width:100%;height:100%;top:0;left:0}.react-flow__pane{z-index:1;touch-action:none}.react-flow__panLine truncated
|
||||
@@ -0,0 +1,512 @@
|
||||
(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const s of document.querySelectorAll('link[rel="modulepreload"]'))a(s);new MutationObserver(s=>{for(const l of s)if(l.type==="childList")for(const c of l.addedNodes)c.tagName==="LINK"&&c.rel==="modulepreload"&&a(c)}).observe(document,{childList:!0,subtree:!0});function i(s){const l={};return s.integrity&&(l.integrity=s.integrity),s.referrerPolicy&&(l.referrerPolicy=s.referrerPolicy),s.crossOrigin==="use-credentials"?l.credentials="include":s.crossOrigin==="anonymous"?l.credentials="omit":l.credentials="same-origin",l}function a(s){if(s.ep)return;s.ep=!0;const l=i(s);fetch(s.href,l)}})();function yl(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}var th={exports:{}},$o={};/**
|
||||
* @license React
|
||||
* react-jsx-runtime.production.js
|
||||
*
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/var jx;function e2(){if(jx)return $o;jx=1;var e=Symbol.for("react.transitional.element"),t=Symbol.for("react.fragment");function i(a,s,l){var c=null;if(l!==void 0&&(c=""+l),s.key!==void 0&&(c=""+s.key),"key"in s){l={};for(var d in s)d!=="key"&&(l[d]=s[d])}else l=s;return s=l.ref,{$$typeof:e,type:a,key:c,ref:s!==void 0?s:null,props:l}}return $o.Fragment=t,$o.jsx=i,$o.jsxs=i,$o}var zx;function t2(){return zx||(zx=1,th.exports=e2()),th.exports}var g=t2(),nh={exports:{}},Ve={};/**
|
||||
* @license React
|
||||
* react.production.js
|
||||
*
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/var Ix;function n2(){if(Ix)return Ve;Ix=1;var e=Symbol.for("react.transitional.element"),t=Symbol.for("react.portal"),i=Symbol.for("react.fragment"),a=Symbol.for("react.strict_mode"),s=Symbol.for("react.profiler"),l=Symbol.for("react.consumer"),c=Symbol.for("react.context"),d=Symbol.for("react.forward_ref"),f=Symbol.for("react.suspense"),h=Symbol.for("react.memo"),m=Symbol.for("react.lazy"),p=Symbol.for("react.activity"),x=Symbol.iterator;function y(j){return j===null||typeof j!="object"?null:(j=x&&j[x]||j["@@iterator"],typeof j=="function"?j:null)}var _={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},N=Object.assign,S={};function w(j,V,D){this.props=j,this.context=V,this.refs=S,this.updater=D||_}w.prototype.isReactComponent={},w.prototype.setState=function(j,V){if(typeof j!="object"&&typeof j!="function"&&j!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,j,V,"setState")},w.prototype.forceUpdate=function(j){this.updater.enqueueForceUpdate(this,j,"forceUpdate")};function k(){}k.prototype=w.prototype;function E(j,V,D){this.props=j,this.context=V,this.refs=S,this.updater=D||_}var O=E.prototype=new k;O.constructor=E,N(O,w.prototype),O.isPureReactComponent=!0;var U=Array.isArray;function R(){}var B={H:null,A:null,T:null,S:null},I=Object.prototype.hasOwnProperty;function Z(j,V,D){var F=D.ref;return{$$typeof:e,type:j,key:V,ref:F!==void 0?F:null,props:D}}function L(j,V){return Z(j.type,V,j.props)}function z(j){return typeof j=="object"&&j!==null&&j.$$typeof===e}function K(j){var V={"=":"=0",":":"=2"};return"$"+j.replace(/[=:]/g,function(D){return V[D]})}var G=/\/+/g;function C(j,V){return typeof j=="object"&&j!==null&&j.key!=null?K(""+j.key):V.toString(36)}function q(j){switch(j.status){case"fulfilled":return j.value;case"rejected":throw j.reason;default:switch(typeof j.status=="string"?j.then(R,R):(j.status="pending",j.then(function(V){j.status==="pending"&&(j.status="fulfilled",j.value=V)},function(V){j.status==="pending"&&(j.status="rejected",j.reason=V)})),j.status){case"fulfilled":return j.value;case"rejected":throw j.reason}}throw j}function M(j,V,D,F,$){var Q=typeof j;(Q==="undefined"||Q==="boolean")&&(j=null);var J=!1;if(j===null)J=!0;else switch(Q){case"bigint":case"string":case"number":J=!0;break;case"object":switch(j.$$typeof){case e:case t:J=!0;break;case m:return J=j._init,M(J(j._payload),V,D,F,$)}}if(J)return $=$(j),J=F===""?"."+C(j,0):F,U($)?(D="",J!=null&&(D=J.replace(G,"$&/")+"/"),M($,V,D,"",function(le){return le})):$!=null&&(z($)&&($=L($,D+($.key==null||j&&j.key===$.key?"":(""+$.key).replace(G,"$&/")+"/")+J)),V.push($)),1;J=0;var W=F===""?".":F+":";if(U(j))for(var ee=0;ee<j.length;ee++)F=j[ee],Q=W+C(F,ee),J+=M(F,V,D,Q,$);else if(ee=y(j),typeof ee=="function")for(j=ee.call(j),ee=0;!(F=j.next()).done;)F=F.value,Q=W+C(F,ee++),J+=M(F,V,D,Q,$);else if(Q==="object"){if(typeof j.then=="function")return M(q(j),V,D,F,$);throw V=String(j),Error("Objects are not valid as a React child (found: "+(V==="[object Object]"?"object with keys {"+Object.keys(j).join(", ")+"}":V)+"). If you meant to render a collection of children, use an array instead.")}return J}function H(j,V,D){if(j==null)return j;var F=[],$=0;return M(j,F,"","",function(Q){return V.call(D,Q,$++)}),F}function X(j){if(j._status===-1){var V=j._result;V=V(),V.then(function(D){(j._status===0||j._status===-1)&&(j._status=1,j._result=D)},function(D){(j._status===0||j._status===-1)&&(j._status=2,j._result=D)}),j._status===-1&&(j._status=0,j._result=V)}if(j._status===1)return j._result.default;throw j._result}var Y=typeof reportError=="function"?reportError:function(j){if(typeof window=="object"&&typeof window.ErrorEvent=="function"){var V=new window.ErrorEvent("error",{bubbles:!0,cancelable:!0,message:typeof j=="object"&&j!==null&&typeof j.message=="string"?String(j.message):String(j),error:j});if(!window.dispatchEvent(V))return}else if(typeof process=="object"&&typeof process.emit=="function"){process.emit("uncaughtException",j);return}console.error(j)},A={map:H,forEach:function(j,V,D){H(j,function(){V.apply(this,arguments)},D)},count:function(j){var V=0;return H(j,function(){V++}),V},toArray:function(j){return H(j,function(V){return V})||[]},only:function(j){if(!z(j))throw Error("React.Children.only expected to receive a single React element child.");return j}};return Ve.Activity=p,Ve.Children=A,Ve.Component=w,Ve.Fragment=i,Ve.Profiler=s,Ve.PureComponent=E,Ve.StrictMode=a,Ve.Suspense=f,Ve.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE=B,Ve.__COMPILER_RUNTIME={__proto__:null,c:function(j){return B.H.useMemoCache(j)}},Ve.cache=function(j){return function(){return j.apply(null,arguments)}},Ve.cacheSignal=function(){return null},Ve.cloneElement=function(j,V,D){if(j==null)throw Error("The argument must be a React element, but you passed "+j+".");var
Line truncated
|
||||
* @license React
|
||||
* scheduler.production.js
|
||||
*
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/var Ux;function r2(){return Ux||(Ux=1,(function(e){function t(M,H){var X=M.length;M.push(H);e:for(;0<X;){var Y=X-1>>>1,A=M[Y];if(0<s(A,H))M[Y]=H,M[X]=A,X=Y;else break e}}function i(M){return M.length===0?null:M[0]}function a(M){if(M.length===0)return null;var H=M[0],X=M.pop();if(X!==H){M[0]=X;e:for(var Y=0,A=M.length,j=A>>>1;Y<j;){var V=2*(Y+1)-1,D=M[V],F=V+1,$=M[F];if(0>s(D,X))F<A&&0>s($,D)?(M[Y]=$,M[F]=X,Y=F):(M[Y]=D,M[V]=X,Y=V);else if(F<A&&0>s($,X))M[Y]=$,M[F]=X,Y=F;else break e}}return H}function s(M,H){var X=M.sortIndex-H.sortIndex;return X!==0?X:M.id-H.id}if(e.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var l=performance;e.unstable_now=function(){return l.now()}}else{var c=Date,d=c.now();e.unstable_now=function(){return c.now()-d}}var f=[],h=[],m=1,p=null,x=3,y=!1,_=!1,N=!1,S=!1,w=typeof setTimeout=="function"?setTimeout:null,k=typeof clearTimeout=="function"?clearTimeout:null,E=typeof setImmediate<"u"?setImmediate:null;function O(M){for(var H=i(h);H!==null;){if(H.callback===null)a(h);else if(H.startTime<=M)a(h),H.sortIndex=H.expirationTime,t(f,H);else break;H=i(h)}}function U(M){if(N=!1,O(M),!_)if(i(f)!==null)_=!0,R||(R=!0,K());else{var H=i(h);H!==null&&q(U,H.startTime-M)}}var R=!1,B=-1,I=5,Z=-1;function L(){return S?!0:!(e.unstable_now()-Z<I)}function z(){if(S=!1,R){var M=e.unstable_now();Z=M;var H=!0;try{e:{_=!1,N&&(N=!1,k(B),B=-1),y=!0;var X=x;try{t:{for(O(M),p=i(f);p!==null&&!(p.expirationTime>M&&L());){var Y=p.callback;if(typeof Y=="function"){p.callback=null,x=p.priorityLevel;var A=Y(p.expirationTime<=M);if(M=e.unstable_now(),typeof A=="function"){p.callback=A,O(M),H=!0;break t}p===i(f)&&a(f),O(M)}else a(f);p=i(f)}if(p!==null)H=!0;else{var j=i(h);j!==null&&q(U,j.startTime-M),H=!1}}break e}finally{p=null,x=X,y=!1}H=void 0}}finally{H?K():R=!1}}}var K;if(typeof E=="function")K=function(){E(z)};else if(typeof MessageChannel<"u"){var G=new MessageChannel,C=G.port2;G.port1.onmessage=z,K=function(){C.postMessage(null)}}else K=function(){w(z,0)};function q(M,H){B=w(function(){M(e.unstable_now())},H)}e.unstable_IdlePriority=5,e.unstable_ImmediatePriority=1,e.unstable_LowPriority=4,e.unstable_NormalPriority=3,e.unstable_Profiling=null,e.unstable_UserBlockingPriority=2,e.unstable_cancelCallback=function(M){M.callback=null},e.unstable_forceFrameRate=function(M){0>M||125<M?console.error("forceFrameRate takes a positive int between 0 and 125, forcing frame rates higher than 125 fps is not supported"):I=0<M?Math.floor(1e3/M):5},e.unstable_getCurrentPriorityLevel=function(){return x},e.unstable_next=function(M){switch(x){case 1:case 2:case 3:var H=3;break;default:H=x}var X=x;x=H;try{return M()}finally{x=X}},e.unstable_requestPaint=function(){S=!0},e.unstable_runWithPriority=function(M,H){switch(M){case 1:case 2:case 3:case 4:case 5:break;default:M=3}var X=x;x=M;try{return H()}finally{x=X}},e.unstable_scheduleCallback=function(M,H,X){var Y=e.unstable_now();switch(typeof X=="object"&&X!==null?(X=X.delay,X=typeof X=="number"&&0<X?Y+X:Y):X=Y,M){case 1:var A=-1;break;case 2:A=250;break;case 5:A=1073741823;break;case 4:A=1e4;break;default:A=5e3}return A=X+A,M={id:m++,callback:H,priorityLevel:M,startTime:X,expirationTime:A,sortIndex:-1},X>Y?(M.sortIndex=X,t(h,M),i(f)===null&&M===i(h)&&(N?(k(B),B=-1):N=!0,q(U,X-Y))):(M.sortIndex=A,t(f,M),_||y||(_=!0,R||(R=!0,K()))),M},e.unstable_shouldYield=L,e.unstable_wrapCallback=function(M){var H=x;return function(){var X=x;x=H;try{return M.apply(this,arguments)}finally{x=X}}}})(ah)),ah}var Hx;function a2(){return Hx||(Hx=1,rh.exports=r2()),rh.exports}var sh={exports:{}},kn={};/**
|
||||
* @license React
|
||||
* react-dom.production.js
|
||||
*
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/var $x;function s2(){if($x)return kn;$x=1;var e=vl();function t(f){var h="https://react.dev/errors/"+f;if(1<arguments.length){h+="?args[]="+encodeURIComponent(arguments[1]);for(var m=2;m<arguments.length;m++)h+="&args[]="+encodeURIComponent(arguments[m])}return"Minified React error #"+f+"; visit "+h+" for the full message or use the non-minified dev environment for full errors and additional helpful warnings."}function i(){}var a={d:{f:i,r:function(){throw Error(t(522))},D:i,C:i,L:i,m:i,X:i,S:i,M:i},p:0,findDOMNode:null},s=Symbol.for("react.portal");function l(f,h,m){var p=3<arguments.length&&arguments[3]!==void 0?arguments[3]:null;return{$$typeof:s,key:p==null?null:""+p,children:f,containerInfo:h,implementation:m}}var c=e.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;function d(f,h){if(f==="font")return"";if(typeof h=="string")return h==="use-credentials"?h:""}return kn.__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE=a,kn.createPortal=function(f,h){var m=2<arguments.length&&arguments[2]!==void 0?arguments[2]:null;if(!h||h.nodeType!==1&&h.nodeType!==9&&h.nodeType!==11)throw Error(t(299));return l(f,h,null,m)},kn.flushSync=function(f){var h=c.T,m=a.p;try{if(c.T=null,a.p=2,f)return f()}finally{c.T=h,a.p=m,a.d.f()}},kn.preconnect=function(f,h){typeof f=="string"&&(h?(h=h.crossOrigin,h=typeof h=="string"?h==="use-credentials"?h:"":void 0):h=null,a.d.C(f,h))},kn.prefetchDNS=function(f){typeof f=="string"&&a.d.D(f)},kn.preinit=function(f,h){if(typeof f=="string"&&h&&typeof h.as=="string"){var m=h.as,p=d(m,h.crossOrigin),x=typeof h.integrity=="string"?h.integrity:void 0,y=typeof h.fetchPriority=="string"?h.fetchPriority:void 0;m==="style"?a.d.S(f,typeof h.precedence=="string"?h.precedence:void 0,{crossOrigin:p,integrity:x,fetchPriority:y}):m==="script"&&a.d.X(f,{crossOrigin:p,integrity:x,fetchPriority:y,nonce:typeof h.nonce=="string"?h.nonce:void 0})}},kn.preinitModule=function(f,h){if(typeof f=="string")if(typeof h=="object"&&h!==null){if(h.as==null||h.as==="script"){var m=d(h.as,h.crossOrigin);a.d.M(f,{crossOrigin:m,integrity:typeof h.integrity=="string"?h.integrity:void 0,nonce:typeof h.nonce=="string"?h.nonce:void 0})}}else h==null&&a.d.M(f)},kn.preload=function(f,h){if(typeof f=="string"&&typeof h=="object"&&h!==null&&typeof h.as=="string"){var m=h.as,p=d(m,h.crossOrigin);a.d.L(f,m,{crossOrigin:p,integrity:typeof h.integrity=="string"?h.integrity:void 0,nonce:typeof h.nonce=="string"?h.nonce:void 0,type:typeof h.type=="string"?h.type:void 0,fetchPriority:typeof h.fetchPriority=="string"?h.fetchPriority:void 0,referrerPolicy:typeof h.referrerPolicy=="string"?h.referrerPolicy:void 0,imageSrcSet:typeof h.imageSrcSet=="string"?h.imageSrcSet:void 0,imageSizes:typeof h.imageSizes=="string"?h.imageSizes:void 0,media:typeof h.media=="string"?h.media:void 0})}},kn.preloadModule=function(f,h){if(typeof f=="string")if(h){var m=d(h.as,h.crossOrigin);a.d.m(f,{as:typeof h.as=="string"&&h.as!=="script"?h.as:void 0,crossOrigin:m,integrity:typeof h.integrity=="string"?h.integrity:void 0})}else a.d.m(f)},kn.requestFormReset=function(f){a.d.r(f)},kn.unstable_batchedUpdates=function(f,h){return f(h)},kn.useFormState=function(f,h,m){return c.H.useFormState(f,h,m)},kn.useFormStatus=function(){return c.H.useHostTransitionStatus()},kn.version="19.2.7",kn}var qx;function u_(){if(qx)return sh.exports;qx=1;function e(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(e)}catch(t){console.error(t)}}return e(),sh.exports=s2(),sh.exports}/**
|
||||
* @license React
|
||||
* react-dom-client.production.js
|
||||
*
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/var Px;function o2(){if(Px)return qo;Px=1;var e=a2(),t=vl(),i=u_();function a(n){var r="https://react.dev/errors/"+n;if(1<arguments.length){r+="?args[]="+encodeURIComponent(arguments[1]);for(var o=2;o<arguments.length;o++)r+="&args[]="+encodeURIComponent(arguments[o])}return"Minified React error #"+n+"; visit "+r+" for the full message or use the non-minified dev environment for full errors and additional helpful warnings."}function s(n){return!(!n||n.nodeType!==1&&n.nodeType!==9&&n.nodeType!==11)}function l(n){var r=n,o=n;if(n.alternate)for(;r.return;)r=r.return;else{n=r;do r=n,(r.flags&4098)!==0&&(o=r.return),n=r.return;while(n)}return r.tag===3?o:null}function c(n){if(n.tag===13){var r=n.memoizedState;if(r===null&&(n=n.alternate,n!==null&&(r=n.memoizedState)),r!==null)return r.dehydrated}return null}function d(n){if(n.tag===31){var r=n.memoizedState;if(r===null&&(n=n.alternate,n!==null&&(r=n.memoizedState)),r!==null)return r.dehydrated}return null}function f(n){if(l(n)!==n)throw Error(a(188))}function h(n){var r=n.alternate;if(!r){if(r=l(n),r===null)throw Error(a(188));return r!==n?null:n}for(var o=n,u=r;;){var b=o.return;if(b===null)break;var v=b.alternate;if(v===null){if(u=b.return,u!==null){o=u;continue}break}if(b.child===v.child){for(v=b.child;v;){if(v===o)return f(b),n;if(v===u)return f(b),r;v=v.sibling}throw Error(a(188))}if(o.return!==u.return)o=b,u=v;else{for(var T=!1,P=b.child;P;){if(P===o){T=!0,o=b,u=v;break}if(P===u){T=!0,u=b,o=v;break}P=P.sibling}if(!T){for(P=v.child;P;){if(P===o){T=!0,o=v,u=b;break}if(P===u){T=!0,u=v,o=b;break}P=P.sibling}if(!T)throw Error(a(189))}}if(o.alternate!==u)throw Error(a(190))}if(o.tag!==3)throw Error(a(188));return o.stateNode.current===o?n:r}function m(n){var r=n.tag;if(r===5||r===26||r===27||r===6)return n;for(n=n.child;n!==null;){if(r=m(n),r!==null)return r;n=n.sibling}return null}var p=Object.assign,x=Symbol.for("react.element"),y=Symbol.for("react.transitional.element"),_=Symbol.for("react.portal"),N=Symbol.for("react.fragment"),S=Symbol.for("react.strict_mode"),w=Symbol.for("react.profiler"),k=Symbol.for("react.consumer"),E=Symbol.for("react.context"),O=Symbol.for("react.forward_ref"),U=Symbol.for("react.suspense"),R=Symbol.for("react.suspense_list"),B=Symbol.for("react.memo"),I=Symbol.for("react.lazy"),Z=Symbol.for("react.activity"),L=Symbol.for("react.memo_cache_sentinel"),z=Symbol.iterator;function K(n){return n===null||typeof n!="object"?null:(n=z&&n[z]||n["@@iterator"],typeof n=="function"?n:null)}var G=Symbol.for("react.client.reference");function C(n){if(n==null)return null;if(typeof n=="function")return n.$$typeof===G?null:n.displayName||n.name||null;if(typeof n=="string")return n;switch(n){case N:return"Fragment";case w:return"Profiler";case S:return"StrictMode";case U:return"Suspense";case R:return"SuspenseList";case Z:return"Activity"}if(typeof n=="object")switch(n.$$typeof){case _:return"Portal";case E:return n.displayName||"Context";case k:return(n._context.displayName||"Context")+".Consumer";case O:var r=n.render;return n=n.displayName,n||(n=r.displayName||r.name||"",n=n!==""?"ForwardRef("+n+")":"ForwardRef"),n;case B:return r=n.displayName||null,r!==null?r:C(n.type)||"Memo";case I:r=n._payload,n=n._init;try{return C(n(r))}catch{}}return null}var q=Array.isArray,M=t.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE,H=i.__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE,X={pending:!1,data:null,method:null,action:null},Y=[],A=-1;function j(n){return{current:n}}function V(n){0>A||(n.current=Y[A],Y[A]=null,A--)}function D(n,r){A++,Y[A]=n.current,n.current=r}var F=j(null),$=j(null),Q=j(null),J=j(null);function W(n,r){switch(D(Q,r),D($,n),D(F,null),r.nodeType){case 9:case 11:n=(n=r.documentElement)&&(n=n.namespaceURI)?rx(n):0;break;default:if(n=r.tagName,r=r.namespaceURI)r=rx(r),n=ax(r,n);else switch(n){case"svg":n=1;break;case"math":n=2;break;default:n=0}}V(F),D(F,n)}function ee(){V(F),V($),V(Q)}function le(n){n.memoizedState!==null&&D(J,n);var r=F.current,o=ax(r,n.type);r!==o&&(D($,n),D(F,o))}function fe(n){$.current===n&&(V(F),V($)),J.current===n&&(V(J),Io._currentValue=X)}var be,we;function Ne(n){if(be===void 0)try{throw Error()}catch(o){var r=o.stack.trim().match(/\n( *(at )?)/);be=r&&r[1]||"",we=-1<o.stack.indexOf(`
|
||||
at`)?" (<anonymous>)":-1<o.stack.indexOf("@")?"@unknown:0:0":""}return`
|
||||
`+be+n+we}var Le=!1;function $e(n,r){if(!n||Le)return"";Le=!0;var o=Error.prepareStackTrace;Error.prepareStackTrace=void 0;try{var u={DetermineComponentFrameRoot:function(){try{if(r){var ge=function(){throw Error()};if(Object.defineProperty(ge.prototype,"props",{set:function(){throw Error()}}),typeof Reflect=="object"&&Reflect.construct){try{Reflect.construct(ge,[])}catch(de){var ce=de}Reflect.construct(n,[],ge)}else{try{ge.call()}catch(de){ce=de}n.call(ge.prototype)}}else{try{throw Error()}catch(de){ce=de}(ge=n())&&typeof ge.catch=="function"&&ge.catch(function(){})}}catch(de){if(de&&ce&&typeof de.stack=="string")return[de.stack,ce.stack]}return[null,null]}};u.DetermineComponentFrameRoot.displayName="DetermineComponentFrameRoot";var b=Object.getOwnPropertyDescriptor(u.DetermineComponentFrameRoot,"name");b&&b.configurable&&Object.defineProperty(u.DetermineComponentFrameRoot,"name",{value:"DetermineComponentFrameRoot"});var v=u.DetermineComponentFrameRoot(),T=v[0],P=v[1];if(T&&P){var ne=T.split(`
|
||||
`),oe=P.split(`
|
||||
`);for(b=u=0;u<ne.length&&!ne[u].includes("DetermineComponentFrameRoot");)u++;for(;b<oe.length&&!oe[b].includes("DetermineComponentFrameRoot");)b++;if(u===ne.length||b===oe.length)for(u=ne.length-1,b=oe.length-1;1<=u&&0<=b&&ne[u]!==oe[b];)b--;for(;1<=u&&0<=b;u--,b--)if(ne[u]!==oe[b]){if(u!==1||b!==1)do if(u--,b--,0>b||ne[u]!==oe[b]){var he=`
|
||||
`+ne[u].replace(" at new "," at ");return n.displayName&&he.includes("<anonymous>")&&(he=he.replace("<anonymous>",n.displayName)),he}while(1<=u&&0<=b);break}}}finally{Le=!1,Error.prepareStackTrace=o}return(o=n?n.displayName||n.name:"")?Ne(o):""}function st(n,r){switch(n.tag){case 26:case 27:case 5:return Ne(n.type);case 16:return Ne("Lazy");case 13:return n.child!==r&&r!==null?Ne("Suspense Fallback"):Ne("Suspense");case 19:return Ne("SuspenseList");case 0:case 15:return $e(n.type,!1);case 11:return $e(n.type.render,!1);case 1:return $e(n.type,!0);case 31:return Ne("Activity");default:return""}}function Rt(n){try{var r="",o=null;do r+=st(n,o),o=n,n=n.return;while(n);return r}catch(u){return`
|
||||
Error generating stack: `+u.message+`
|
||||
`+u.stack}}var Yt=Object.prototype.hasOwnProperty,Pt=e.unstable_scheduleCallback,Xt=e.unstable_cancelCallback,Vn=e.unstable_shouldYield,wn=e.unstable_requestPaint,ct=e.unstable_now,It=e.unstable_getCurrentPriorityLevel,ue=e.unstable_ImmediatePriority,xe=e.unstable_UserBlockingPriority,Oe=e.unstable_NormalPriority,Fe=e.unstable_LowPriority,Ze=e.unstable_IdlePriority,ln=e.log,En=e.unstable_setDisableYieldValue,Kt=null,At=null;function Wt(n){if(typeof ln=="function"&&En(n),At&&typeof At.setStrictMode=="function")try{At.setStrictMode(Kt,n)}catch{}}var ut=Math.clz32?Math.clz32:vr,jn=Math.log,cn=Math.LN2;function vr(n){return n>>>=0,n===0?32:31-(jn(n)/cn|0)|0}var nt=256,Yn=262144,An=4194304;function hn(n){var r=n&42;if(r!==0)return r;switch(n&-n){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return n&261888;case 262144:case 524288:case 1048576:case 2097152:return n&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return n&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return n}}function ie(n,r,o){var u=n.pendingLanes;if(u===0)return 0;var b=0,v=n.suspendedLanes,T=n.pingedLanes;n=n.warmLanes;var P=u&134217727;return P!==0?(u=P&~v,u!==0?b=hn(u):(T&=P,T!==0?b=hn(T):o||(o=P&~n,o!==0&&(b=hn(o))))):(P=u&~v,P!==0?b=hn(P):T!==0?b=hn(T):o||(o=u&~n,o!==0&&(b=hn(o)))),b===0?0:r!==0&&r!==b&&(r&v)===0&&(v=b&-b,o=r&-r,v>=o||v===32&&(o&4194048)!==0)?r:b}function me(n,r){return(n.pendingLanes&~(n.suspendedLanes&~n.pingedLanes)&r)===0}function Ee(n,r){switch(n){case 1:case 2:case 4:case 8:case 64:return r+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return r+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function Pe(){var n=An;return An<<=1,(An&62914560)===0&&(An=4194304),n}function St(n){for(var r=[],o=0;31>o;o++)r.push(n);return r}function gt(n,r){n.pendingLanes|=r,r!==268435456&&(n.suspendedLanes=0,n.pingedLanes=0,n.warmLanes=0)}function Me(n,r,o,u,b,v){var T=n.pendingLanes;n.pendingLanes=o,n.suspendedLanes=0,n.pingedLanes=0,n.warmLanes=0,n.expiredLanes&=o,n.entangledLanes&=o,n.errorRecoveryDisabledLanes&=o,n.shellSuspendCounter=0;var P=n.entanglements,ne=n.expirationTimes,oe=n.hiddenUpdates;for(o=T&~o;0<o;){var he=31-ut(o),ge=1<<he;P[he]=0,ne[he]=-1;var ce=oe[he];if(ce!==null)for(oe[he]=null,he=0;he<ce.length;he++){var de=ce[he];de!==null&&(de.lane&=-536870913)}o&=~ge}u!==0&&Se(n,u,0),v!==0&&b===0&&n.tag!==0&&(n.suspendedLanes|=v&~(T&~r))}function Se(n,r,o){n.pendingLanes|=r,n.suspendedLanes&=~r;var u=31-ut(r);n.entangledLanes|=r,n.entanglements[u]=n.entanglements[u]|1073741824|o&261930}function Ue(n,r){var o=n.entangledLanes|=r;for(n=n.entanglements;o;){var u=31-ut(o),b=1<<u;b&r|n[u]&r&&(n[u]|=r),o&=~b}}function Bt(n,r){var o=r&-r;return o=(o&42)!==0?1:Mt(o),(o&(n.suspendedLanes|r))!==0?0:o}function Mt(n){switch(n){case 2:n=1;break;case 8:n=4;break;case 32:n=16;break;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:case 4194304:case 8388608:case 16777216:case 33554432:n=128;break;case 268435456:n=134217728;break;default:n=0}return n}function gi(n){return n&=-n,2<n?8<n?(n&134217727)!==0?32:268435456:8:2}function _r(){var n=H.p;return n!==0?n:(n=window.event,n===void 0?32:Cx(n.type))}function wr(n,r){var o=H.p;try{return H.p=n,r()}finally{H.p=o}}var ai=Math.random().toString(36).slice(2),Ut="__reactFiber$"+ai,mn="__reactProps$"+ai,bi="__reactContainer$"+ai,Er="__reactEvents$"+ai,ua="__reactListeners$"+ai,Nr="__reactHandles$"+ai,Ya="__reactResources$"+ai,Ki="__reactMarker$"+ai;function da(n){delete n[Ut],delete n[mn],delete n[Er],delete n[ua],delete n[Nr]}function bn(n){var r=n[Ut];if(r)return r;for(var o=n.parentNode;o;){if(r=o[bi]||o[Ut]){if(o=r.alternate,r.child!==null||o!==null&&o.child!==null)for(n=fx(n);n!==null;){if(o=n[Ut])return o;n=fx(n)}return r}n=o,o=n.parentNode}return null}function xi(n){if(n=n[Ut]||n[bi]){var r=n.tag;if(r===5||r===6||r===13||r===31||r===26||r===27||r===3)return n}return null}function yi(n){var r=n.tag;if(r===5||r===26||r===27||r===6)return n.stateNode;throw Error(a(33))}function ji(n){var r=n[Ya];return r||(r=n[Ya]={hoistableStyles:new Map,hoistableScripts:new Map}),r}function Ft(n){n[Ki]=!0}var Xa=new Set,Zi={};function vi(n,r){ye(n,r),ye(n+"Capture",r)}function ye(n,r){for(Zi[n]=r,n=0;n<r.length;n++)Xa.add(r[n])}var je=RegExp("^[:A-Z_a-z\\u00C0-\\u00D6\\u00D8-\\u00F6\\u00F8-\\u02FF\\u0370-\\u037D\\u037F-\\u1FFF\\u2
Line truncated
|
||||
`).replace(wk,"")}function nx(n,r){return r=tx(r),tx(n)===r}function Et(n,r,o,u,b,v){switch(o){case"children":typeof u=="string"?r==="body"||r==="textarea"&&u===""||qe(n,u):(typeof u=="number"||typeof u=="bigint")&&r!=="body"&&qe(n,""+u);break;case"className":Mn(n,"class",u);break;case"tabIndex":Mn(n,"tabindex",u);break;case"dir":case"role":case"viewBox":case"width":case"height":Mn(n,o,u);break;case"style":bt(n,u,v);break;case"data":if(r!=="object"){Mn(n,"data",u);break}case"src":case"href":if(u===""&&(r!=="a"||o!=="href")){n.removeAttribute(o);break}if(u==null||typeof u=="function"||typeof u=="symbol"||typeof u=="boolean"){n.removeAttribute(o);break}u=Ar(""+u),n.setAttribute(o,u);break;case"action":case"formAction":if(typeof u=="function"){n.setAttribute(o,"javascript:throw new Error('A React form was unexpectedly submitted. If you called form.submit() manually, consider using form.requestSubmit() instead. If you\\'re trying to use event.stopPropagation() in a submit event handler, consider also calling event.preventDefault().')");break}else typeof v=="function"&&(o==="formAction"?(r!=="input"&&Et(n,r,"name",b.name,b,null),Et(n,r,"formEncType",b.formEncType,b,null),Et(n,r,"formMethod",b.formMethod,b,null),Et(n,r,"formTarget",b.formTarget,b,null)):(Et(n,r,"encType",b.encType,b,null),Et(n,r,"method",b.method,b,null),Et(n,r,"target",b.target,b,null)));if(u==null||typeof u=="symbol"||typeof u=="boolean"){n.removeAttribute(o);break}u=Ar(""+u),n.setAttribute(o,u);break;case"onClick":u!=null&&(n.onclick=_e);break;case"onScroll":u!=null&&et("scroll",n);break;case"onScrollEnd":u!=null&&et("scrollend",n);break;case"dangerouslySetInnerHTML":if(u!=null){if(typeof u!="object"||!("__html"in u))throw Error(a(61));if(o=u.__html,o!=null){if(b.children!=null)throw Error(a(60));n.innerHTML=o}}break;case"multiple":n.multiple=u&&typeof u!="function"&&typeof u!="symbol";break;case"muted":n.muted=u&&typeof u!="function"&&typeof u!="symbol";break;case"suppressContentEditableWarning":case"suppressHydrationWarning":case"defaultValue":case"defaultChecked":case"innerHTML":case"ref":break;case"autoFocus":break;case"xlinkHref":if(u==null||typeof u=="function"||typeof u=="boolean"||typeof u=="symbol"){n.removeAttribute("xlink:href");break}o=Ar(""+u),n.setAttributeNS("http://www.w3.org/1999/xlink","xlink:href",o);break;case"contentEditable":case"spellCheck":case"draggable":case"value":case"autoReverse":case"externalResourcesRequired":case"focusable":case"preserveAlpha":u!=null&&typeof u!="function"&&typeof u!="symbol"?n.setAttribute(o,""+u):n.removeAttribute(o);break;case"inert":case"allowFullScreen":case"async":case"autoPlay":case"controls":case"default":case"defer":case"disabled":case"disablePictureInPicture":case"disableRemotePlayback":case"formNoValidate":case"hidden":case"loop":case"noModule":case"noValidate":case"open":case"playsInline":case"readOnly":case"required":case"reversed":case"scoped":case"seamless":case"itemScope":u&&typeof u!="function"&&typeof u!="symbol"?n.setAttribute(o,""):n.removeAttribute(o);break;case"capture":case"download":u===!0?n.setAttribute(o,""):u!==!1&&u!=null&&typeof u!="function"&&typeof u!="symbol"?n.setAttribute(o,u):n.removeAttribute(o);break;case"cols":case"rows":case"size":case"span":u!=null&&typeof u!="function"&&typeof u!="symbol"&&!isNaN(u)&&1<=u?n.setAttribute(o,u):n.removeAttribute(o);break;case"rowSpan":case"start":u==null||typeof u=="function"||typeof u=="symbol"||isNaN(u)?n.removeAttribute(o):n.setAttribute(o,u);break;case"popover":et("beforetoggle",n),et("toggle",n),pn(n,"popover",u);break;case"xlinkActuate":Nn(n,"http://www.w3.org/1999/xlink","xlink:actuate",u);break;case"xlinkArcrole":Nn(n,"http://www.w3.org/1999/xlink","xlink:arcrole",u);break;case"xlinkRole":Nn(n,"http://www.w3.org/1999/xlink","xlink:role",u);break;case"xlinkShow":Nn(n,"http://www.w3.org/1999/xlink","xlink:show",u);break;case"xlinkTitle":Nn(n,"http://www.w3.org/1999/xlink","xlink:title",u);break;case"xlinkType":Nn(n,"http://www.w3.org/1999/xlink","xlink:type",u);break;case"xmlBase":Nn(n,"http://www.w3.org/XML/1998/namespace","xml:base",u);break;case"xmlLang":Nn(n,"http://www.w3.org/XML/1998/namespace","xml:lang",u);break;case"xmlSpace":Nn(n,"http://www.w3.org/XML/1998/namespace","xml:space",u);break;case"is":pn(n,"is",u);break;case"innerText":case"textContent":break;default:(!(2<o.length)||o[0]!=="o"&&o[0]!=="O"||o[1]!=="n"&&o[1]!=="N")&&(o=Qi.get(o)||o,pn(n,o,u))}}function If(n,r,o,u,b,v){switch(o){case"style":bt(n,u,v);break;case"dangerouslySetInnerHTML":if(u!=null){if(typeof u!="object"||!("__html"in u))throw Error(a(61));if(o=u.__html,o!=null){if(b.children!=null)throw Error(a(60));n.innerHTML=o}}break;case"children":typeof u=="string"?qe(n,u):(typeof u=="number"||typeof u=="bigint")&&qe(n,""+u);break;case"onScroll":u!=null&&et("scroll",n);break;case"onScrollEnd":u!=null&&et("scrollend",n);break;case"onClick":u!=null&&(n.onclick=_e);break;case"suppressContentEditableWarning":case"suppressHydrationWarning":case"inne
Line truncated
|
||||
* @license lucide-react v0.563.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const d_=(...e)=>e.filter((t,i,a)=>!!t&&t.trim()!==""&&a.indexOf(t)===i).join(" ").trim();/**
|
||||
* @license lucide-react v0.563.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const u2=e=>e.replace(/([a-z0-9])([A-Z])/g,"$1-$2").toLowerCase();/**
|
||||
* @license lucide-react v0.563.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const d2=e=>e.replace(/^([A-Z])|[\s-_]+(\w)/g,(t,i,a)=>a?a.toUpperCase():i.toLowerCase());/**
|
||||
* @license lucide-react v0.563.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const Gx=e=>{const t=d2(e);return t.charAt(0).toUpperCase()+t.slice(1)};/**
|
||||
* @license lucide-react v0.563.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/var f2={xmlns:"http://www.w3.org/2000/svg",width:24,height:24,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:2,strokeLinecap:"round",strokeLinejoin:"round"};/**
|
||||
* @license lucide-react v0.563.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const h2=e=>{for(const t in e)if(t.startsWith("aria-")||t==="role"||t==="title")return!0;return!1};/**
|
||||
* @license lucide-react v0.563.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const m2=te.forwardRef(({color:e="currentColor",size:t=24,strokeWidth:i=2,absoluteStrokeWidth:a,className:s="",children:l,iconNode:c,...d},f)=>te.createElement("svg",{ref:f,...f2,width:t,height:t,stroke:e,strokeWidth:a?Number(i)*24/Number(t):i,className:d_("lucide",s),...!l&&!h2(d)&&{"aria-hidden":"true"},...d},[...c.map(([h,m])=>te.createElement(h,m)),...Array.isArray(l)?l:[l]]));/**
|
||||
* @license lucide-react v0.563.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const Te=(e,t)=>{const i=te.forwardRef(({className:a,...s},l)=>te.createElement(m2,{ref:l,iconNode:t,className:d_(`lucide-${u2(Gx(e))}`,`lucide-${e}`,a),...s}));return i.displayName=Gx(e),i};/**
|
||||
* @license lucide-react v0.563.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const p2=[["path",{d:"m12 19-7-7 7-7",key:"1l729n"}],["path",{d:"M19 12H5",key:"x3x0zl"}]],f_=Te("arrow-left",p2);/**
|
||||
* @license lucide-react v0.563.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const g2=[["path",{d:"M7 7h10v10",key:"1tivn9"}],["path",{d:"M7 17 17 7",key:"1vkiza"}]],Bs=Te("arrow-up-right",g2);/**
|
||||
* @license lucide-react v0.563.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const b2=[["path",{d:"m5 12 7-7 7 7",key:"hav0vg"}],["path",{d:"M12 19V5",key:"x0mq9r"}]],x2=Te("arrow-up",b2);/**
|
||||
* @license lucide-react v0.563.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const y2=[["path",{d:"M4.929 4.929 19.07 19.071",key:"196cmz"}],["circle",{cx:"12",cy:"12",r:"10",key:"1mglay"}]],h_=Te("ban",y2);/**
|
||||
* @license lucide-react v0.563.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const v2=[["path",{d:"M10.268 21a2 2 0 0 0 3.464 0",key:"vwvbt9"}],["path",{d:"M17 17H4a1 1 0 0 1-.74-1.673C4.59 13.956 6 12.499 6 8a6 6 0 0 1 .258-1.742",key:"178tsu"}],["path",{d:"m2 2 20 20",key:"1ooewy"}],["path",{d:"M8.668 3.01A6 6 0 0 1 18 8c0 2.687.77 4.653 1.707 6.05",key:"1hqiys"}]],_2=Te("bell-off",v2);/**
|
||||
* @license lucide-react v0.563.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const w2=[["path",{d:"M12 8V4H8",key:"hb8ula"}],["rect",{width:"16",height:"12",x:"4",y:"8",rx:"2",key:"enze0r"}],["path",{d:"M2 14h2",key:"vft8re"}],["path",{d:"M20 14h2",key:"4cs60a"}],["path",{d:"M15 13v2",key:"1xurst"}],["path",{d:"M9 13v2",key:"rq6x2g"}]],sp=Te("bot",w2);/**
|
||||
* @license lucide-react v0.563.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const E2=[["path",{d:"M12 18V5",key:"adv99a"}],["path",{d:"M15 13a4.17 4.17 0 0 1-3-4 4.17 4.17 0 0 1-3 4",key:"1e3is1"}],["path",{d:"M17.598 6.5A3 3 0 1 0 12 5a3 3 0 1 0-5.598 1.5",key:"1gqd8o"}],["path",{d:"M17.997 5.125a4 4 0 0 1 2.526 5.77",key:"iwvgf7"}],["path",{d:"M18 18a4 4 0 0 0 2-7.464",key:"efp6ie"}],["path",{d:"M19.967 17.483A4 4 0 1 1 12 18a4 4 0 1 1-7.967-.517",key:"1gq6am"}],["path",{d:"M6 18a4 4 0 0 1-2-7.464",key:"k1g0md"}],["path",{d:"M6.003 5.125a4 4 0 0 0-2.526 5.77",key:"q97ue3"}]],m_=Te("brain",E2);/**
|
||||
* @license lucide-react v0.563.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const N2=[["path",{d:"M12 20v-9",key:"1qisl0"}],["path",{d:"M14 7a4 4 0 0 1 4 4v3a6 6 0 0 1-12 0v-3a4 4 0 0 1 4-4z",key:"uouzyp"}],["path",{d:"M14.12 3.88 16 2",key:"qol33r"}],["path",{d:"M21 21a4 4 0 0 0-3.81-4",key:"1b0z45"}],["path",{d:"M21 5a4 4 0 0 1-3.55 3.97",key:"5cxbf6"}],["path",{d:"M22 13h-4",key:"1jl80f"}],["path",{d:"M3 21a4 4 0 0 1 3.81-4",key:"1fjd4g"}],["path",{d:"M3 5a4 4 0 0 0 3.55 3.97",key:"1d7oge"}],["path",{d:"M6 13H2",key:"82j7cp"}],["path",{d:"m8 2 1.88 1.88",key:"fmnt4t"}],["path",{d:"M9 7.13V6a3 3 0 1 1 6 0v1.13",key:"1vgav8"}]],S2=Te("bug",N2);/**
|
||||
* @license lucide-react v0.563.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const k2=[["path",{d:"M16 14v2.2l1.6 1",key:"fo4ql5"}],["path",{d:"M16 2v4",key:"4m81vk"}],["path",{d:"M21 7.5V6a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h3.5",key:"1osxxc"}],["path",{d:"M3 10h5",key:"r794hk"}],["path",{d:"M8 2v4",key:"1cmpym"}],["circle",{cx:"16",cy:"16",r:"6",key:"qoo3c4"}]],p_=Te("calendar-clock",k2);/**
|
||||
* @license lucide-react v0.563.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const T2=[["path",{d:"M18 6 7 17l-5-5",key:"116fxf"}],["path",{d:"m22 10-7.5 7.5L13 16",key:"ke71qq"}]],C2=Te("check-check",T2);/**
|
||||
* @license lucide-react v0.563.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const A2=[["path",{d:"M20 6 9 17l-5-5",key:"1gmf2c"}]],Us=Te("check",A2);/**
|
||||
* @license lucide-react v0.563.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const M2=[["path",{d:"m6 9 6 6 6-6",key:"qrunsl"}]],hu=Te("chevron-down",M2);/**
|
||||
* @license lucide-react v0.563.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const O2=[["path",{d:"m9 18 6-6-6-6",key:"mthhwq"}]],R2=Te("chevron-right",O2);/**
|
||||
* @license lucide-react v0.563.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const D2=[["path",{d:"m18 15-6-6-6 6",key:"153udz"}]],L2=Te("chevron-up",D2);/**
|
||||
* @license lucide-react v0.563.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const j2=[["circle",{cx:"12",cy:"12",r:"10",key:"1mglay"}],["line",{x1:"12",x2:"12",y1:"8",y2:"12",key:"1pkeuh"}],["line",{x1:"12",x2:"12.01",y1:"16",y2:"16",key:"4dfq90"}]],op=Te("circle-alert",j2);/**
|
||||
* @license lucide-react v0.563.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const z2=[["path",{d:"M21.801 10A10 10 0 1 1 17 3.335",key:"yps3ct"}],["path",{d:"m9 11 3 3L22 4",key:"1pflzl"}]],g_=Te("circle-check-big",z2);/**
|
||||
* @license lucide-react v0.563.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const I2=[["circle",{cx:"12",cy:"12",r:"10",key:"1mglay"}],["path",{d:"m9 12 2 2 4-4",key:"dzmm74"}]],B2=Te("circle-check",I2);/**
|
||||
* @license lucide-react v0.563.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const U2=[["circle",{cx:"12",cy:"12",r:"10",key:"1mglay"}],["circle",{cx:"12",cy:"12",r:"1",key:"41hilf"}]],H2=Te("circle-dot",U2);/**
|
||||
* @license lucide-react v0.563.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const $2=[["circle",{cx:"12",cy:"12",r:"10",key:"1mglay"}]],q2=Te("circle",$2);/**
|
||||
* @license lucide-react v0.563.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const P2=[["path",{d:"M12 6v6l4 2",key:"mmk7yg"}],["circle",{cx:"12",cy:"12",r:"10",key:"1mglay"}]],b_=Te("clock",P2);/**
|
||||
* @license lucide-react v0.563.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const F2=[["path",{d:"m16 18 6-6-6-6",key:"eg8j8"}],["path",{d:"m8 6-6 6 6 6",key:"ppft3o"}]],G2=Te("code",F2);/**
|
||||
* @license lucide-react v0.563.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const V2=[["rect",{width:"14",height:"14",x:"8",y:"8",rx:"2",ry:"2",key:"17jyea"}],["path",{d:"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2",key:"zix9uf"}]],sl=Te("copy",V2);/**
|
||||
* @license lucide-react v0.563.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const Y2=[["circle",{cx:"12",cy:"12",r:"10",key:"1mglay"}],["line",{x1:"22",x2:"18",y1:"12",y2:"12",key:"l9bcsi"}],["line",{x1:"6",x2:"2",y1:"12",y2:"12",key:"13hhkx"}],["line",{x1:"12",x2:"12",y1:"6",y2:"2",key:"10w3f3"}],["line",{x1:"12",x2:"12",y1:"22",y2:"18",key:"15g9kq"}]],X2=Te("crosshair",Y2);/**
|
||||
* @license lucide-react v0.563.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const K2=[["ellipse",{cx:"12",cy:"5",rx:"9",ry:"3",key:"msslwz"}],["path",{d:"M3 5V19A9 3 0 0 0 21 19V5",key:"1wlel7"}],["path",{d:"M3 12A9 3 0 0 0 21 12",key:"mv7ke4"}]],Z2=Te("database",K2);/**
|
||||
* @license lucide-react v0.563.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const Q2=[["path",{d:"M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0",key:"1nclc0"}],["circle",{cx:"12",cy:"12",r:"3",key:"1v7zrd"}]],W2=Te("eye",Q2);/**
|
||||
* @license lucide-react v0.563.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const J2=[["path",{d:"M10.5 22H6a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.706.706l3.588 3.588A2.4 2.4 0 0 1 20 8v6",key:"g5mvt7"}],["path",{d:"M14 2v5a1 1 0 0 0 1 1h5",key:"wfsgrz"}],["path",{d:"m14 20 2 2 4-4",key:"15kota"}]],eT=Te("file-check-corner",J2);/**
|
||||
* @license lucide-react v0.563.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const tT=[["path",{d:"M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z",key:"1oefj6"}],["path",{d:"M14 2v5a1 1 0 0 0 1 1h5",key:"wfsgrz"}],["path",{d:"M10 9H8",key:"b1mrlr"}],["path",{d:"M16 13H8",key:"t4e002"}],["path",{d:"M16 17H8",key:"z1uh3a"}]],x_=Te("file-text",tT);/**
|
||||
* @license lucide-react v0.563.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const nT=[["path",{d:"M4 22V4a1 1 0 0 1 .4-.8A6 6 0 0 1 8 2c3 0 5 2 7.333 2q2 0 3.067-.8A1 1 0 0 1 20 4v10a1 1 0 0 1-.4.8A6 6 0 0 1 16 16c-3 0-5-2-8-2a6 6 0 0 0-4 1.528",key:"1jaruq"}]],y_=Te("flag",nT);/**
|
||||
* @license lucide-react v0.563.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const iT=[["circle",{cx:"18",cy:"18",r:"3",key:"1xkwt0"}],["circle",{cx:"6",cy:"6",r:"3",key:"1lh9wr"}],["path",{d:"M6 21V9a9 9 0 0 0 9 9",key:"7kw0sc"}]],rT=Te("git-merge",iT);/**
|
||||
* @license lucide-react v0.563.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const aT=[["circle",{cx:"18",cy:"18",r:"3",key:"1xkwt0"}],["circle",{cx:"6",cy:"6",r:"3",key:"1lh9wr"}],["path",{d:"M13 6h3a2 2 0 0 1 2 2v7",key:"1yeb86"}],["line",{x1:"6",x2:"6",y1:"9",y2:"21",key:"rroup"}]],lp=Te("git-pull-request",aT);/**
|
||||
* @license lucide-react v0.563.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const sT=[["path",{d:"M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4",key:"tonef"}],["path",{d:"M9 18c-4.51 2-5-2-7-2",key:"9comsn"}]],oT=Te("github",sT);/**
|
||||
* @license lucide-react v0.563.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const lT=[["path",{d:"m22 13.29-3.33-10a.42.42 0 0 0-.14-.18.38.38 0 0 0-.22-.11.39.39 0 0 0-.23.07.42.42 0 0 0-.14.18l-2.26 6.67H8.32L6.1 3.26a.42.42 0 0 0-.1-.18.38.38 0 0 0-.26-.08.39.39 0 0 0-.23.07.42.42 0 0 0-.14.18L2 13.29a.74.74 0 0 0 .27.83L12 21l9.69-6.88a.71.71 0 0 0 .31-.83Z",key:"148pdi"}]],cT=Te("gitlab",lT);/**
|
||||
* @license lucide-react v0.563.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const uT=[["circle",{cx:"12",cy:"12",r:"10",key:"1mglay"}],["path",{d:"M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20",key:"13o1zl"}],["path",{d:"M2 12h20",key:"9i4pu4"}]],cp=Te("globe",uT);/**
|
||||
* @license lucide-react v0.563.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const dT=[["path",{d:"M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8",key:"1357e3"}],["path",{d:"M3 3v5h5",key:"1xhq8a"}],["path",{d:"M12 7v5l4 2",key:"1fdv2h"}]],Hs=Te("history",dT);/**
|
||||
* @license lucide-react v0.563.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const fT=[["rect",{width:"18",height:"18",x:"3",y:"3",rx:"2",ry:"2",key:"1m3agn"}],["circle",{cx:"9",cy:"9",r:"2",key:"af1f0g"}],["path",{d:"m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21",key:"1xmnt7"}]],hT=Te("image",fT);/**
|
||||
* @license lucide-react v0.563.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const mT=[["path",{d:"M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83z",key:"zw3jo"}],["path",{d:"M2 12a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 12",key:"1wduqc"}],["path",{d:"M2 17a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 17",key:"kqbvx6"}]],pT=Te("layers",mT);/**
|
||||
* @license lucide-react v0.563.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const gT=[["rect",{width:"7",height:"9",x:"3",y:"3",rx:"1",key:"10lvy0"}],["rect",{width:"7",height:"5",x:"14",y:"3",rx:"1",key:"16une8"}],["rect",{width:"7",height:"9",x:"14",y:"12",rx:"1",key:"1hutg5"}],["rect",{width:"7",height:"5",x:"3",y:"16",rx:"1",key:"ldoo1y"}]],bT=Te("layout-dashboard",gT);/**
|
||||
* @license lucide-react v0.563.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const xT=[["path",{d:"M13 5h8",key:"a7qcls"}],["path",{d:"M13 12h8",key:"h98zly"}],["path",{d:"M13 19h8",key:"c3s6r1"}],["path",{d:"m3 17 2 2 4-4",key:"1jhpwq"}],["rect",{x:"3",y:"4",width:"6",height:"6",rx:"1",key:"cif1o7"}]],yT=Te("list-todo",xT);/**
|
||||
* @license lucide-react v0.563.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const vT=[["path",{d:"M21 12a9 9 0 1 1-6.219-8.56",key:"13zald"}]],js=Te("loader-circle",vT);/**
|
||||
* @license lucide-react v0.563.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const _T=[["rect",{width:"18",height:"11",x:"3",y:"11",rx:"2",ry:"2",key:"1w4ew1"}],["path",{d:"M7 11V7a5 5 0 0 1 10 0v4",key:"fwvmzm"}]],wT=Te("lock",_T);/**
|
||||
* @license lucide-react v0.563.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const ET=[["path",{d:"m16 17 5-5-5-5",key:"1bji2h"}],["path",{d:"M21 12H9",key:"dn1m92"}],["path",{d:"M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4",key:"1uf3rs"}]],NT=Te("log-out",ET);/**
|
||||
* @license lucide-react v0.563.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const ST=[["path",{d:"m22 7-8.991 5.727a2 2 0 0 1-2.009 0L2 7",key:"132q7q"}],["rect",{x:"2",y:"4",width:"20",height:"16",rx:"2",key:"izxlao"}]],up=Te("mail",ST);/**
|
||||
* @license lucide-react v0.563.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const kT=[["path",{d:"M2.992 16.342a2 2 0 0 1 .094 1.167l-1.065 3.29a1 1 0 0 0 1.236 1.168l3.413-.998a2 2 0 0 1 1.099.092 10 10 0 1 0-4.777-4.719",key:"1sd12s"}]],Vx=Te("message-circle",kT);/**
|
||||
* @license lucide-react v0.563.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const TT=[["path",{d:"M22 17a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 21.286V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2z",key:"18887p"}]],CT=Te("message-square",TT);/**
|
||||
* @license lucide-react v0.563.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const AT=[["rect",{x:"16",y:"16",width:"6",height:"6",rx:"1",key:"4q2zg0"}],["rect",{x:"2",y:"16",width:"6",height:"6",rx:"1",key:"8cvhb9"}],["rect",{x:"9",y:"2",width:"6",height:"6",rx:"1",key:"1egb70"}],["path",{d:"M5 16v-3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v3",key:"1jsf9p"}],["path",{d:"M12 12V8",key:"2874zd"}]],MT=Te("network",AT);/**
|
||||
* @license lucide-react v0.563.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const OT=[["rect",{width:"18",height:"18",x:"3",y:"3",rx:"2",key:"afitv7"}],["path",{d:"M9 3v18",key:"fh3hqa"}],["path",{d:"m16 15-3-3 3-3",key:"14y99z"}]],RT=Te("panel-left-close",OT);/**
|
||||
* @license lucide-react v0.563.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const DT=[["rect",{width:"18",height:"18",x:"3",y:"3",rx:"2",key:"afitv7"}],["path",{d:"M9 3v18",key:"fh3hqa"}]],LT=Te("panel-left",DT);/**
|
||||
* @license lucide-react v0.563.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const jT=[["path",{d:"M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z",key:"1a8usu"}],["path",{d:"m15 5 4 4",key:"1mk7zo"}]],zT=Te("pencil",jT);/**
|
||||
* @license lucide-react v0.563.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const IT=[["path",{d:"M5 12h14",key:"1ays0h"}],["path",{d:"M12 5v14",key:"s699le"}]],BT=Te("plus",IT);/**
|
||||
* @license lucide-react v0.563.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const UT=[["path",{d:"M15.39 4.39a1 1 0 0 0 1.68-.474 2.5 2.5 0 1 1 3.014 3.015 1 1 0 0 0-.474 1.68l1.683 1.682a2.414 2.414 0 0 1 0 3.414L19.61 15.39a1 1 0 0 1-1.68-.474 2.5 2.5 0 1 0-3.014 3.015 1 1 0 0 1 .474 1.68l-1.683 1.682a2.414 2.414 0 0 1-3.414 0L8.61 19.61a1 1 0 0 0-1.68.474 2.5 2.5 0 1 1-3.014-3.015 1 1 0 0 0 .474-1.68l-1.683-1.682a2.414 2.414 0 0 1 0-3.414L4.39 8.61a1 1 0 0 1 1.68.474 2.5 2.5 0 1 0 3.014-3.015 1 1 0 0 1-.474-1.68l1.683-1.682a2.414 2.414 0 0 1 3.414 0z",key:"w46dr5"}]],v_=Te("puzzle",UT);/**
|
||||
* @license lucide-react v0.563.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const HT=[["path",{d:"M19.07 4.93A10 10 0 0 0 6.99 3.34",key:"z3du51"}],["path",{d:"M4 6h.01",key:"oypzma"}],["path",{d:"M2.29 9.62A10 10 0 1 0 21.31 8.35",key:"qzzz0"}],["path",{d:"M16.24 7.76A6 6 0 1 0 8.23 16.67",key:"1yjesh"}],["path",{d:"M12 18h.01",key:"mhygvu"}],["path",{d:"M17.99 11.66A6 6 0 0 1 15.77 16.67",key:"1u2y91"}],["circle",{cx:"12",cy:"12",r:"2",key:"1c9p78"}],["path",{d:"m13.41 10.59 5.66-5.66",key:"mhq4k0"}]],__=Te("radar",HT);/**
|
||||
* @license lucide-react v0.563.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const $T=[["path",{d:"M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8",key:"v9h5vc"}],["path",{d:"M21 3v5h-5",key:"1q7to0"}],["path",{d:"M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16",key:"3uifl3"}],["path",{d:"M8 16H3v5",key:"1cv678"}]],qT=Te("refresh-cw",$T);/**
|
||||
* @license lucide-react v0.563.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const PT=[["path",{d:"M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z",key:"m3kijz"}],["path",{d:"m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z",key:"1fmvmk"}],["path",{d:"M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0",key:"1f8sc4"}],["path",{d:"M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5",key:"qeys4"}]],FT=Te("rocket",PT);/**
|
||||
* @license lucide-react v0.563.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const GT=[["path",{d:"M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8",key:"1357e3"}],["path",{d:"M3 3v5h5",key:"1xhq8a"}]],VT=Te("rotate-ccw",GT);/**
|
||||
* @license lucide-react v0.563.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const YT=[["path",{d:"m21 21-4.34-4.34",key:"14j7rj"}],["circle",{cx:"11",cy:"11",r:"8",key:"4ej97u"}]],w_=Te("search",YT);/**
|
||||
* @license lucide-react v0.563.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const XT=[["path",{d:"M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z",key:"oel41y"}],["path",{d:"M12 8v4",key:"1got3b"}],["path",{d:"M12 16h.01",key:"1drbdi"}]],KT=Te("shield-alert",XT);/**
|
||||
* @license lucide-react v0.563.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const ZT=[["path",{d:"M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z",key:"oel41y"}],["path",{d:"m9 12 2 2 4-4",key:"dzmm74"}]],mu=Te("shield-check",ZT);/**
|
||||
* @license lucide-react v0.563.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const QT=[["path",{d:"M11.017 2.814a1 1 0 0 1 1.966 0l1.051 5.558a2 2 0 0 0 1.594 1.594l5.558 1.051a1 1 0 0 1 0 1.966l-5.558 1.051a2 2 0 0 0-1.594 1.594l-1.051 5.558a1 1 0 0 1-1.966 0l-1.051-5.558a2 2 0 0 0-1.594-1.594l-5.558-1.051a1 1 0 0 1 0-1.966l5.558-1.051a2 2 0 0 0 1.594-1.594z",key:"1s2grr"}],["path",{d:"M20 2v4",key:"1rf3ol"}],["path",{d:"M22 4h-4",key:"gwowj6"}],["circle",{cx:"4",cy:"20",r:"2",key:"6kqj1y"}]],Yx=Te("sparkles",QT);/**
|
||||
* @license lucide-react v0.563.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const WT=[["path",{d:"M21 9a2.4 2.4 0 0 0-.706-1.706l-3.588-3.588A2.4 2.4 0 0 0 15 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2z",key:"1dfntj"}],["path",{d:"M15 3v5a1 1 0 0 0 1 1h5",key:"6s6qgf"}]],JT=Te("sticky-note",WT);/**
|
||||
* @license lucide-react v0.563.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const eC=[["path",{d:"M12 19h8",key:"baeox8"}],["path",{d:"m4 17 6-6-6-6",key:"1yngyt"}]],E_=Te("terminal",eC);/**
|
||||
* @license lucide-react v0.563.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const tC=[["path",{d:"M10 11v6",key:"nco0om"}],["path",{d:"M14 11v6",key:"outv1u"}],["path",{d:"M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6",key:"miytrc"}],["path",{d:"M3 6h18",key:"d0wm0j"}],["path",{d:"M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2",key:"e791ji"}]],nC=Te("trash-2",tC);/**
|
||||
* @license lucide-react v0.563.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const iC=[["path",{d:"m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3",key:"wmoenq"}],["path",{d:"M12 9v4",key:"juzpu7"}],["path",{d:"M12 17h.01",key:"p32p05"}]],rC=Te("triangle-alert",iC);/**
|
||||
* @license lucide-react v0.563.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const aC=[["path",{d:"M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2",key:"1yyitq"}],["path",{d:"M16 3.128a4 4 0 0 1 0 7.744",key:"16gr8j"}],["path",{d:"M22 21v-2a4 4 0 0 0-3-3.87",key:"kshegd"}],["circle",{cx:"9",cy:"7",r:"4",key:"nufk8"}]],N_=Te("users",aC);/**
|
||||
* @license lucide-react v0.563.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const sC=[["path",{d:"m21.64 3.64-1.28-1.28a1.21 1.21 0 0 0-1.72 0L2.36 18.64a1.21 1.21 0 0 0 0 1.72l1.28 1.28a1.2 1.2 0 0 0 1.72 0L21.64 5.36a1.2 1.2 0 0 0 0-1.72",key:"ul74o6"}],["path",{d:"m14 7 3 3",key:"1r5n42"}],["path",{d:"M5 6v4",key:"ilb8ba"}],["path",{d:"M19 14v4",key:"blhpug"}],["path",{d:"M10 2v2",key:"7u0qdc"}],["path",{d:"M7 8H3",key:"zfb6yr"}],["path",{d:"M21 16h-4",key:"1cnmox"}],["path",{d:"M11 3H9",key:"1obp7u"}]],oC=Te("wand-sparkles",sC);/**
|
||||
* @license lucide-react v0.563.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const lC=[["path",{d:"m10.586 5.414-5.172 5.172",key:"4mc350"}],["path",{d:"m18.586 13.414-5.172 5.172",key:"8c96vv"}],["path",{d:"M6 12h12",key:"8npq4p"}],["circle",{cx:"12",cy:"20",r:"2",key:"144qzu"}],["circle",{cx:"12",cy:"4",r:"2",key:"muu5ef"}],["circle",{cx:"20",cy:"12",r:"2",key:"1xzzfp"}],["circle",{cx:"4",cy:"12",r:"2",key:"1hvhnz"}]],S_=Te("waypoints",lC);/**
|
||||
* @license lucide-react v0.563.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const cC=[["path",{d:"M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.106-3.105c.32-.322.863-.22.983.218a6 6 0 0 1-8.259 7.057l-7.91 7.91a1 1 0 0 1-2.999-3l7.91-7.91a6 6 0 0 1 7.057-8.259c.438.12.54.662.219.984z",key:"1ngwbx"}]],pu=Te("wrench",cC);/**
|
||||
* @license lucide-react v0.563.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const uC=[["path",{d:"M18 6 6 18",key:"1bl5f8"}],["path",{d:"m6 6 12 12",key:"d8bk6v"}]],dC=Te("x",uC),fC={open:{label:"Open",color:"bg-red-500/10 text-red-400 border-red-500/20",dotColor:"bg-red-500",description:"Newly discovered, awaiting triage"},in_progress:{label:"In Progress",color:"bg-blue-500/10 text-blue-400 border-blue-500/20",dotColor:"bg-blue-500",description:"Someone is working on this"},snoozed:{label:"Snoozed",color:"bg-purple-500/10 text-purple-400 border-purple-500/20",dotColor:"bg-purple-500",description:"Temporarily hidden until a follow-up date"},fixed:{label:"Fixed",color:"bg-emerald-500/10 text-emerald-400 border-emerald-500/20",dotColor:"bg-emerald-500",description:"This vulnerability has been fixed"},ignored:{label:"Ignored",color:"bg-gray-500/10 text-gray-400 border-gray-500/20",dotColor:"bg-gray-500",description:"Acknowledged but accepted"}},hC={trivial:{label:"Trivial",color:"bg-emerald-500/10 text-emerald-400 border-emerald-500/20"},low:{label:"Low",color:"bg-blue-500/10 text-blue-400 border-blue-500/20"},medium:{label:"Medium",color:"bg-yellow-500/10 text-yellow-400 border-yellow-500/20"},high:{label:"High",color:"bg-orange-500/10 text-orange-400 border-orange-500/20"}},k_={critical:"bg-red-500/20 text-red-500 border-red-500/30",high:"bg-orange-500/20 text-orange-500 border-orange-500/30",medium:"bg-yellow-500/20 text-yellow-500 border-yellow-500/30",low:"bg-blue-500/20 text-blue-500 border-blue-500/30"};function Pc(e){return e.original_severity!=null&&e.original_severity!==e.severity}const mC={js:"javascript",ts:"typescript",tsx:"typescript",jsx:"javascript",py:"python",rb:"ruby",go:"go",rs:"rust",java:"java",php:"php",cs:"csharp",cpp:"cpp",c:"c",sh:"bash",bash:"bash",sql:"sql",html:"html",css:"css",json:"json",yaml:"yaml",yml:"yaml",xml:"xml"};function pC(e){var i;if(!e)return null;const t=(i=e.split(".").pop())==null?void 0:i.toLowerCase();return t&&mC[t]||null}function dp(e){switch(e){case"critical":return"bg-red-500";case"high":return"bg-orange-500";case"medium":return"bg-yellow-500";default:return"bg-blue-500"}}async function fp(e){try{await navigator.clipboard.writeText(e)}catch{const t=document.createElement("textarea");t.value=e,t.style.position="absolute",t.style.left="-9999px",document.body.appendChild(t),t.select(),document.execCommand("copy"),document.body.removeChild(t)}}const _l="https://app.strix.ai/api/auth/signup",gC="https://strix.ai/pricing",bC="ref=oss_viewer&utm_source=oss_viewer&utm_medium=local_viewer&utm_campaign=oss_viewer";function oa(e,t){const i=e.includes("?")?"&":"?";return`${e}${i}${bC}&utm_content=${encodeURIComponent(t)}`}function ki(e,t={}){try{const i={event:e};for(const[s,l]of Object.entries(t))l!==void 0&&(i[s]=l);const a=JSON.stringify(i);typeof navigator<"u"&&navigator.sendBeacon?navigator.sendBeacon("/api/event",a):fetch("/api/event",{method:"POST",body:a,keepalive:!0})}catch{}}function Ci(e,t){ki("cta_clicked",{cta:e,surface:t})}function Lu({label:e="Pro",className:t=""}){return g.jsx("span",{className:`inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-[#aaa] ${t}`,style:{border:"1px solid #2a2a2a",background:"rgba(255,255,255,0.04)"},children:e})}function xC({text:e,children:t,className:i=""}){const[a,s]=te.useState(!1);return g.jsxs("span",{className:`relative inline-flex ${i}`,onMouseEnter:()=>s(!0),onMouseLeave:()=>s(!1),onFocus:()=>s(!0),onBlur:()=>s(!1),children:[t,a&&g.jsx("span",{role:"tooltip",className:"pointer-events-none absolute bottom-full left-1/2 z-50 mb-2 w-max max-w-[240px] -translate-x-1/2 rounded-md px-2.5 py-1.5 text-xs text-[#ddd] shadow-lg",style:{border:"1px solid #2a2a2a",background:"#0a0a0a"},children:e})]})}function Xx({item:e,surface:t}){const i=e.icon;return g.jsxs("a",{href:oa(_l,e.slug),target:"_blank",rel:"noopener noreferrer",onClick:()=>Ci(e.slug,t),title:e.desc,className:"group block cursor-pointer rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-4 text-left transition-colors hover:border-[#444]",children:[g.jsxs("div",{className:"mb-2 flex items-center justify-between",children:[g.jsx(i,{className:"h-4 w-4 text-[#888] transition-colors group-hover:text-white","aria-hidden":"true"}),g.jsxs("div",{className:"flex items-center gap-1.5",children:[g.jsx(Lu,{}),g.jsx(Bs,{className:"h-3.5 w-3.5 text-[#555] transition-colors group-hover:text-[#aaa]","aria-hidden":"true"})]})]}),g.jsx("p",{className:"text-sm font-medium text-white",children:e.title}),g.jsx("p",{className:"mt-0.5 text-xs text-[#666]",children:e.desc})]})}function yC({feature:e,active:t,onClick:i,collapsed:a=!1}){const s=e.icon;return a?g.jsx("button",{onClick:i,title:`${e.title} (${e.tier})`,className:`group flex w-full cursor-pointer items-center justify-center rounded-md px-2.5 py-2 transition-colors ${t?"text-white":"text-[#888] hover:bg-[rgba(255,255,255,0.06)] hover:text-white"}`,style:t?{background:"rgba(255,255,255,0.12)"}:void 0,children:g.jsx(s,{className:"h-4 w-4Line truncated
|
||||
`,h="/",m="*",p="",x="comment",y="declaration";function _(S,w){if(typeof S!="string")throw new TypeError("First argument must be a string");if(!S)return[];w=w||{};var k=1,E=1;function O(C){var q=C.match(t);q&&(k+=q.length);var M=C.lastIndexOf(f);E=~M?C.length-M:E+C.length}function U(){var C={line:k,column:E};return function(q){return q.position=new R(C),Z(),q}}function R(C){this.start=C,this.end={line:k,column:E},this.source=w.source}R.prototype.content=S;function B(C){var q=new Error(w.source+":"+k+":"+E+": "+C);if(q.reason=C,q.filename=w.source,q.line=k,q.column=E,q.source=S,!w.silent)throw q}function I(C){var q=C.exec(S);if(q){var M=q[0];return O(M),S=S.slice(M.length),q}}function Z(){I(i)}function L(C){var q;for(C=C||[];q=z();)q!==!1&&C.push(q);return C}function z(){var C=U();if(!(h!=S.charAt(0)||m!=S.charAt(1))){for(var q=2;p!=S.charAt(q)&&(m!=S.charAt(q)||h!=S.charAt(q+1));)++q;if(q+=2,p===S.charAt(q-1))return B("End of comment missing");var M=S.slice(2,q-2);return E+=2,O(M),S=S.slice(q),E+=2,C({type:x,comment:M})}}function K(){var C=U(),q=I(a);if(q){if(z(),!I(s))return B("property missing ':'");var M=I(l),H=C({type:y,property:N(q[0].replace(e,p)),value:M?N(M[0].replace(e,p)):p});return I(c),H}}function G(){var C=[];L(C);for(var q;q=K();)q!==!1&&(C.push(q),L(C));return C}return Z(),G()}function N(S){return S?S.replace(d,p):p}return ch=_,ch}var sy;function RA(){if(sy)return Ts;sy=1;var e=Ts&&Ts.__importDefault||function(a){return a&&a.__esModule?a:{default:a}};Object.defineProperty(Ts,"__esModule",{value:!0}),Ts.default=i;const t=e(OA());function i(a,s){let l=null;if(!a||typeof a!="string")return l;const c=(0,t.default)(a),d=typeof s=="function";return c.forEach(f=>{if(f.type!=="declaration")return;const{property:h,value:m}=f;d?s(h,m,f):m&&(l=l||{},l[h]=m)}),l}return Ts}var Fo={},oy;function DA(){if(oy)return Fo;oy=1,Object.defineProperty(Fo,"__esModule",{value:!0}),Fo.camelCase=void 0;var e=/^--[a-zA-Z0-9_-]+$/,t=/-([a-z])/g,i=/^[^-]+$/,a=/^-(webkit|moz|ms|o|khtml)-/,s=/^-(ms)-/,l=function(h){return!h||i.test(h)||e.test(h)},c=function(h,m){return m.toUpperCase()},d=function(h,m){return"".concat(m,"-")},f=function(h,m){return m===void 0&&(m={}),l(h)?h:(h=h.toLowerCase(),m.reactCompat?h=h.replace(s,d):h=h.replace(a,d),h.replace(t,c))};return Fo.camelCase=f,Fo}var Go,ly;function LA(){if(ly)return Go;ly=1;var e=Go&&Go.__importDefault||function(s){return s&&s.__esModule?s:{default:s}},t=e(RA()),i=DA();function a(s,l){var c={};return!s||typeof s!="string"||(0,t.default)(s,function(d,f){d&&f&&(c[(0,i.camelCase)(d,l)]=f)}),c}return a.default=a,Go=a,Go}var jA=LA();const zA=yl(jA),K_=Z_("end"),bp=Z_("start");function Z_(e){return t;function t(i){const a=i&&i.position&&i.position[e]||{};if(typeof a.line=="number"&&a.line>0&&typeof a.column=="number"&&a.column>0)return{line:a.line,column:a.column,offset:typeof a.offset=="number"&&a.offset>-1?a.offset:void 0}}}function IA(e){const t=bp(e),i=K_(e);if(t&&i)return{start:t,end:i}}function tl(e){return!e||typeof e!="object"?"":"position"in e||"type"in e?cy(e.position):"start"in e||"end"in e?cy(e):"line"in e||"column"in e?Dm(e):""}function Dm(e){return uy(e&&e.line)+":"+uy(e&&e.column)}function cy(e){return Dm(e&&e.start)+"-"+Dm(e&&e.end)}function uy(e){return e&&typeof e=="number"?e:1}class Cn extends Error{constructor(t,i,a){super(),typeof i=="string"&&(a=i,i=void 0);let s="",l={},c=!1;if(i&&("line"in i&&"column"in i?l={place:i}:"start"in i&&"end"in i?l={place:i}:"type"in i?l={ancestors:[i],place:i.position}:l={...i}),typeof t=="string"?s=t:!l.cause&&t&&(c=!0,s=t.message,l.cause=t),!l.ruleId&&!l.source&&typeof a=="string"){const f=a.indexOf(":");f===-1?l.ruleId=a:(l.source=a.slice(0,f),l.ruleId=a.slice(f+1))}if(!l.place&&l.ancestors&&l.ancestors){const f=l.ancestors[l.ancestors.length-1];f&&(l.place=f.position)}const d=l.place&&"start"in l.place?l.place.start:l.place;this.ancestors=l.ancestors||void 0,this.cause=l.cause||void 0,this.column=d?d.column:void 0,this.fatal=void 0,this.file="",this.message=s,this.line=d?d.line:void 0,this.name=tl(l.place)||"1:1",this.place=l.place||void 0,this.reason=this.message,this.ruleId=l.ruleId||void 0,this.source=l.source||void 0,this.stack=c&&l.cause&&typeof l.cause.stack=="string"?l.cause.stack:"",this.actual=void 0,this.expected=void 0,this.note=void 0,this.url=void 0}}Cn.prototype.file="";Cn.prototype.name="";Cn.prototype.reason="";Cn.prototype.message="";Cn.prototype.stack="";Cn.prototype.column=void 0;Cn.prototype.line=void 0;Cn.prototype.ancestors=void 0;Cn.prototype.cause=void 0;Cn.prototype.fatal=void 0;Cn.prototype.place=void 0;Cn.prototype.ruleId=void 0;Cn.prototype.source=void 0;const xp={}.hasOwnProperty,BA=new Map,UA=/[A-Z]/g,HA=new Set(["table","tbody","thead","tfoot","tr"]),$A=new Set(["td","th"]),Q_="https://github.com/syntax-tree/hast-util-to-jsx-runtime";function qA(e,t){if(!t||t.Fragment===void 0)throw new TypeError("Expected `Fragment` in options");const i=t.filePath||void 0;let a;if(t.development){if(typeof t.j
Line truncated
|
||||
`;break}case-3:{c=`\r
|
||||
`;break}case-2:{c=t?" ":" ";break}case-1:{if(!t&&s)continue;c=" ";break}default:c=String.fromCharCode(l)}s=l===-2,a.push(c)}return a.join("")}function ZO(e){const a={constructs:nw([VO,...(e||{}).extensions||[]]),content:s(dM),defined:[],document:s(hM),flow:s(OO),lazy:{},string:s(LO),text:s(jO)};return a;function s(l){return c;function c(d){return YO(a,l,d)}}}function QO(e){for(;!ow(e););return e}const vy=/[\0\t\n\r]/g;function WO(){let e=1,t="",i=!0,a;return s;function s(l,c,d){const f=[];let h,m,p,x,y;for(l=t+(typeof l=="string"?l.toString():new TextDecoder(c||void 0).decode(l)),p=0,t="",i&&(l.charCodeAt(0)===65279&&p++,i=void 0);p<l.length;){if(vy.lastIndex=p,h=vy.exec(l),x=h&&h.index!==void 0?h.index:l.length,y=l.charCodeAt(x),!h){t=l.slice(p);break}if(y===10&&p===x&&a)f.push(-3),a=void 0;else switch(a&&(f.push(-5),a=void 0),p<x&&(f.push(l.slice(p,x)),e+=x-p),y){case 0:{f.push(65533),e++;break}case 9:{for(m=Math.ceil(e/4)*4,f.push(-2);e++<m;)f.push(-1);break}case 10:{f.push(-4),e=1;break}default:a=!0,e=1}p=x+1}return d&&(a&&f.push(-5),t&&f.push(t),f.push(null)),f}}const JO=/\\([!-/:-@[-`{-~])|&(#(?:\d{1,7}|x[\da-f]{1,6})|[\da-z]{1,31});/gi;function e5(e){return e.replace(JO,t5)}function t5(e,t,i){if(t)return t;if(i.charCodeAt(0)===35){const s=i.charCodeAt(1),l=s===120||s===88;return iw(i.slice(l?2:1),l?16:10)}return wp(i)||e}const hw={}.hasOwnProperty;function n5(e,t,i){return t&&typeof t=="object"&&(i=t,t=void 0),i5(i)(QO(ZO(i).document().write(WO()(e,t,!0))))}function i5(e){const t={transforms:[],canContainEols:["emphasis","fragment","heading","paragraph","strong"],enter:{autolink:l(Yt),autolinkProtocol:Z,autolinkEmail:Z,atxHeading:l(Le),blockQuote:l(le),characterEscape:Z,characterReference:Z,codeFenced:l(fe),codeFencedFenceInfo:c,codeFencedFenceMeta:c,codeIndented:l(fe,c),codeText:l(be,c),codeTextData:Z,data:Z,codeFlowValue:Z,definition:l(we),definitionDestinationString:c,definitionLabelString:c,definitionTitleString:c,emphasis:l(Ne),hardBreakEscape:l($e),hardBreakTrailing:l($e),htmlFlow:l(st,c),htmlFlowData:Z,htmlText:l(st,c),htmlTextData:Z,image:l(Rt),label:c,link:l(Yt),listItem:l(Xt),listItemValue:x,listOrdered:l(Pt,p),listUnordered:l(Pt),paragraph:l(Vn),reference:D,referenceString:c,resourceDestinationString:c,resourceTitleString:c,setextHeading:l(Le),strong:l(wn),thematicBreak:l(It)},exit:{atxHeading:f(),atxHeadingSequence:U,autolink:f(),autolinkEmail:ee,autolinkProtocol:W,blockQuote:f(),characterEscapeValue:L,characterReferenceMarkerHexadecimal:$,characterReferenceMarkerNumeric:$,characterReferenceValue:Q,characterReference:J,codeFenced:f(S),codeFencedFence:N,codeFencedFenceInfo:y,codeFencedFenceMeta:_,codeFlowValue:L,codeIndented:f(w),codeText:f(q),codeTextData:L,data:L,definition:f(),definitionDestinationString:O,definitionLabelString:k,definitionTitleString:E,emphasis:f(),hardBreakEscape:f(K),hardBreakTrailing:f(K),htmlFlow:f(G),htmlFlowData:L,htmlText:f(C),htmlTextData:L,image:f(H),label:Y,labelText:X,lineEnding:z,link:f(M),listItem:f(),listOrdered:f(),listUnordered:f(),paragraph:f(),referenceString:F,resourceDestinationString:A,resourceTitleString:j,resource:V,setextHeading:f(I),setextHeadingLineSequence:B,setextHeadingText:R,strong:f(),thematicBreak:f()}};mw(t,(e||{}).mdastExtensions||[]);const i={};return a;function a(ue){let xe={type:"root",children:[]};const Oe={stack:[xe],tokenStack:[],config:t,enter:d,exit:h,buffer:c,resume:m,data:i},Fe=[];let Ze=-1;for(;++Ze<ue.length;)if(ue[Ze][1].type==="listOrdered"||ue[Ze][1].type==="listUnordered")if(ue[Ze][0]==="enter")Fe.push(Ze);else{const ln=Fe.pop();Ze=s(ue,ln,Ze)}for(Ze=-1;++Ze<ue.length;){const ln=t[ue[Ze][0]];hw.call(ln,ue[Ze][1].type)&&ln[ue[Ze][1].type].call(Object.assign({sliceSerialize:ue[Ze][2].sliceSerialize},Oe),ue[Ze][1])}if(Oe.tokenStack.length>0){const ln=Oe.tokenStack[Oe.tokenStack.length-1];(ln[1]||_y).call(Oe,void 0,ln[0])}for(xe.position={start:ea(ue.length>0?ue[0][1].start:{line:1,column:1,offset:0}),end:ea(ue.length>0?ue[ue.length-2][1].end:{line:1,column:1,offset:0})},Ze=-1;++Ze<t.transforms.length;)xe=t.transforms[Ze](xe)||xe;return xe}function s(ue,xe,Oe){let Fe=xe-1,Ze=-1,ln=!1,En,Kt,At,Wt;for(;++Fe<=Oe;){const ut=ue[Fe];switch(ut[1].type){case"listUnordered":case"listOrdered":case"blockQuote":{ut[0]==="enter"?Ze++:Ze--,Wt=void 0;break}case"lineEndingBlank":{ut[0]==="enter"&&(En&&!Wt&&!Ze&&!At&&(At=Fe),Wt=void 0);break}case"linePrefix":case"listItemValue":case"listItemMarker":case"listItemPrefix":case"listItemPrefixWhitespace":break;default:Wt=void 0}if(!Ze&&ut[0]==="enter"&&ut[1].type==="listItemPrefix"||Ze===-1&&ut[0]==="exit"&&(ut[1].type==="listUnordered"||ut[1].type==="listOrdered")){if(En){let jn=Fe;for(Kt=void 0;jn--;){const cn=ue[jn];if(cn[1].type==="lineEnding"||cn[1].type==="lineEndingBlank"){if(cn[0]==="exit")continue;Kt&&(ue[Kt][1].type="lineEndingBlank",ln=!0),cn[1].type="lineEnding",Kt=jn}else if(!(cn[1].type==="linePrefix"||cn[1].type==="blockQuotePrefix"||cn[1].type==="blockQuotePrefixWhitespace"||cn[Line truncated
|
||||
`}]}function l5(e,t){const i=t.value?t.value+`
|
||||
`:"",a={},s=t.lang?t.lang.split(/\s+/):[];s.length>0&&(a.className=["language-"+s[0]]);let l={type:"element",tagName:"code",properties:a,children:[{type:"text",value:i}]};return t.meta&&(l.data={meta:t.meta}),e.patch(t,l),l=e.applyData(t,l),l={type:"element",tagName:"pre",properties:{},children:[l]},e.patch(t,l),l}function c5(e,t){const i={type:"element",tagName:"del",properties:{},children:e.all(t)};return e.patch(t,i),e.applyData(t,i)}function u5(e,t){const i={type:"element",tagName:"em",properties:{},children:e.all(t)};return e.patch(t,i),e.applyData(t,i)}function d5(e,t){const i=typeof e.options.clobberPrefix=="string"?e.options.clobberPrefix:"user-content-",a=String(t.identifier).toUpperCase(),s=Zs(a.toLowerCase()),l=e.footnoteOrder.indexOf(a);let c,d=e.footnoteCounts.get(a);d===void 0?(d=0,e.footnoteOrder.push(a),c=e.footnoteOrder.length):c=l+1,d+=1,e.footnoteCounts.set(a,d);const f={type:"element",tagName:"a",properties:{href:"#"+i+"fn-"+s,id:i+"fnref-"+s+(d>1?"-"+d:""),dataFootnoteRef:!0,ariaDescribedBy:["footnote-label"]},children:[{type:"text",value:String(c)}]};e.patch(t,f);const h={type:"element",tagName:"sup",properties:{},children:[f]};return e.patch(t,h),e.applyData(t,h)}function f5(e,t){const i={type:"element",tagName:"h"+t.depth,properties:{},children:e.all(t)};return e.patch(t,i),e.applyData(t,i)}function h5(e,t){if(e.options.allowDangerousHtml){const i={type:"raw",value:t.value};return e.patch(t,i),e.applyData(t,i)}}function pw(e,t){const i=t.referenceType;let a="]";if(i==="collapsed"?a+="[]":i==="full"&&(a+="["+(t.label||t.identifier)+"]"),t.type==="imageReference")return[{type:"text",value:"!["+t.alt+a}];const s=e.all(t),l=s[0];l&&l.type==="text"?l.value="["+l.value:s.unshift({type:"text",value:"["});const c=s[s.length-1];return c&&c.type==="text"?c.value+=a:s.push({type:"text",value:a}),s}function m5(e,t){const i=String(t.identifier).toUpperCase(),a=e.definitionById.get(i);if(!a)return pw(e,t);const s={src:Zs(a.url||""),alt:t.alt};a.title!==null&&a.title!==void 0&&(s.title=a.title);const l={type:"element",tagName:"img",properties:s,children:[]};return e.patch(t,l),e.applyData(t,l)}function p5(e,t){const i={src:Zs(t.url)};t.alt!==null&&t.alt!==void 0&&(i.alt=t.alt),t.title!==null&&t.title!==void 0&&(i.title=t.title);const a={type:"element",tagName:"img",properties:i,children:[]};return e.patch(t,a),e.applyData(t,a)}function g5(e,t){const i={type:"text",value:t.value.replace(/\r?\n|\r/g," ")};e.patch(t,i);const a={type:"element",tagName:"code",properties:{},children:[i]};return e.patch(t,a),e.applyData(t,a)}function b5(e,t){const i=String(t.identifier).toUpperCase(),a=e.definitionById.get(i);if(!a)return pw(e,t);const s={href:Zs(a.url||"")};a.title!==null&&a.title!==void 0&&(s.title=a.title);const l={type:"element",tagName:"a",properties:s,children:e.all(t)};return e.patch(t,l),e.applyData(t,l)}function x5(e,t){const i={href:Zs(t.url)};t.title!==null&&t.title!==void 0&&(i.title=t.title);const a={type:"element",tagName:"a",properties:i,children:e.all(t)};return e.patch(t,a),e.applyData(t,a)}function y5(e,t,i){const a=e.all(t),s=i?v5(i):gw(t),l={},c=[];if(typeof t.checked=="boolean"){const m=a[0];let p;m&&m.type==="element"&&m.tagName==="p"?p=m:(p={type:"element",tagName:"p",properties:{},children:[]},a.unshift(p)),p.children.length>0&&p.children.unshift({type:"text",value:" "}),p.children.unshift({type:"element",tagName:"input",properties:{type:"checkbox",checked:t.checked,disabled:!0},children:[]}),l.className=["task-list-item"]}let d=-1;for(;++d<a.length;){const m=a[d];(s||d!==0||m.type!=="element"||m.tagName!=="p")&&c.push({type:"text",value:`
|
||||
`}),m.type==="element"&&m.tagName==="p"&&!s?c.push(...m.children):c.push(m)}const f=a[a.length-1];f&&(s||f.type!=="element"||f.tagName!=="p")&&c.push({type:"text",value:`
|
||||
`});const h={type:"element",tagName:"li",properties:l,children:c};return e.patch(t,h),e.applyData(t,h)}function v5(e){let t=!1;if(e.type==="list"){t=e.spread||!1;const i=e.children;let a=-1;for(;!t&&++a<i.length;)t=gw(i[a])}return t}function gw(e){const t=e.spread;return t??e.children.length>1}function _5(e,t){const i={},a=e.all(t);let s=-1;for(typeof t.start=="number"&&t.start!==1&&(i.start=t.start);++s<a.length;){const c=a[s];if(c.type==="element"&&c.tagName==="li"&&c.properties&&Array.isArray(c.properties.className)&&c.properties.className.includes("task-list-item")){i.className=["contains-task-list"];break}}const l={type:"element",tagName:t.ordered?"ol":"ul",properties:i,children:e.wrap(a,!0)};return e.patch(t,l),e.applyData(t,l)}function w5(e,t){const i={type:"element",tagName:"p",properties:{},children:e.all(t)};return e.patch(t,i),e.applyData(t,i)}function E5(e,t){const i={type:"root",children:e.wrap(e.all(t))};return e.patch(t,i),e.applyData(t,i)}function N5(e,t){const i={type:"element",tagName:"strong",properties:{},children:e.all(t)};return e.patch(t,i),e.applyData(t,i)}function S5(e,t){const i=e.all(t),a=i.shift(),s=[];if(a){const c={type:"element",tagName:"thead",properties:{},children:e.wrap([a],!0)};e.patch(t.children[0],c),s.push(c)}if(i.length>0){const c={type:"element",tagName:"tbody",properties:{},children:e.wrap(i,!0)},d=bp(t.children[1]),f=K_(t.children[t.children.length-1]);d&&f&&(c.position={start:d,end:f}),s.push(c)}const l={type:"element",tagName:"table",properties:{},children:e.wrap(s,!0)};return e.patch(t,l),e.applyData(t,l)}function k5(e,t,i){const a=i?i.children:void 0,l=(a?a.indexOf(t):1)===0?"th":"td",c=i&&i.type==="table"?i.align:void 0,d=c?c.length:t.children.length;let f=-1;const h=[];for(;++f<d;){const p=t.children[f],x={},y=c?c[f]:void 0;y&&(x.align=y);let _={type:"element",tagName:l,properties:x,children:[]};p&&(_.children=e.all(p),e.patch(p,_),_=e.applyData(p,_)),h.push(_)}const m={type:"element",tagName:"tr",properties:{},children:e.wrap(h,!0)};return e.patch(t,m),e.applyData(t,m)}function T5(e,t){const i={type:"element",tagName:"td",properties:{},children:e.all(t)};return e.patch(t,i),e.applyData(t,i)}const wy=9,Ey=32;function C5(e){const t=String(e),i=/\r?\n|\r/g;let a=i.exec(t),s=0;const l=[];for(;a;)l.push(Ny(t.slice(s,a.index),s>0,!0),a[0]),s=a.index+a[0].length,a=i.exec(t);return l.push(Ny(t.slice(s),s>0,!1)),l.join("")}function Ny(e,t,i){let a=0,s=e.length;if(t){let l=e.codePointAt(a);for(;l===wy||l===Ey;)a++,l=e.codePointAt(a)}if(i){let l=e.codePointAt(s-1);for(;l===wy||l===Ey;)s--,l=e.codePointAt(s-1)}return s>a?e.slice(a,s):""}function A5(e,t){const i={type:"text",value:C5(String(t.value))};return e.patch(t,i),e.applyData(t,i)}function M5(e,t){const i={type:"element",tagName:"hr",properties:{},children:[]};return e.patch(t,i),e.applyData(t,i)}const O5={blockquote:s5,break:o5,code:l5,delete:c5,emphasis:u5,footnoteReference:d5,heading:f5,html:h5,imageReference:m5,image:p5,inlineCode:g5,linkReference:b5,link:x5,listItem:y5,list:_5,paragraph:w5,root:E5,strong:N5,table:S5,tableCell:T5,tableRow:k5,text:A5,thematicBreak:M5,toml:Vc,yaml:Vc,definition:Vc,footnoteDefinition:Vc};function Vc(){}const bw=-1,Iu=0,il=1,xu=2,Np=3,Sp=4,kp=5,Tp=6,xw=7,yw=8,vw=typeof self=="object"?self:globalThis,Sy=(e,t)=>{switch(e){case"Function":case"SharedWorker":case"Worker":case"eval":case"setInterval":case"setTimeout":throw new TypeError("unable to deserialize "+e)}return new vw[e](t)},R5=(e,t)=>{const i=(s,l)=>(e.set(l,s),s),a=s=>{if(e.has(s))return e.get(s);const[l,c]=t[s];switch(l){case Iu:case bw:return i(c,s);case il:{const d=i([],s);for(const f of c)d.push(a(f));return d}case xu:{const d=i({},s);for(const[f,h]of c)d[a(f)]=a(h);return d}case Np:return i(new Date(c),s);case Sp:{const{source:d,flags:f}=c;return i(new RegExp(d,f),s)}case kp:{const d=i(new Map,s);for(const[f,h]of c)d.set(a(f),a(h));return d}case Tp:{const d=i(new Set,s);for(const f of c)d.add(a(f));return d}case xw:{const{name:d,message:f}=c;return i(typeof vw[d]=="function"?Sy(d,f):new Error(f),s)}case yw:return i(BigInt(c),s);case"BigInt":return i(Object(BigInt(c)),s);case"ArrayBuffer":return i(new Uint8Array(c).buffer,c);case"DataView":{const{buffer:d}=new Uint8Array(c);return i(new DataView(d),c)}}return i(Sy(l,c),s)};return a},ky=e=>R5(new Map,e)(0),Ra="",{toString:D5}={},{keys:L5}=Object,Yo=e=>{const t=typeof e;if(t!=="object"||!e)return[Iu,t];const i=D5.call(e).slice(8,-1);switch(i){case"Array":return[il,Ra];case"Object":return[xu,Ra];case"Date":return[Np,Ra];case"RegExp":return[Sp,Ra];case"Map":return[kp,Ra];case"Set":return[Tp,Ra];case"DataView":return[il,i]}return i.includes("Array")?[il,i]:e instanceof Error?[xw,e.name||"Error"]:[xu,i]},Yc=([e,t])=>e===Iu&&(t==="function"||t==="symbol"),j5=(e,t,i,a)=>{const s=(c,d)=>{const f=a.push(c)-1;return i.set(d,f),f},l=c=>{if(i.has(c))return i.get(c);let[d,f]=Yo(c);switch(d){case Iu:{let m=c;switch(f){case"bigint":d=yw,m=c.toString();break;case"function":case"symbol":
Line truncated
|
||||
`},{type:"element",tagName:"ol",properties:{},children:e.wrap(d,!0)},{type:"text",value:`
|
||||
`}]}}const Bu=(function(e){if(e==null)return q5;if(typeof e=="function")return Uu(e);if(typeof e=="object")return Array.isArray(e)?U5(e):H5(e);if(typeof e=="string")return $5(e);throw new Error("Expected function, string, or object as test")});function U5(e){const t=[];let i=-1;for(;++i<e.length;)t[i]=Bu(e[i]);return Uu(a);function a(...s){let l=-1;for(;++l<t.length;)if(t[l].apply(this,s))return!0;return!1}}function H5(e){const t=e;return Uu(i);function i(a){const s=a;let l;for(l in e)if(s[l]!==t[l])return!1;return!0}}function $5(e){return Uu(t);function t(i){return i&&i.type===e}}function Uu(e){return t;function t(i,a,s){return!!(P5(i)&&e.call(this,i,typeof a=="number"?a:void 0,s||void 0))}}function q5(){return!0}function P5(e){return e!==null&&typeof e=="object"&&"type"in e}const _w=[],F5=!0,zm=!1,G5="skip";function ww(e,t,i,a){let s;typeof t=="function"&&typeof i!="function"?(a=i,i=t):s=t;const l=Bu(s),c=a?-1:1;d(e,void 0,[])();function d(f,h,m){const p=f&&typeof f=="object"?f:{};if(typeof p.type=="string"){const y=typeof p.tagName=="string"?p.tagName:typeof p.name=="string"?p.name:void 0;Object.defineProperty(x,"name",{value:"node ("+(f.type+(y?"<"+y+">":""))+")"})}return x;function x(){let y=_w,_,N,S;if((!t||l(f,h,m[m.length-1]||void 0))&&(y=V5(i(f,m)),y[0]===zm))return y;if("children"in f&&f.children){const w=f;if(w.children&&y[0]!==G5)for(N=(a?w.children.length:-1)+c,S=m.concat(w);N>-1&&N<w.children.length;){const k=w.children[N];if(_=d(k,N,S)(),_[0]===zm)return _;N=typeof _[1]=="number"?_[1]:N+c}}return y}}}function V5(e){return Array.isArray(e)?e:typeof e=="number"?[F5,e]:e==null?_w:[e]}function Cp(e,t,i,a){let s,l,c;typeof t=="function"&&typeof i!="function"?(l=void 0,c=t,s=i):(l=t,c=i,s=a),ww(e,l,d,s);function d(f,h){const m=h[h.length-1],p=m?m.children.indexOf(f):void 0;return c(f,p,m)}}const Im={}.hasOwnProperty,Y5={};function X5(e,t){const i=t||Y5,a=new Map,s=new Map,l=new Map,c={...O5,...i.handlers},d={all:h,applyData:Z5,definitionById:a,footnoteById:s,footnoteCounts:l,footnoteOrder:[],handlers:c,one:f,options:i,patch:K5,wrap:W5};return Cp(e,function(m){if(m.type==="definition"||m.type==="footnoteDefinition"){const p=m.type==="definition"?a:s,x=String(m.identifier).toUpperCase();p.has(x)||p.set(x,m)}}),d;function f(m,p){const x=m.type,y=d.handlers[x];if(Im.call(d.handlers,x)&&y)return y(d,m,p);if(d.options.passThrough&&d.options.passThrough.includes(x)){if("children"in m){const{children:N,...S}=m,w=yu(S);return w.children=d.all(m),w}return yu(m)}return(d.options.unknownHandler||Q5)(d,m,p)}function h(m){const p=[];if("children"in m){const x=m.children;let y=-1;for(;++y<x.length;){const _=d.one(x[y],m);if(_){if(y&&x[y-1].type==="break"&&(!Array.isArray(_)&&_.type==="text"&&(_.value=Cy(_.value)),!Array.isArray(_)&&_.type==="element")){const N=_.children[0];N&&N.type==="text"&&(N.value=Cy(N.value))}Array.isArray(_)?p.push(..._):p.push(_)}}}return p}}function K5(e,t){e.position&&(t.position=IA(e))}function Z5(e,t){let i=t;if(e&&e.data){const a=e.data.hName,s=e.data.hChildren,l=e.data.hProperties;if(typeof a=="string")if(i.type==="element")i.tagName=a;else{const c="children"in i?i.children:[i];i={type:"element",tagName:a,properties:{},children:c}}i.type==="element"&&l&&Object.assign(i.properties,yu(l)),"children"in i&&i.children&&s!==null&&s!==void 0&&(i.children=s)}return i}function Q5(e,t){const i=t.data||{},a="value"in t&&!(Im.call(i,"hProperties")||Im.call(i,"hChildren"))?{type:"text",value:t.value}:{type:"element",tagName:"div",properties:{},children:e.all(t)};return e.patch(t,a),e.applyData(t,a)}function W5(e,t){const i=[];let a=-1;for(t&&i.push({type:"text",value:`
|
||||
`});++a<e.length;)a&&i.push({type:"text",value:`
|
||||
`}),i.push(e[a]);return t&&e.length>0&&i.push({type:"text",value:`
|
||||
`}),i}function Cy(e){let t=0,i=e.charCodeAt(t);for(;i===9||i===32;)t++,i=e.charCodeAt(t);return e.slice(t)}function Ay(e,t){const i=X5(e,t),a=i.one(e,void 0),s=B5(i),l=Array.isArray(a)?{type:"root",children:a}:a||{type:"root",children:[]};return s&&l.children.push({type:"text",value:`
|
||||
`},s),l}function J5(e,t){return e&&"run"in e?async function(i,a){const s=Ay(i,{file:a,...t});await e.run(s,a)}:function(i,a){return Ay(i,{file:a,...e||t})}}function My(e){if(e)throw e}var hh,Oy;function eR(){if(Oy)return hh;Oy=1;var e=Object.prototype.hasOwnProperty,t=Object.prototype.toString,i=Object.defineProperty,a=Object.getOwnPropertyDescriptor,s=function(h){return typeof Array.isArray=="function"?Array.isArray(h):t.call(h)==="[object Array]"},l=function(h){if(!h||t.call(h)!=="[object Object]")return!1;var m=e.call(h,"constructor"),p=h.constructor&&h.constructor.prototype&&e.call(h.constructor.prototype,"isPrototypeOf");if(h.constructor&&!m&&!p)return!1;var x;for(x in h);return typeof x>"u"||e.call(h,x)},c=function(h,m){i&&m.name==="__proto__"?i(h,m.name,{enumerable:!0,configurable:!0,value:m.newValue,writable:!0}):h[m.name]=m.newValue},d=function(h,m){if(m==="__proto__")if(e.call(h,m)){if(a)return a(h,m).value}else return;return h[m]};return hh=function f(){var h,m,p,x,y,_,N=arguments[0],S=1,w=arguments.length,k=!1;for(typeof N=="boolean"&&(k=N,N=arguments[1]||{},S=2),(N==null||typeof N!="object"&&typeof N!="function")&&(N={});S<w;++S)if(h=arguments[S],h!=null)for(m in h)p=d(N,m),x=d(h,m),N!==x&&(k&&x&&(l(x)||(y=s(x)))?(y?(y=!1,_=p&&s(p)?p:[]):_=p&&l(p)?p:{},c(N,{name:m,newValue:f(k,_,x)})):typeof x<"u"&&c(N,{name:m,newValue:x}));return N},hh}var tR=eR();const mh=yl(tR);function Bm(e){if(typeof e!="object"||e===null)return!1;const t=Object.getPrototypeOf(e);return(t===null||t===Object.prototype||Object.getPrototypeOf(t)===null)&&!(Symbol.toStringTag in e)&&!(Symbol.iterator in e)}function nR(){const e=[],t={run:i,use:a};return t;function i(...s){let l=-1;const c=s.pop();if(typeof c!="function")throw new TypeError("Expected function as last argument, not "+c);d(null,...s);function d(f,...h){const m=e[++l];let p=-1;if(f){c(f);return}for(;++p<s.length;)(h[p]===null||h[p]===void 0)&&(h[p]=s[p]);s=h,m?iR(m,d)(...h):c(null,...h)}}function a(s){if(typeof s!="function")throw new TypeError("Expected `middelware` to be a function, not "+s);return e.push(s),t}}function iR(e,t){let i;return a;function a(...c){const d=e.length>c.length;let f;d&&c.push(s);try{f=e.apply(this,c)}catch(h){const m=h;if(d&&i)throw m;return s(m)}d||(f&&f.then&&typeof f.then=="function"?f.then(l,s):f instanceof Error?s(f):l(f))}function s(c,...d){i||(i=!0,t(c,...d))}function l(c){s(null,c)}}const qi={basename:rR,dirname:aR,extname:sR,join:oR,sep:"/"};function rR(e,t){if(t!==void 0&&typeof t!="string")throw new TypeError('"ext" argument must be a string');Nl(e);let i=0,a=-1,s=e.length,l;if(t===void 0||t.length===0||t.length>e.length){for(;s--;)if(e.codePointAt(s)===47){if(l){i=s+1;break}}else a<0&&(l=!0,a=s+1);return a<0?"":e.slice(i,a)}if(t===e)return"";let c=-1,d=t.length-1;for(;s--;)if(e.codePointAt(s)===47){if(l){i=s+1;break}}else c<0&&(l=!0,c=s+1),d>-1&&(e.codePointAt(s)===t.codePointAt(d--)?d<0&&(a=s):(d=-1,a=c));return i===a?a=c:a<0&&(a=e.length),e.slice(i,a)}function aR(e){if(Nl(e),e.length===0)return".";let t=-1,i=e.length,a;for(;--i;)if(e.codePointAt(i)===47){if(a){t=i;break}}else a||(a=!0);return t<0?e.codePointAt(0)===47?"/":".":t===1&&e.codePointAt(0)===47?"//":e.slice(0,t)}function sR(e){Nl(e);let t=e.length,i=-1,a=0,s=-1,l=0,c;for(;t--;){const d=e.codePointAt(t);if(d===47){if(c){a=t+1;break}continue}i<0&&(c=!0,i=t+1),d===46?s<0?s=t:l!==1&&(l=1):s>-1&&(l=-1)}return s<0||i<0||l===0||l===1&&s===i-1&&s===a+1?"":e.slice(s,i)}function oR(...e){let t=-1,i;for(;++t<e.length;)Nl(e[t]),e[t]&&(i=i===void 0?e[t]:i+"/"+e[t]);return i===void 0?".":lR(i)}function lR(e){Nl(e);const t=e.codePointAt(0)===47;let i=cR(e,!t);return i.length===0&&!t&&(i="."),i.length>0&&e.codePointAt(e.length-1)===47&&(i+="/"),t?"/"+i:i}function cR(e,t){let i="",a=0,s=-1,l=0,c=-1,d,f;for(;++c<=e.length;){if(c<e.length)d=e.codePointAt(c);else{if(d===47)break;d=47}if(d===47){if(!(s===c-1||l===1))if(s!==c-1&&l===2){if(i.length<2||a!==2||i.codePointAt(i.length-1)!==46||i.codePointAt(i.length-2)!==46){if(i.length>2){if(f=i.lastIndexOf("/"),f!==i.length-1){f<0?(i="",a=0):(i=i.slice(0,f),a=i.length-1-i.lastIndexOf("/")),s=c,l=0;continue}}else if(i.length>0){i="",a=0,s=c,l=0;continue}}t&&(i=i.length>0?i+"/..":"..",a=2)}else i.length>0?i+="/"+e.slice(s+1,c):i=e.slice(s+1,c),a=c-s-1;s=c,l=0}else d===46&&l>-1?l++:l=-1}return i}function Nl(e){if(typeof e!="string")throw new TypeError("Path must be a string. Received "+JSON.stringify(e))}const uR={cwd:dR};function dR(){return"/"}function Um(e){return!!(e!==null&&typeof e=="object"&&"href"in e&&e.href&&"protocol"in e&&e.protocol&&e.auth===void 0)}function fR(e){if(typeof e=="string")e=new URL(e);else if(!Um(e)){const t=new TypeError('The "path" argument must be of type string or an instance of URL. Received `'+e+"`");throw t.code="ERR_INVALID_ARG_TYPE",t}if(e.protocol!=="file:"){const t=new TypeError("The URL must be of scheme file");throw t.code="ERR_INVALID_URL_SCHEME",t}return hR(e)}function hR(e){if(e.hostname!==""){consLine truncated
|
||||
`:" ")+l.indentLines(l.containerFlow(a,d.current()),t?kw:n3))),h(),f}}function n3(e,t,i){return t===0?e:kw(e,t,i)}function kw(e,t,i){return(i?"":" ")+e}const i3=["autolink","destinationLiteral","destinationRaw","reference","titleQuote","titleApostrophe"];Tw.peek=l3;function r3(){return{canContainEols:["delete"],enter:{strikethrough:s3},exit:{strikethrough:o3}}}function a3(){return{unsafe:[{character:"~",inConstruct:"phrasing",notInConstruct:i3}],handlers:{delete:Tw}}}function s3(e){this.enter({type:"delete",children:[]},e)}function o3(e){this.exit(e)}function Tw(e,t,i,a){const s=i.createTracker(a),l=i.enter("strikethrough");let c=s.move("~~");return c+=i.containerPhrasing(e,{...s.current(),before:c,after:"~"}),c+=s.move("~~"),l(),c}function l3(){return"~"}function c3(e){return e.length}function u3(e,t){const i=t||{},a=(i.align||[]).concat(),s=i.stringLength||c3,l=[],c=[],d=[],f=[];let h=0,m=-1;for(;++m<e.length;){const N=[],S=[];let w=-1;for(e[m].length>h&&(h=e[m].length);++w<e[m].length;){const k=d3(e[m][w]);if(i.alignDelimiters!==!1){const E=s(k);S[w]=E,(f[w]===void 0||E>f[w])&&(f[w]=E)}N.push(k)}c[m]=N,d[m]=S}let p=-1;if(typeof a=="object"&&"length"in a)for(;++p<h;)l[p]=By(a[p]);else{const N=By(a);for(;++p<h;)l[p]=N}p=-1;const x=[],y=[];for(;++p<h;){const N=l[p];let S="",w="";N===99?(S=":",w=":"):N===108?S=":":N===114&&(w=":");let k=i.alignDelimiters===!1?1:Math.max(1,f[p]-S.length-w.length);const E=S+"-".repeat(k)+w;i.alignDelimiters!==!1&&(k=S.length+k+w.length,k>f[p]&&(f[p]=k),y[p]=k),x[p]=E}c.splice(1,0,x),d.splice(1,0,y),m=-1;const _=[];for(;++m<c.length;){const N=c[m],S=d[m];p=-1;const w=[];for(;++p<h;){const k=N[p]||"";let E="",O="";if(i.alignDelimiters!==!1){const U=f[p]-(S[p]||0),R=l[p];R===114?E=" ".repeat(U):R===99?U%2?(E=" ".repeat(U/2+.5),O=" ".repeat(U/2-.5)):(E=" ".repeat(U/2),O=E):O=" ".repeat(U)}i.delimiterStart!==!1&&!p&&w.push("|"),i.padding!==!1&&!(i.alignDelimiters===!1&&k==="")&&(i.delimiterStart!==!1||p)&&w.push(" "),i.alignDelimiters!==!1&&w.push(E),w.push(k),i.alignDelimiters!==!1&&w.push(O),i.padding!==!1&&w.push(" "),(i.delimiterEnd!==!1||p!==h-1)&&w.push("|")}_.push(i.delimiterEnd===!1?w.join("").replace(/ +$/,""):w.join(""))}return _.join(`
|
||||
`)}function d3(e){return e==null?"":String(e)}function By(e){const t=typeof e=="string"?e.codePointAt(0):0;return t===67||t===99?99:t===76||t===108?108:t===82||t===114?114:0}function f3(e,t,i,a){const s=i.enter("blockquote"),l=i.createTracker(a);l.move("> "),l.shift(2);const c=i.indentLines(i.containerFlow(e,l.current()),h3);return s(),c}function h3(e,t,i){return">"+(i?"":" ")+e}function m3(e,t){return Uy(e,t.inConstruct,!0)&&!Uy(e,t.notInConstruct,!1)}function Uy(e,t,i){if(typeof t=="string"&&(t=[t]),!t||t.length===0)return i;let a=-1;for(;++a<t.length;)if(e.includes(t[a]))return!0;return!1}function Hy(e,t,i,a){let s=-1;for(;++s<i.unsafe.length;)if(i.unsafe[s].character===`
|
||||
`&&m3(i.stack,i.unsafe[s]))return/[ \t]/.test(a.before)?"":" ";return`\\
|
||||
`}function p3(e,t){const i=String(e);let a=i.indexOf(t),s=a,l=0,c=0;if(typeof t!="string")throw new TypeError("Expected substring");for(;a!==-1;)a===s?++l>c&&(c=l):l=1,s=a+t.length,a=i.indexOf(t,s);return c}function g3(e,t){return!!(t.options.fences===!1&&e.value&&!e.lang&&/[^ \r\n]/.test(e.value)&&!/^[\t ]*(?:[\r\n]|$)|(?:^|[\r\n])[\t ]*$/.test(e.value))}function b3(e){const t=e.options.fence||"`";if(t!=="`"&&t!=="~")throw new Error("Cannot serialize code with `"+t+"` for `options.fence`, expected `` ` `` or `~`");return t}function x3(e,t,i,a){const s=b3(i),l=e.value||"",c=s==="`"?"GraveAccent":"Tilde";if(g3(e,i)){const p=i.enter("codeIndented"),x=i.indentLines(l,y3);return p(),x}const d=i.createTracker(a),f=s.repeat(Math.max(p3(l,s)+1,3)),h=i.enter("codeFenced");let m=d.move(f);if(e.lang){const p=i.enter(`codeFencedLang${c}`);m+=d.move(i.safe(e.lang,{before:m,after:" ",encode:["`"],...d.current()})),p()}if(e.lang&&e.meta){const p=i.enter(`codeFencedMeta${c}`);m+=d.move(" "),m+=d.move(i.safe(e.meta,{before:m,after:`
|
||||
`,encode:["`"],...d.current()})),p()}return m+=d.move(`
|
||||
`),l&&(m+=d.move(l+`
|
||||
`)),m+=d.move(f),h(),m}function y3(e,t,i){return(i?"":" ")+e}function Op(e){const t=e.options.quote||'"';if(t!=='"'&&t!=="'")throw new Error("Cannot serialize title with `"+t+"` for `options.quote`, expected `\"`, or `'`");return t}function v3(e,t,i,a){const s=Op(i),l=s==='"'?"Quote":"Apostrophe",c=i.enter("definition");let d=i.enter("label");const f=i.createTracker(a);let h=f.move("[");return h+=f.move(i.safe(i.associationId(e),{before:h,after:"]",...f.current()})),h+=f.move("]: "),d(),!e.url||/[\0- \u007F]/.test(e.url)?(d=i.enter("destinationLiteral"),h+=f.move("<"),h+=f.move(i.safe(e.url,{before:h,after:">",...f.current()})),h+=f.move(">")):(d=i.enter("destinationRaw"),h+=f.move(i.safe(e.url,{before:h,after:e.title?" ":`
|
||||
`,...f.current()}))),d(),e.title&&(d=i.enter(`title${l}`),h+=f.move(" "+s),h+=f.move(i.safe(e.title,{before:h,after:s,...f.current()})),h+=f.move(s),d()),c(),h}function _3(e){const t=e.options.emphasis||"*";if(t!=="*"&&t!=="_")throw new Error("Cannot serialize emphasis with `"+t+"` for `options.emphasis`, expected `*`, or `_`");return t}function ll(e){return"&#x"+e.toString(16).toUpperCase()+";"}function vu(e,t,i){const a=$s(e),s=$s(t);return a===void 0?s===void 0?i==="_"?{inside:!0,outside:!0}:{inside:!1,outside:!1}:s===1?{inside:!0,outside:!0}:{inside:!1,outside:!0}:a===1?s===void 0?{inside:!1,outside:!1}:s===1?{inside:!0,outside:!0}:{inside:!1,outside:!1}:s===void 0?{inside:!1,outside:!1}:s===1?{inside:!0,outside:!1}:{inside:!1,outside:!1}}Cw.peek=w3;function Cw(e,t,i,a){const s=_3(i),l=i.enter("emphasis"),c=i.createTracker(a),d=c.move(s);let f=c.move(i.containerPhrasing(e,{after:s,before:d,...c.current()}));const h=f.charCodeAt(0),m=vu(a.before.charCodeAt(a.before.length-1),h,s);m.inside&&(f=ll(h)+f.slice(1));const p=f.charCodeAt(f.length-1),x=vu(a.after.charCodeAt(0),p,s);x.inside&&(f=f.slice(0,-1)+ll(p));const y=c.move(s);return l(),i.attentionEncodeSurroundingInfo={after:x.outside,before:m.outside},d+f+y}function w3(e,t,i){return i.options.emphasis||"*"}function E3(e,t){let i=!1;return Cp(e,function(a){if("value"in a&&/\r?\n|\r/.test(a.value)||a.type==="break")return i=!0,zm}),!!((!e.depth||e.depth<3)&&_p(e)&&(t.options.setext||i))}function N3(e,t,i,a){const s=Math.max(Math.min(6,e.depth||1),1),l=i.createTracker(a);if(E3(e,i)){const m=i.enter("headingSetext"),p=i.enter("phrasing"),x=i.containerPhrasing(e,{...l.current(),before:`
|
||||
`,after:`
|
||||
`});return p(),m(),x+`
|
||||
`+(s===1?"=":"-").repeat(x.length-(Math.max(x.lastIndexOf("\r"),x.lastIndexOf(`
|
||||
`))+1))}const c="#".repeat(s),d=i.enter("headingAtx"),f=i.enter("phrasing");l.move(c+" ");let h=i.containerPhrasing(e,{before:"# ",after:`
|
||||
`,...l.current()});return/^[\t ]/.test(h)&&(h=ll(h.charCodeAt(0))+h.slice(1)),h=h?c+" "+h:c,i.options.closeAtx&&(h+=" "+c),f(),d(),h}Aw.peek=S3;function Aw(e){return e.value||""}function S3(){return"<"}Mw.peek=k3;function Mw(e,t,i,a){const s=Op(i),l=s==='"'?"Quote":"Apostrophe",c=i.enter("image");let d=i.enter("label");const f=i.createTracker(a);let h=f.move("![");return h+=f.move(i.safe(e.alt,{before:h,after:"]",...f.current()})),h+=f.move("]("),d(),!e.url&&e.title||/[\0- \u007F]/.test(e.url)?(d=i.enter("destinationLiteral"),h+=f.move("<"),h+=f.move(i.safe(e.url,{before:h,after:">",...f.current()})),h+=f.move(">")):(d=i.enter("destinationRaw"),h+=f.move(i.safe(e.url,{before:h,after:e.title?" ":")",...f.current()}))),d(),e.title&&(d=i.enter(`title${l}`),h+=f.move(" "+s),h+=f.move(i.safe(e.title,{before:h,after:s,...f.current()})),h+=f.move(s),d()),h+=f.move(")"),c(),h}function k3(){return"!"}Ow.peek=T3;function Ow(e,t,i,a){const s=e.referenceType,l=i.enter("imageReference");let c=i.enter("label");const d=i.createTracker(a);let f=d.move("![");const h=i.safe(e.alt,{before:f,after:"]",...d.current()});f+=d.move(h+"]["),c();const m=i.stack;i.stack=[],c=i.enter("reference");const p=i.safe(i.associationId(e),{before:f,after:"]",...d.current()});return c(),i.stack=m,l(),s==="full"||!h||h!==p?f+=d.move(p+"]"):s==="shortcut"?f=f.slice(0,-1):f+=d.move("]"),f}function T3(){return"!"}Rw.peek=C3;function Rw(e,t,i){let a=e.value||"",s="`",l=-1;for(;new RegExp("(^|[^`])"+s+"([^`]|$)").test(a);)s+="`";for(/[^ \r\n]/.test(a)&&(/^[ \r\n]/.test(a)&&/[ \r\n]$/.test(a)||/^`|`$/.test(a))&&(a=" "+a+" ");++l<i.unsafe.length;){const c=i.unsafe[l],d=i.compilePattern(c);let f;if(c.atBreak)for(;f=d.exec(a);){let h=f.index;a.charCodeAt(h)===10&&a.charCodeAt(h-1)===13&&h--,a=a.slice(0,h)+" "+a.slice(f.index+1)}}return s+a+s}function C3(){return"`"}function Dw(e,t){const i=_p(e);return!!(!t.options.resourceLink&&e.url&&!e.title&&e.children&&e.children.length===1&&e.children[0].type==="text"&&(i===e.url||"mailto:"+i===e.url)&&/^[a-z][a-z+.-]+:/i.test(e.url)&&!/[\0- <>\u007F]/.test(e.url))}Lw.peek=A3;function Lw(e,t,i,a){const s=Op(i),l=s==='"'?"Quote":"Apostrophe",c=i.createTracker(a);let d,f;if(Dw(e,i)){const m=i.stack;i.stack=[],d=i.enter("autolink");let p=c.move("<");return p+=c.move(i.containerPhrasing(e,{before:p,after:">",...c.current()})),p+=c.move(">"),d(),i.stack=m,p}d=i.enter("link"),f=i.enter("label");let h=c.move("[");return h+=c.move(i.containerPhrasing(e,{before:h,after:"](",...c.current()})),h+=c.move("]("),f(),!e.url&&e.title||/[\0- \u007F]/.test(e.url)?(f=i.enter("destinationLiteral"),h+=c.move("<"),h+=c.move(i.safe(e.url,{before:h,after:">",...c.current()})),h+=c.move(">")):(f=i.enter("destinationRaw"),h+=c.move(i.safe(e.url,{before:h,after:e.title?" ":")",...c.current()}))),f(),e.title&&(f=i.enter(`title${l}`),h+=c.move(" "+s),h+=c.move(i.safe(e.title,{before:h,after:s,...c.current()})),h+=c.move(s),f()),h+=c.move(")"),d(),h}function A3(e,t,i){return Dw(e,i)?"<":"["}jw.peek=M3;function jw(e,t,i,a){const s=e.referenceType,l=i.enter("linkReference");let c=i.enter("label");const d=i.createTracker(a);let f=d.move("[");const h=i.containerPhrasing(e,{before:f,after:"]",...d.current()});f+=d.move(h+"]["),c();const m=i.stack;i.stack=[],c=i.enter("reference");const p=i.safe(i.associationId(e),{before:f,after:"]",...d.current()});return c(),i.stack=m,l(),s==="full"||!h||h!==p?f+=d.move(p+"]"):s==="shortcut"?f=f.slice(0,-1):f+=d.move("]"),f}function M3(){return"["}function Rp(e){const t=e.options.bullet||"*";if(t!=="*"&&t!=="+"&&t!=="-")throw new Error("Cannot serialize items with `"+t+"` for `options.bullet`, expected `*`, `+`, or `-`");return t}function O3(e){const t=Rp(e),i=e.options.bulletOther;if(!i)return t==="*"?"-":"*";if(i!=="*"&&i!=="+"&&i!=="-")throw new Error("Cannot serialize items with `"+i+"` for `options.bulletOther`, expected `*`, `+`, or `-`");if(i===t)throw new Error("Expected `bullet` (`"+t+"`) and `bulletOther` (`"+i+"`) to be different");return i}function R3(e){const t=e.options.bulletOrdered||".";if(t!=="."&&t!==")")throw new Error("Cannot serialize items with `"+t+"` for `options.bulletOrdered`, expected `.` or `)`");return t}function zw(e){const t=e.options.rule||"*";if(t!=="*"&&t!=="-"&&t!=="_")throw new Error("Cannot serialize rules with `"+t+"` for `options.rule`, expected `*`, `-`, or `_`");return t}function D3(e,t,i,a){const s=i.enter("list"),l=i.bulletCurrent;let c=e.ordered?R3(i):Rp(i);const d=e.ordered?c==="."?")":".":O3(i);let f=t&&i.bulletLastUsed?c===i.bulletLastUsed:!1;if(!e.ordered){const m=e.children?e.children[0]:void 0;if((c==="*"||c==="-")&&m&&(!m.children||!m.children[0])&&i.stack[i.stack.length-1]==="list"&&i.stack[i.stack.length-2]==="listItem"&&i.stack[i.stack.length-3]==="list"&&i.stack[i.stack.length-4]==="listItem"&&i.indexStack[i.indexStack.length-1]===0&&i.indexStack[i.indexStack.length-2]===0&&i.indexStack[i.indexStack.length-3]===0&&(f=!0),zw(i)===c&&m){let p=-1;for(;++p<e.chilLine truncated
|
||||
`,inConstruct:"tableCell"},{atBreak:!0,character:"|",after:"[ :-]"},{character:"|",inConstruct:"tableCell"},{atBreak:!0,character:":",after:"-"},{atBreak:!0,character:"-",after:"[:|-]"}],handlers:{inlineCode:x,table:c,tableCell:f,tableRow:d}};function c(y,_,N,S){return h(m(y,N,S),y.align)}function d(y,_,N,S){const w=p(y,N,S),k=h([w]);return k.slice(0,k.indexOf(`
|
||||
`))}function f(y,_,N,S){const w=N.enter("tableCell"),k=N.enter("phrasing"),E=N.containerPhrasing(y,{...S,before:l,after:l});return k(),w(),E}function h(y,_){return u3(y,{align:_,alignDelimiters:a,padding:i,stringLength:s})}function m(y,_,N){const S=y.children;let w=-1;const k=[],E=_.enter("table");for(;++w<S.length;)k[w]=p(S[w],_,N);return E(),k}function p(y,_,N){const S=y.children;let w=-1;const k=[],E=_.enter("tableRow");for(;++w<S.length;)k[w]=f(S[w],y,_,N);return E(),k}function x(y,_,N){let S=Bw.inlineCode(y,_,N);return N.stack.includes("tableCell")&&(S=S.replace(/\|/g,"\\$&")),S}}function Q3(){return{exit:{taskListCheckValueChecked:qy,taskListCheckValueUnchecked:qy,paragraph:J3}}}function W3(){return{unsafe:[{atBreak:!0,character:"-",after:"[:|-]"}],handlers:{listItem:e4}}}function qy(e){const t=this.stack[this.stack.length-2];t.type,t.checked=e.type==="taskListCheckValueChecked"}function J3(e){const t=this.stack[this.stack.length-2];if(t&&t.type==="listItem"&&typeof t.checked=="boolean"){const i=this.stack[this.stack.length-1];i.type;const a=i.children[0];if(a&&a.type==="text"){const s=t.children;let l=-1,c;for(;++l<s.length;){const d=s[l];if(d.type==="paragraph"){c=d;break}}c===i&&(a.value=a.value.slice(1),a.value.length===0?i.children.shift():i.position&&a.position&&typeof a.position.start.offset=="number"&&(a.position.start.column++,a.position.start.offset++,i.position.start=Object.assign({},a.position.start)))}}this.exit(e)}function e4(e,t,i,a){const s=e.children[0],l=typeof e.checked=="boolean"&&s&&s.type==="paragraph",c="["+(e.checked?"x":" ")+"] ",d=i.createTracker(a);l&&d.move(c);let f=Bw.listItem(e,t,i,{...a,...d.current()});return l&&(f=f.replace(/^(?:[*+-]|\d+\.)([\r\n]| {1,3})/,h)),f;function h(m){return m+c}}function t4(){return[DR(),e3(),r3(),F3(),Q3()]}function n4(e){return{extensions:[LR(),t3(e),a3(),Z3(e),W3()]}}const i4={tokenize:c4,partial:!0},Uw={tokenize:u4,partial:!0},Hw={tokenize:d4,partial:!0},$w={tokenize:f4,partial:!0},r4={tokenize:h4,partial:!0},qw={name:"wwwAutolink",tokenize:o4,previous:Fw},Pw={name:"protocolAutolink",tokenize:l4,previous:Gw},yr={name:"emailAutolink",tokenize:s4,previous:Vw},Vi={};function a4(){return{text:Vi}}let Oa=48;for(;Oa<123;)Vi[Oa]=yr,Oa++,Oa===58?Oa=65:Oa===91&&(Oa=97);Vi[43]=yr;Vi[45]=yr;Vi[46]=yr;Vi[95]=yr;Vi[72]=[yr,Pw];Vi[104]=[yr,Pw];Vi[87]=[yr,qw];Vi[119]=[yr,qw];function s4(e,t,i){const a=this;let s,l;return c;function c(p){return!Hm(p)||!Vw.call(a,a.previous)||Dp(a.events)?i(p):(e.enter("literalAutolink"),e.enter("literalAutolinkEmail"),d(p))}function d(p){return Hm(p)?(e.consume(p),d):p===64?(e.consume(p),f):i(p)}function f(p){return p===46?e.check(r4,m,h)(p):p===45||p===95||Tn(p)?(l=!0,e.consume(p),f):m(p)}function h(p){return e.consume(p),s=!0,f}function m(p){return l&&s&&Dn(a.previous)?(e.exit("literalAutolinkEmail"),e.exit("literalAutolink"),t(p)):i(p)}}function o4(e,t,i){const a=this;return s;function s(c){return c!==87&&c!==119||!Fw.call(a,a.previous)||Dp(a.events)?i(c):(e.enter("literalAutolink"),e.enter("literalAutolinkWww"),e.check(i4,e.attempt(Uw,e.attempt(Hw,l),i),i)(c))}function l(c){return e.exit("literalAutolinkWww"),e.exit("literalAutolink"),t(c)}}function l4(e,t,i){const a=this;let s="",l=!1;return c;function c(p){return(p===72||p===104)&&Gw.call(a,a.previous)&&!Dp(a.events)?(e.enter("literalAutolink"),e.enter("literalAutolinkHttp"),s+=String.fromCodePoint(p),e.consume(p),d):i(p)}function d(p){if(Dn(p)&&s.length<5)return s+=String.fromCodePoint(p),e.consume(p),d;if(p===58){const x=s.toLowerCase();if(x==="http"||x==="https")return e.consume(p),f}return i(p)}function f(p){return p===47?(e.consume(p),l?h:(l=!0,f)):i(p)}function h(p){return p===null||bu(p)||Ct(p)||Ba(p)||ju(p)?i(p):e.attempt(Uw,e.attempt(Hw,m),i)(p)}function m(p){return e.exit("literalAutolinkHttp"),e.exit("literalAutolink"),t(p)}}function c4(e,t,i){let a=0;return s;function s(c){return(c===87||c===119)&&a<3?(a++,e.consume(c),s):c===46&&a===3?(e.consume(c),l):i(c)}function l(c){return c===null?i(c):t(c)}}function u4(e,t,i){let a,s,l;return c;function c(h){return h===46||h===95?e.check($w,f,d)(h):h===null||Ct(h)||Ba(h)||h!==45&&ju(h)?f(h):(l=!0,e.consume(h),c)}function d(h){return h===95?a=!0:(s=a,a=void 0),e.consume(h),c}function f(h){return s||a||!l?i(h):t(h)}}function d4(e,t){let i=0,a=0;return s;function s(c){return c===40?(i++,e.consume(c),s):c===41&&a<i?l(c):c===33||c===34||c===38||c===39||c===41||c===42||c===44||c===46||c===58||c===59||c===60||c===63||c===93||c===95||c===126?e.check($w,t,l)(c):c===null||Ct(c)||Ba(c)?t(c):(e.consume(c),s)}function l(c){return c===41&&a++,e.consume(c),s}}function f4(e,t,i){return a;function a(d){return d===33||d===34||d===39||d===41||d===42||d===44||d===46||d===58||d===59||d===63||d===95||d===126?(e.consume(d),a):d===38?(e.consume(d),l):d===93?(e.consume(d),s):d===60||d===null||Ct(d)||Ba(d)?t(d):i(d)}function s(d){return d===null||d===40||d===91||Ct(d)||Ba(d)?t(d):a(d)}function l(d){return Dn(d)?c(d):i(d)}function c(d){re
Line truncated
|
||||
https://github.com/highlightjs/highlight.js/issues/2277`),Ht=ye,ft=je),Qe===void 0&&(Qe=!0);const pn={code:ft,language:Ht};Zi("before:highlight",pn);const Mn=pn.result?pn.result:gi(pn.language,pn.code,Qe);return Mn.code=pn.code,Zi("after:highlight",Mn),Mn}function gi(ye,je,Qe,ft){const Ht=Object.create(null);function pn(_e,Re){return _e.keywords[Re]}function Mn(){if(!qe.keywords){Jt.addText(bt);return}let _e=0;qe.keywordPatternRe.lastIndex=0;let Re=qe.keywordPatternRe.exec(bt),Ye="";for(;Re;){Ye+=bt.substring(_e,Re.index);const it=un.case_insensitive?Re[0].toLowerCase():Re[0],$t=pn(qe,it);if($t){const[si,Ws]=$t;if(Jt.addText(Ye),Ye="",Ht[it]=(Ht[it]||0)+1,Ht[it]<=Yn&&(Cr+=Ws),si.startsWith("_"))Ye+=Re[0];else{const Ll=un.classNameAliases[si]||si;On(Re[0],Ll)}}else Ye+=Re[0];_e=qe.keywordPatternRe.lastIndex,Re=qe.keywordPatternRe.exec(bt)}Ye+=bt.substring(_e),Jt.addText(Ye)}function Nn(){if(bt==="")return;let _e=null;if(typeof qe.subLanguage=="string"){if(!me[qe.subLanguage]){Jt.addText(bt);return}_e=gi(qe.subLanguage,bt,!0,Dl[qe.subLanguage]),Dl[qe.subLanguage]=_e._top}else _e=wr(bt,qe.subLanguage.length?qe.subLanguage:null);qe.relevance>0&&(Cr+=_e.relevance),Jt.__addSublanguage(_e._emitter,_e.language)}function _t(){qe.subLanguage!=null?Nn():Mn(),bt=""}function On(_e,Re){_e!==""&&(Jt.startScope(Re),Jt.addText(_e),Jt.endScope())}function Ka(_e,Re){let Ye=1;const it=Re.length-1;for(;Ye<=it;){if(!_e._emit[Ye]){Ye++;continue}const $t=un.classNameAliases[_e[Ye]]||_e[Ye],si=Re[Ye];$t?On(si,$t):(bt=si,Mn(),bt=""),Ye++}}function Sr(_e,Re){return _e.scope&&typeof _e.scope=="string"&&Jt.openNode(un.classNameAliases[_e.scope]||_e.scope),_e.beginScope&&(_e.beginScope._wrap?(On(bt,un.classNameAliases[_e.beginScope._wrap]||_e.beginScope._wrap),bt=""):_e.beginScope._multi&&(Ka(_e.beginScope,Re),bt="")),qe=Object.create(_e,{parent:{value:qe}}),qe}function zi(_e,Re,Ye){let it=E(_e.endRe,Ye);if(it){if(_e["on:end"]){const $t=new t(_e);_e["on:end"](Re,$t),$t.isMatchIgnored&&(it=!1)}if(it){for(;_e.endsParent&&_e.parent;)_e=_e.parent;return _e}}if(_e.endsWithParent)return zi(_e.parent,Re,Ye)}function kr(_e){return qe.matcher.regexIndex===0?(bt+=_e[0],1):(Ar=!0,0)}function Za(_e){const Re=_e[0],Ye=_e.rule,it=new t(Ye),$t=[Ye.__beforeBegin,Ye["on:begin"]];for(const si of $t)if(si&&(si(_e,it),it.isMatchIgnored))return kr(Re);return Ye.skip?bt+=Re:(Ye.excludeBegin&&(bt+=Re),_t(),!Ye.returnBegin&&!Ye.excludeBegin&&(bt=Re)),Sr(Ye,_e),Ye.returnBegin?0:Re.length}function Sn(_e){const Re=_e[0],Ye=je.substring(_e.index),it=zi(qe,_e,Ye);if(!it)return nt;const $t=qe;qe.endScope&&qe.endScope._wrap?(_t(),On(Re,qe.endScope._wrap)):qe.endScope&&qe.endScope._multi?(_t(),Ka(qe.endScope,_e)):$t.skip?bt+=Re:($t.returnEnd||$t.excludeEnd||(bt+=Re),_t(),$t.excludeEnd&&(bt=Re));do qe.scope&&Jt.closeNode(),!qe.skip&&!qe.subLanguage&&(Cr+=qe.relevance),qe=qe.parent;while(qe!==it.parent);return it.starts&&Sr(it.starts,_e),$t.returnEnd?0:Re.length}function fa(){const _e=[];for(let Re=qe;Re!==un;Re=Re.parent)Re.scope&&_e.unshift(Re.scope);_e.forEach(Re=>Jt.openNode(Re))}let _i={};function Tr(_e,Re){const Ye=Re&&Re[0];if(bt+=_e,Ye==null)return _t(),0;if(_i.type==="begin"&&Re.type==="end"&&_i.index===Re.index&&Ye===""){if(bt+=je.slice(Re.index,Re.index+1),!St){const it=new Error(`0 width match regex (${ye})`);throw it.languageName=ye,it.badRule=_i.rule,it}return 1}if(_i=Re,Re.type==="begin")return Za(Re);if(Re.type==="illegal"&&!Qe){const it=new Error('Illegal lexeme "'+Ye+'" for mode "'+(qe.scope||"<unnamed>")+'"');throw it.mode=qe,it}else if(Re.type==="end"){const it=Sn(Re);if(it!==nt)return it}if(Re.type==="illegal"&&Ye==="")return bt+=`
|
||||
`,1;if(Qs>1e5&&Qs>Re.index*3)throw new Error("potential infinite loop, way more iterations than matches");return bt+=Ye,Ye.length}const un=bn(ye);if(!un)throw ct(gt.replace("{}",ye)),new Error('Unknown language: "'+ye+'"');const ha=Kt(un);let Qa="",qe=ft||ha;const Dl={},Jt=new Se.__emitter(Se);fa();let bt="",Cr=0,Qi=0,Qs=0,Ar=!1;try{if(un.__emitTokens)un.__emitTokens(je,Jt);else{for(qe.matcher.considerAll();;){Qs++,Ar?Ar=!1:qe.matcher.considerAll(),qe.matcher.lastIndex=Qi;const _e=qe.matcher.exec(je);if(!_e)break;const Re=je.substring(Qi,_e.index),Ye=Tr(Re,_e);Qi=_e.index+Ye}Tr(je.substring(Qi))}return Jt.finalize(),Qa=Jt.toHTML(),{language:ye,value:Qa,relevance:Cr,illegal:!1,_emitter:Jt,_top:qe}}catch(_e){if(_e.message&&_e.message.includes("Illegal"))return{language:ye,value:cn(je),illegal:!0,relevance:0,_illegalBy:{message:_e.message,index:Qi,context:je.slice(Qi-100,Qi+100),mode:_e.mode,resultSoFar:Qa},_emitter:Jt};if(St)return{language:ye,value:cn(je),illegal:!1,relevance:0,errorRaised:_e,_emitter:Jt,_top:qe};throw _e}}function _r(ye){const je={value:cn(ye),illegal:!1,relevance:0,_top:Me,_emitter:new Se.__emitter(Se)};return je._emitter.addText(ye),je}function wr(ye,je){je=je||Se.languages||Object.keys(me);const Qe=_r(ye),ft=je.filter(bn).filter(yi).map(_t=>gi(_t,ye,!1));ft.unshift(Qe);const Ht=ft.sort((_t,On)=>{if(_t.relevance!==On.relevance)return On.relevance-_t.relevance;if(_t.language&&On.language){if(bn(_t.language).supersetOf===On.language)return 1;if(bn(On.language).supersetOf===_t.language)return-1}return 0}),[pn,Mn]=Ht,Nn=pn;return Nn.secondBest=Mn,Nn}function ai(ye,je,Qe){const ft=je&&Ee[je]||Qe;ye.classList.add("hljs"),ye.classList.add(`language-${ft}`)}function Ut(ye){let je=null;const Qe=Bt(ye);if(Ue(Qe))return;if(Zi("before:highlightElement",{el:ye,language:Qe}),ye.dataset.highlighted){console.log("Element previously highlighted. To highlight again, first unset `dataset.highlighted`.",ye);return}if(ye.children.length>0&&(Se.ignoreUnescapedHTML||(console.warn("One of your code blocks includes unescaped HTML. This is a potentially serious security risk."),console.warn("https://github.com/highlightjs/highlight.js/wiki/security"),console.warn("The element with unescaped HTML:"),console.warn(ye)),Se.throwUnescapedHTML))throw new jn("One of your code blocks includes unescaped HTML.",ye.innerHTML);je=ye;const ft=je.textContent,Ht=Qe?Mt(ft,{language:Qe,ignoreIllegals:!0}):wr(ft);ye.innerHTML=Ht.value,ye.dataset.highlighted="yes",ai(ye,Qe,Ht.language),ye.result={language:Ht.language,re:Ht.relevance,relevance:Ht.relevance},Ht.secondBest&&(ye.secondBest={language:Ht.secondBest.language,relevance:Ht.secondBest.relevance}),Zi("after:highlightElement",{el:ye,result:Ht,text:ft})}function mn(ye){Se=vr(Se,ye)}const bi=()=>{Nr(),ue("10.6.0","initHighlighting() deprecated. Use highlightAll() now.")};function Er(){Nr(),ue("10.6.0","initHighlightingOnLoad() deprecated. Use highlightAll() now.")}let ua=!1;function Nr(){function ye(){Nr()}if(document.readyState==="loading"){ua||window.addEventListener("DOMContentLoaded",ye,!1),ua=!0;return}document.querySelectorAll(Se.cssSelector).forEach(Ut)}function Ya(ye,je){let Qe=null;try{Qe=je(ie)}catch(ft){if(ct("Language definition for '{}' could not be registered.".replace("{}",ye)),St)ct(ft);else throw ft;Qe=Me}Qe.name||(Qe.name=ye),me[ye]=Qe,Qe.rawDefinition=je.bind(null,ie),Qe.aliases&&xi(Qe.aliases,{languageName:ye})}function Ki(ye){delete me[ye];for(const je of Object.keys(Ee))Ee[je]===ye&&delete Ee[je]}function da(){return Object.keys(me)}function bn(ye){return ye=(ye||"").toLowerCase(),me[ye]||me[Ee[ye]]}function xi(ye,{languageName:je}){typeof ye=="string"&&(ye=[ye]),ye.forEach(Qe=>{Ee[Qe.toLowerCase()]=je})}function yi(ye){const je=bn(ye);return je&&!je.disableAutodetect}function ji(ye){ye["before:highlightBlock"]&&!ye["before:highlightElement"]&&(ye["before:highlightElement"]=je=>{ye["before:highlightBlock"](Object.assign({block:je.el},je))}),ye["after:highlightBlock"]&&!ye["after:highlightElement"]&&(ye["after:highlightElement"]=je=>{ye["after:highlightBlock"](Object.assign({block:je.el},je))})}function Ft(ye){ji(ye),Pe.push(ye)}function Xa(ye){const je=Pe.indexOf(ye);je!==-1&&Pe.splice(je,1)}function Zi(ye,je){const Qe=ye;Pe.forEach(function(ft){ft[Qe]&&ft[Qe](je)})}function vi(ye){return ue("10.7.0","highlightBlock will be removed entirely in v12.0"),ue("10.7.0","Please use highlightElement now."),Ut(ye)}Object.assign(ie,{highlight:Mt,highlightAuto:wr,highlightAll:Nr,highlightElement:Ut,highlightBlock:vi,configure:mn,initHighlighting:bi,initHighlightingOnLoad:Er,registerLanguage:Ya,unregisterLanguage:Ki,listLanguages:da,getLanguage:bn,registerAliases:xi,autoDetection:yi,inherit:vr,addPlugin:Ft,removePlugin:Xa}),ie.debugMode=function(){St=!1},ie.safeMode=function(){St=!0},ie.versionString=ut,ie.regex={concat:N,lookahead:x,either:w,optional:_,anyNumberOfTimes:y};for(const ye in le)typeof le[ye]=="object"&&e(le[ye]);return Object.assign(ie,le),ie},hn=An({})
Line truncated
|
||||
`},_]}}return $h=s,$h}var qh,ov;function J4(){if(ov)return qh;ov=1;const e=m=>({IMPORTANT:{scope:"meta",begin:"!important"},BLOCK_COMMENT:m.C_BLOCK_COMMENT_MODE,HEXCOLOR:{scope:"number",begin:/#(([0-9a-fA-F]{3,4})|(([0-9a-fA-F]{2}){3,4}))\b/},FUNCTION_DISPATCH:{className:"built_in",begin:/[\w-]+(?=\()/},ATTRIBUTE_SELECTOR_MODE:{scope:"selector-attr",begin:/\[/,end:/\]/,illegal:"$",contains:[m.APOS_STRING_MODE,m.QUOTE_STRING_MODE]},CSS_NUMBER_MODE:{scope:"number",begin:m.NUMBER_RE+"(%|em|ex|ch|rem|vw|vh|vmin|vmax|cm|mm|in|pt|pc|px|deg|grad|rad|turn|s|ms|Hz|kHz|dpi|dpcm|dppx)?",relevance:0},CSS_VARIABLE:{className:"attr",begin:/--[A-Za-z_][A-Za-z0-9_-]*/}}),t=["a","abbr","address","article","aside","audio","b","blockquote","body","button","canvas","caption","cite","code","dd","del","details","dfn","div","dl","dt","em","fieldset","figcaption","figure","footer","form","h1","h2","h3","h4","h5","h6","header","hgroup","html","i","iframe","img","input","ins","kbd","label","legend","li","main","mark","menu","nav","object","ol","optgroup","option","p","picture","q","quote","samp","section","select","source","span","strong","summary","sup","table","tbody","td","textarea","tfoot","th","thead","time","tr","ul","var","video"],i=["defs","g","marker","mask","pattern","svg","switch","symbol","feBlend","feColorMatrix","feComponentTransfer","feComposite","feConvolveMatrix","feDiffuseLighting","feDisplacementMap","feFlood","feGaussianBlur","feImage","feMerge","feMorphology","feOffset","feSpecularLighting","feTile","feTurbulence","linearGradient","radialGradient","stop","circle","ellipse","image","line","path","polygon","polyline","rect","text","use","textPath","tspan","foreignObject","clipPath"],a=[...t,...i],s=["any-hover","any-pointer","aspect-ratio","color","color-gamut","color-index","device-aspect-ratio","device-height","device-width","display-mode","forced-colors","grid","height","hover","inverted-colors","monochrome","orientation","overflow-block","overflow-inline","pointer","prefers-color-scheme","prefers-contrast","prefers-reduced-motion","prefers-reduced-transparency","resolution","scan","scripting","update","width","min-width","max-width","min-height","max-height"].sort().reverse(),l=["active","any-link","blank","checked","current","default","defined","dir","disabled","drop","empty","enabled","first","first-child","first-of-type","fullscreen","future","focus","focus-visible","focus-within","has","host","host-context","hover","indeterminate","in-range","invalid","is","lang","last-child","last-of-type","left","link","local-link","not","nth-child","nth-col","nth-last-child","nth-last-col","nth-last-of-type","nth-of-type","only-child","only-of-type","optional","out-of-range","past","placeholder-shown","read-only","read-write","required","right","root","scope","target","target-within","user-invalid","valid","visited","where"].sort().reverse(),c=["after","backdrop","before","cue","cue-region","first-letter","first-line","grammar-error","marker","part","placeholder","selection","slotted","spelling-error"].sort().reverse(),d=["accent-color","align-content","align-items","align-self","alignment-baseline","all","anchor-name","animation","animation-composition","animation-delay","animation-direction","animation-duration","animation-fill-mode","animation-iteration-count","animation-name","animation-play-state","animation-range","animation-range-end","animation-range-start","animation-timeline","animation-timing-function","appearance","aspect-ratio","backdrop-filter","backface-visibility","background","background-attachment","background-blend-mode","background-clip","background-color","background-image","background-origin","background-position","background-position-x","background-position-y","background-repeat","background-size","baseline-shift","block-size","border","border-block","border-block-color","border-block-end","border-block-end-color","border-block-end-style","border-block-end-width","border-block-start","border-block-start-color","border-block-start-style","border-block-start-width","border-block-style","border-block-width","border-bottom","border-bottom-color","border-bottom-left-radius","border-bottom-right-radius","border-bottom-style","border-bottom-width","border-collapse","border-color","border-end-end-radius","border-end-start-radius","border-image","border-image-outset","border-image-repeat","border-image-slice","border-image-source","border-image-width","border-inline","border-inline-color","border-inline-end","border-inline-end-color","border-inline-end-style","border-inline-end-width","border-inline-start","border-inline-start-color","border-inline-start-style","border-inline-start-width","border-inline-style","border-inline-width","border-left","border-left-color","border-left-style","border-left-width","border-radius","border-right","border-right-color","border-right-style","border-right-width","border-spacing","border-start-end-radius","border-start-start-radius","border-style","border-top","border-top
Line truncated
|
||||
]`,N={scope:"string",variants:[p,m,x,y]},S={scope:"number",variants:[{begin:"\\b0[bB][01]+(?:_[01]+)*\\b"},{begin:"\\b0[oO][0-7]+(?:_[0-7]+)*\\b"},{begin:"\\b0[xX][\\da-fA-F]+(?:_[\\da-fA-F]+)*\\b"},{begin:"(?:\\b\\d+(?:_\\d+)*(\\.(?:\\d+(?:_\\d+)*))?|\\B\\.\\d+)(?:[eE][+-]?\\d+)?"}],relevance:0},w=["false","null","true"],k=["__CLASS__","__DIR__","__FILE__","__FUNCTION__","__COMPILER_HALT_OFFSET__","__LINE__","__METHOD__","__NAMESPACE__","__TRAIT__","die","echo","exit","include","include_once","print","require","require_once","array","abstract","and","as","binary","bool","boolean","break","callable","case","catch","class","clone","const","continue","declare","default","do","double","else","elseif","empty","enddeclare","endfor","endforeach","endif","endswitch","endwhile","enum","eval","extends","final","finally","float","for","foreach","from","global","goto","if","implements","instanceof","insteadof","int","integer","interface","isset","iterable","list","match|0","mixed","new","never","object","or","private","protected","public","readonly","real","return","string","switch","throw","trait","try","unset","use","var","void","while","xor","yield"],E=["Error|0","AppendIterator","ArgumentCountError","ArithmeticError","ArrayIterator","ArrayObject","AssertionError","BadFunctionCallException","BadMethodCallException","CachingIterator","CallbackFilterIterator","CompileError","Countable","DirectoryIterator","DivisionByZeroError","DomainException","EmptyIterator","ErrorException","Exception","FilesystemIterator","FilterIterator","GlobIterator","InfiniteIterator","InvalidArgumentException","IteratorIterator","LengthException","LimitIterator","LogicException","MultipleIterator","NoRewindIterator","OutOfBoundsException","OutOfRangeException","OuterIterator","OverflowException","ParentIterator","ParseError","RangeException","RecursiveArrayIterator","RecursiveCachingIterator","RecursiveCallbackFilterIterator","RecursiveDirectoryIterator","RecursiveFilterIterator","RecursiveIterator","RecursiveIteratorIterator","RecursiveRegexIterator","RecursiveTreeIterator","RegexIterator","RuntimeException","SeekableIterator","SplDoublyLinkedList","SplFileInfo","SplFileObject","SplFixedArray","SplHeap","SplMaxHeap","SplMinHeap","SplObjectStorage","SplObserver","SplPriorityQueue","SplQueue","SplStack","SplSubject","SplTempFileObject","TypeError","UnderflowException","UnexpectedValueException","UnhandledMatchError","ArrayAccess","BackedEnum","Closure","Fiber","Generator","Iterator","IteratorAggregate","Serializable","Stringable","Throwable","Traversable","UnitEnum","WeakReference","WeakMap","Directory","__PHP_Incomplete_Class","parent","php_user_filter","self","static","stdClass"],U={keyword:k,literal:(q=>{const M=[];return q.forEach(H=>{M.push(H),H.toLowerCase()===H?M.push(H.toUpperCase()):M.push(H.toLowerCase())}),M})(w),built_in:E},R=q=>q.map(M=>M.replace(/\|\d+$/,"")),B={variants:[{match:[/new/,i.concat(_,"+"),i.concat("(?!",R(E).join("\\b|"),"\\b)"),l],scope:{1:"keyword",4:"title.class"}}]},I=i.concat(s,"\\b(?!\\()"),Z={variants:[{match:[i.concat(/::/,i.lookahead(/(?!class\b)/)),I],scope:{2:"variable.constant"}},{match:[/::/,/class/],scope:{2:"variable.language"}},{match:[l,i.concat(/::/,i.lookahead(/(?!class\b)/)),I],scope:{1:"title.class",3:"variable.constant"}},{match:[l,i.concat("::",i.lookahead(/(?!class\b)/))],scope:{1:"title.class"}},{match:[l,/::/,/class/],scope:{1:"title.class",3:"variable.language"}}]},L={scope:"attr",match:i.concat(s,i.lookahead(":"),i.lookahead(/(?!::)/))},z={relevance:0,begin:/\(/,end:/\)/,keywords:U,contains:[L,d,Z,t.C_BLOCK_COMMENT_MODE,N,S,B]},K={relevance:0,match:[/\b/,i.concat("(?!fn\\b|function\\b|",R(k).join("\\b|"),"|",R(E).join("\\b|"),"\\b)"),s,i.concat(_,"*"),i.lookahead(/(?=\()/)],scope:{3:"title.function.invoke"},contains:[z]};z.contains.push(K);const G=[L,Z,t.C_BLOCK_COMMENT_MODE,N,S,B],C={begin:i.concat(/#\[\s*\\?/,i.either(l,c)),beginScope:"meta",end:/]/,endScope:"meta",keywords:{literal:w,keyword:["new","array"]},contains:[{begin:/\[/,end:/]/,keywords:{literal:w,keyword:["new","array"]},contains:["self",...G]},...G,{scope:"meta",variants:[{match:l},{match:c}]}]};return{case_insensitive:!1,keywords:U,contains:[C,t.HASH_COMMENT_MODE,t.COMMENT("//","$"),t.COMMENT("/\\*","\\*/",{contains:[{scope:"doctag",match:"@[A-Za-z]+"}]}),{match:/__halt_compiler\(\);/,keywords:"__halt_compiler",starts:{scope:"comment",end:t.MATCH_NOTHING_RE,contains:[{match:/\?>/,scope:"meta",endsParent:!0}]}},f,{scope:"variable.language",match:/\$this\b/},d,K,Z,{match:[/const/,/\s/,s],scope:{1:"keyword",3:"variable.constant"}},B,{scope:"function",relevance:0,beginKeywords:"fn function",end:/[;{]/,excludeEnd:!0,illegal:"[$%\\[]",contains:[{beginKeywords:"use"},t.UNDERSCORE_TITLE_MODE,{begin:"=>",endsParent:!0},{scope:"params",begin:"\\(",end:"\\)",excludeBegin:!0,excludeEnd:!0,keywords:U,contains:["self",C,d,Z,t.C_BLOCK_COMMENT_MODE,N,S]}]},{scope:"class",variants:[{beginKeywords:"enum",illegal:/[($"]/},{beginKeywords:"cl
Line truncated
|
||||
`)||c))return g.jsx("code",{className:`${e||""} bg-white/8 px-1.5 py-0.5 rounded text-[13px]`,children:t});const f=((w=i==null?void 0:i.data)==null?void 0:w.meta)||((k=i==null?void 0:i.properties)==null?void 0:k.metastring)||"",h=/title=["']?([^"'\s}]+)["']?/.exec(f),m=/startLineNumber=(\d+)/.exec(f),p=(h==null?void 0:h[1])||null,x=m?parseInt(m[1],10):1,y=p?m?`${p}:${x}`:p:null;let _;if(c)try{_=Fn.highlight(l,{language:c[1],ignoreIllegals:!0}).value}catch{_=Fn.highlightAuto(l).value}else _=Fn.highlightAuto(l).value;const N=_.split(`
|
||||
`),S=()=>{fp(l),s(!0),setTimeout(()=>s(!1),2e3)};return g.jsxs("div",{className:"group/code relative rounded-md border border-[#2a2a2a] my-4 text-[#ddd] overflow-hidden",children:[y?g.jsxs("div",{className:"flex items-stretch",children:[g.jsxs("span",{className:"relative flex items-center text-[13px] text-[#999] font-mono px-4 py-2 border-r border-[#2a2a2a]",children:[y,g.jsx("span",{className:"absolute top-0 inset-x-0 h-0.5 bg-white/60 rounded-full"})]}),g.jsx("div",{className:"flex-1 border-b border-[#2a2a2a]"}),g.jsx("button",{onClick:S,className:"px-3 py-2 text-[#555] hover:text-white transition-colors border-b border-[#2a2a2a]","aria-label":"Copy code",children:a?g.jsx(Us,{className:"w-3.5 h-3.5 text-emerald-400"}):g.jsx(sl,{className:"w-3.5 h-3.5"})})]}):g.jsx("button",{onClick:S,className:"absolute top-2 right-2 z-10 p-1 rounded text-[#444] hover:text-white opacity-0 group-hover/code:opacity-100 transition-opacity","aria-label":"Copy code",children:a?g.jsx(Us,{className:"w-3.5 h-3.5 text-emerald-400"}):g.jsx(sl,{className:"w-3.5 h-3.5"})}),g.jsx("div",{className:"overflow-auto max-h-[400px]",children:g.jsx("table",{className:"w-full border-collapse font-mono text-[12px] leading-[22px] [font-variant-ligatures:none]",children:g.jsx("tbody",{children:N.map((E,O)=>g.jsxs("tr",{children:[g.jsx("td",{className:"select-none w-[1px] whitespace-nowrap px-4 text-right text-[#555] align-top text-[12px] leading-[22px] border-r border-[#2a2a2a]",children:x+O}),g.jsx("td",{className:"pl-4 pr-4 whitespace-pre",dangerouslySetInnerHTML:{__html:E||`
|
||||
`}})]},O))})})})]})}function jp(){return e=>{const t=i=>{var a;if(i.type==="element"&&i.tagName==="pre"&&i.children){const s=i.children.find(l=>l.type==="element"&&l.tagName==="code");(a=s==null?void 0:s.data)!=null&&a.meta&&(s.properties=s.properties||{},s.properties.metastring=s.data.meta)}i.children&&i.children.forEach(s=>t(s))};t(e)}}const zp={code:Yw,pre:({children:e})=>g.jsx(g.Fragment,{children:e})};function ia({title:e,content:t,action:i}){return g.jsxs("section",{children:[(e||i)&&g.jsxs("div",{className:"flex items-center justify-between gap-3 mb-3",children:[e?g.jsx("h2",{className:"text-xl font-semibold text-white",children:e}):g.jsx("span",{}),i]}),g.jsx("div",{className:"prose-markdown",children:g.jsx(Mp,{remarkPlugins:[Lp],rehypePlugins:[jp],components:zp,children:t})})]})}class kD{diff(t,i,a={}){let s;typeof a=="function"?(s=a,a={}):"callback"in a&&(s=a.callback);const l=this.castInput(t,a),c=this.castInput(i,a),d=this.removeEmpty(this.tokenize(l,a)),f=this.removeEmpty(this.tokenize(c,a));return this.diffWithOptionsObj(d,f,a,s)}diffWithOptionsObj(t,i,a,s){var l;const c=k=>{if(k=this.postProcess(k,a),s){setTimeout(function(){s(k)},0);return}else return k},d=i.length,f=t.length;let h=1,m=d+f;a.maxEditLength!=null&&(m=Math.min(m,a.maxEditLength));const p=(l=a.timeout)!==null&&l!==void 0?l:1/0,x=Date.now()+p,y=[{oldPos:-1,lastComponent:void 0}];let _=this.extractCommon(y[0],i,t,0,a);if(y[0].oldPos+1>=f&&_+1>=d)return c(this.buildValues(y[0].lastComponent,i,t));let N=-1/0,S=1/0;const w=()=>{for(let k=Math.max(N,-h);k<=Math.min(S,h);k+=2){let E;const O=y[k-1],U=y[k+1];O&&(y[k-1]=void 0);let R=!1;if(U){const I=U.oldPos-k;R=U&&0<=I&&I<d}const B=O&&O.oldPos+1<f;if(!R&&!B){y[k]=void 0;continue}if(!B||R&&O.oldPos<U.oldPos?E=this.addToPath(U,!0,!1,0,a):E=this.addToPath(O,!1,!0,1,a),_=this.extractCommon(E,i,t,k,a),E.oldPos+1>=f&&_+1>=d)return c(this.buildValues(E.lastComponent,i,t))||!0;y[k]=E,E.oldPos+1>=f&&(S=Math.min(S,k-1)),_+1>=d&&(N=Math.max(N,k+1))}h++};if(s)(function k(){setTimeout(function(){if(h>m||Date.now()>x)return s(void 0);w()||k()},0)})();else for(;h<=m&&Date.now()<=x;){const k=w();if(k)return k}}addToPath(t,i,a,s,l){const c=t.lastComponent;return c&&!l.oneChangePerToken&&c.added===i&&c.removed===a?{oldPos:t.oldPos+s,lastComponent:{count:c.count+1,added:i,removed:a,previousComponent:c.previousComponent}}:{oldPos:t.oldPos+s,lastComponent:{count:1,added:i,removed:a,previousComponent:c}}}extractCommon(t,i,a,s,l){const c=i.length,d=a.length;let f=t.oldPos,h=f-s,m=0;for(;h+1<c&&f+1<d&&this.equals(a[f+1],i[h+1],l);)h++,f++,m++,l.oneChangePerToken&&(t.lastComponent={count:1,previousComponent:t.lastComponent,added:!1,removed:!1});return m&&!l.oneChangePerToken&&(t.lastComponent={count:m,previousComponent:t.lastComponent,added:!1,removed:!1}),t.oldPos=f,h}equals(t,i,a){return a.comparator?a.comparator(t,i):t===i||!!a.ignoreCase&&t.toLowerCase()===i.toLowerCase()}removeEmpty(t){const i=[];for(let a=0;a<t.length;a++)t[a]&&i.push(t[a]);return i}castInput(t,i){return t}tokenize(t,i){return Array.from(t)}join(t){return t.join("")}postProcess(t,i){return t}get useLongestToken(){return!1}buildValues(t,i,a){const s=[];let l;for(;t;)s.push(t),l=t.previousComponent,delete t.previousComponent,t=l;s.reverse();const c=s.length;let d=0,f=0,h=0;for(;d<c;d++){const m=s[d];if(m.removed)m.value=this.join(a.slice(h,h+m.count)),h+=m.count;else{if(!m.added&&this.useLongestToken){let p=i.slice(f,f+m.count);p=p.map(function(x,y){const _=a[h+y];return _.length>x.length?_:x}),m.value=this.join(p)}else m.value=this.join(i.slice(f,f+m.count));f+=m.count,m.added||(h+=m.count)}}return s}}class TD extends kD{constructor(){super(...arguments),this.tokenize=MD}equals(t,i,a){return a.ignoreWhitespace?((!a.newlineIsToken||!t.includes(`
|
||||
`))&&(t=t.trim()),(!a.newlineIsToken||!i.includes(`
|
||||
`))&&(i=i.trim())):a.ignoreNewlineAtEof&&!a.newlineIsToken&&(t.endsWith(`
|
||||
`)&&(t=t.slice(0,-1)),i.endsWith(`
|
||||
`)&&(i=i.slice(0,-1))),super.equals(t,i,a)}}const CD=new TD;function AD(e,t,i){return CD.diff(e,t,i)}function MD(e,t){t.stripTrailingCr&&(e=e.replace(/\r\n/g,`
|
||||
`));const i=[],a=e.split(/(\n|\r\n)/);a[a.length-1]||a.pop();for(let s=0;s<a.length;s++){const l=a[s];s%2&&!t.newlineIsToken?i[i.length-1]+=l:i.push(l)}return i}function OD(e,t){try{return Fn.highlight(e,{language:t,ignoreIllegals:!0}).value}catch{return Fn.highlightAuto(e).value}}function RD({file:e,startLine:t,endLine:i,before:a,after:s,onCopy:l}){const[c,d]=te.useState(!1),f=i&&i!==t?`${t}-${i}`:`${t}`,h=pC(e)||"text",m=AD(a,s);let p=t,x=t;const y=m.flatMap(N=>N.value.replace(/\n$/,"").split(`
|
||||
`).map(S=>{const w=S===""?`
|
||||
`:h!=="text"?OD(S,h):S.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">");let k="",E="";return N.removed?k=String(p++):(N.added||(k=String(p++)),E=String(x++)),{highlighted:w,added:!!N.added,removed:!!N.removed,leftNo:k,rightNo:E}})),_=()=>{fp(s),d(!0),setTimeout(()=>d(!1),2e3),l==null||l()};return g.jsxs("div",{className:"rounded-md border border-[#2a2a2a] overflow-hidden",children:[g.jsxs("div",{className:"flex items-stretch",children:[g.jsxs("span",{className:"relative flex items-center text-[13px] text-[#999] font-mono px-4 py-2 border-r border-[#2a2a2a] break-all",children:[e,":",f,g.jsx("span",{className:"absolute top-0 inset-x-0 h-0.5 bg-white/60 rounded-full"})]}),g.jsx("div",{className:"flex-1 border-b border-[#2a2a2a]"}),g.jsx("button",{onClick:_,className:"px-3 py-2 text-[#555] hover:text-white transition-colors flex-shrink-0 border-b border-[#2a2a2a]","aria-label":"Copy fixed code",children:c?g.jsx(Us,{className:"w-3.5 h-3.5 text-emerald-400"}):g.jsx(sl,{className:"w-3.5 h-3.5"})})]}),g.jsx("div",{className:"overflow-auto max-h-[400px]",children:g.jsx("table",{className:"w-full border-collapse font-mono text-[12px] leading-[22px] [font-variant-ligatures:none]",children:g.jsx("tbody",{children:y.map((N,S)=>g.jsxs("tr",{className:N.added?"bg-blue-500/[0.12]":N.removed?"bg-red-500/[0.12]":"",children:[g.jsx("td",{className:"select-none w-[1px] whitespace-nowrap pl-4 pr-1.5 text-right text-[#555] align-top text-[12px] leading-[22px]",children:N.leftNo}),g.jsx("td",{className:"select-none w-[1px] whitespace-nowrap pl-1.5 pr-4 text-right text-[#555] align-top text-[12px] leading-[22px] border-r border-[#2a2a2a]",children:N.rightNo}),g.jsx("td",{className:"pl-4 pr-4 whitespace-pre",dangerouslySetInnerHTML:{__html:N.highlighted}})]},S))})})})]})}function DD({description:e,scriptCode:t,onCopy:i}){const[a,s]=te.useState(!1);if(!e&&!t)return null;const l=()=>{t&&(fp(t),s(!0),setTimeout(()=>s(!1),2e3),i==null||i())};return g.jsxs("section",{children:[g.jsx("h2",{className:"text-xl font-semibold text-white mb-3",children:"Proof of Concept"}),g.jsxs("div",{className:"space-y-4",children:[e&&g.jsx("div",{className:"prose-markdown",children:g.jsx(Mp,{remarkPlugins:[Lp],rehypePlugins:[jp],components:zp,children:e})}),t&&g.jsxs("div",{className:"group/poc relative rounded-md border border-[#2a2a2a] overflow-hidden",children:[g.jsxs("div",{className:"flex items-stretch",children:[g.jsxs("span",{className:"relative flex items-center text-[13px] text-[#999] font-mono px-4 py-2 border-r border-[#2a2a2a]",children:["PoC Script",g.jsx("span",{className:"absolute top-0 inset-x-0 h-0.5 bg-white/60 rounded-full"})]}),g.jsx("div",{className:"flex-1 border-b border-[#2a2a2a]"}),g.jsx("button",{onClick:l,className:"px-3 py-2 text-[#555] hover:text-white transition-colors flex-shrink-0 border-b border-[#2a2a2a]","aria-label":"Copy PoC code",children:a?g.jsx(Us,{className:"w-3.5 h-3.5 text-emerald-400"}):g.jsx(sl,{className:"w-3.5 h-3.5"})})]}),g.jsx("div",{className:"overflow-auto max-h-[400px] px-4 py-3",children:g.jsx("pre",{className:"font-mono text-[12px] leading-[22px] whitespace-pre [font-variant-ligatures:none]",children:g.jsx("code",{dangerouslySetInnerHTML:{__html:Fn.highlight(t,{language:"python"}).value}})})})]})]})]})}function Xw(e){const t=e.match(/(?:https?:\/\/)?(?:www\.)?github\.com\/([^\s/]+\/[^\s/]+)/);if(t){const s=t[1].replace(/\.git$/,"");return{display:s,href:`https://github.com/${s}`,provider:"github"}}const i=e.match(/(?:https?:\/\/)?(?:www\.)?gitlab\.com\/([^\s/]+\/[^\s/]+)/);if(i){const s=i[1].replace(/\.git$/,"");return{display:s,href:`https://gitlab.com/${s}`,provider:"gitlab"}}const a=e.match(/(?:https?:\/\/)?(?:www\.)?bitbucket\.org\/([^\s/]+\/[^\s/]+)/);if(a){const s=a[1].replace(/\.git$/,"");return{display:s,href:`https://bitbucket.org/${s}`,provider:"bitbucket"}}return/^https?:\/\//i.test(e)?{display:e.replace(/^https?:\/\/(www\.)?/,""),href:e,provider:null}:/^[a-zA-Z0-9][\w.-]*\.[a-zA-Z]{2,}/.test(e)?{display:e,href:`https://${e}`,provider:null}:{display:e,href:null,provider:null}}function cl(e,t){return e?Xw(e).display.replace(/\/$/,""):t||"Untitled scan"}function LD({className:e}){return g.jsx("svg",{viewBox:"0 0 24 24",fill:"currentColor",className:e,"aria-hidden":"true",children:g.jsx("path",{d:"M2.65 3a.72.72 0 0 0-.72.83l2.86 17.39a.98.98 0 0 0 .96.82h13.72a.72.72 0 0 0 .72-.6l2.86-17.4A.72.72 0 0 0 22.3 3H2.65Zm12.1 12.53H9.3L8.06 8.9h7.8l-1.11 6.63Z"})})}function jD({provider:e,className:t}){const i=t??"w-4 h-4";return e==="gitlab"?g.jsx(cT,{className:`${i} text-orange-400`}):e==="bitbucket"?g.jsx(LD,{className:`${i} text-blue-400`}):g.jsx(oT,{className:`${i} text-white`})}const zD={attack_vector:{N:"Remotely exploitable",A:"Adjacent network",L:"Local access required",P:"Physical access required"},attack_complexity:{L:"Easy to exploit",H:"Requires specific conditions"},privileges_required:{N:"No authentication needed",L:"Low privileges needed",H:"High privileges needed"}
Line truncated
|
||||
* @license React
|
||||
* use-sync-external-store-shim.production.js
|
||||
*
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/var l1;function _6(){if(l1)return ym;l1=1;var e=vl();function t(p,x){return p===x&&(p!==0||1/p===1/x)||p!==p&&x!==x}var i=typeof Object.is=="function"?Object.is:t,a=e.useState,s=e.useEffect,l=e.useLayoutEffect,c=e.useDebugValue;function d(p,x){var y=x(),_=a({inst:{value:y,getSnapshot:x}}),N=_[0].inst,S=_[1];return l(function(){N.value=y,N.getSnapshot=x,f(N)&&S({inst:N})},[p,y,x]),s(function(){return f(N)&&S({inst:N}),p(function(){f(N)&&S({inst:N})})},[p]),c(y),y}function f(p){var x=p.getSnapshot;p=p.value;try{var y=x();return!i(p,y)}catch{return!0}}function h(p,x){return x()}var m=typeof window>"u"||typeof window.document>"u"||typeof window.document.createElement>"u"?h:d;return ym.useSyncExternalStore=e.useSyncExternalStore!==void 0?e.useSyncExternalStore:m,ym}var c1;function w6(){return c1||(c1=1,xm.exports=_6()),xm.exports}/**
|
||||
* @license React
|
||||
* use-sync-external-store-shim/with-selector.production.js
|
||||
*
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/var u1;function E6(){if(u1)return bm;u1=1;var e=vl(),t=w6();function i(h,m){return h===m&&(h!==0||1/h===1/m)||h!==h&&m!==m}var a=typeof Object.is=="function"?Object.is:i,s=t.useSyncExternalStore,l=e.useRef,c=e.useEffect,d=e.useMemo,f=e.useDebugValue;return bm.useSyncExternalStoreWithSelector=function(h,m,p,x,y){var _=l(null);if(_.current===null){var N={hasValue:!1,value:null};_.current=N}else N=_.current;_=d(function(){function w(R){if(!k){if(k=!0,E=R,R=x(R),y!==void 0&&N.hasValue){var B=N.value;if(y(B,R))return O=B}return O=R}if(B=O,a(E,R))return B;var I=x(R);return y!==void 0&&y(B,I)?(E=R,B):(E=R,O=I)}var k=!1,E,O,U=p===void 0?null:p;return[function(){return w(m())},U===null?void 0:function(){return w(U())}]},[m,p,x,y]);var S=s(h,_[0],_[1]);return c(function(){N.hasValue=!0,N.value=S},[S]),f(S),S},bm}var d1;function N6(){return d1||(d1=1,gm.exports=E6()),gm.exports}var S6=N6();const k6=yl(S6),T6={},f1=e=>{let t;const i=new Set,a=(m,p)=>{const x=typeof m=="function"?m(t):m;if(!Object.is(x,t)){const y=t;t=p??(typeof x!="object"||x===null)?x:Object.assign({},t,x),i.forEach(_=>_(t,y))}},s=()=>t,f={setState:a,getState:s,getInitialState:()=>h,subscribe:m=>(i.add(m),()=>i.delete(m)),destroy:()=>{(T6?"production":void 0)!=="production"&&console.warn("[DEPRECATED] The `destroy` method will be unsupported in a future version. Instead use unsubscribe function returned by subscribe. Everything will be garbage-collected if store is garbage-collected."),i.clear()}},h=t=e(a,s,f);return f},C6=e=>e?f1(e):f1,{useDebugValue:A6}=i2,{useSyncExternalStoreWithSelector:M6}=k6,O6=e=>e;function tN(e,t=O6,i){const a=M6(e.subscribe,e.getState,e.getServerState||e.getInitialState,t,i);return A6(a),a}const h1=(e,t)=>{const i=C6(e),a=(s,l=t)=>tN(i,s,l);return Object.assign(a,i),a},R6=(e,t)=>e?h1(e,t):h1;function qt(e,t){if(Object.is(e,t))return!0;if(typeof e!="object"||e===null||typeof t!="object"||t===null)return!1;if(e instanceof Map&&t instanceof Map){if(e.size!==t.size)return!1;for(const[a,s]of e)if(!Object.is(s,t.get(a)))return!1;return!0}if(e instanceof Set&&t instanceof Set){if(e.size!==t.size)return!1;for(const a of e)if(!t.has(a))return!1;return!0}const i=Object.keys(e);if(i.length!==Object.keys(t).length)return!1;for(const a of i)if(!Object.prototype.hasOwnProperty.call(t,a)||!Object.is(e[a],t[a]))return!1;return!0}u_();const Xu=te.createContext(null),D6=Xu.Provider,nN=Ri.error001("react");function dt(e,t){const i=te.useContext(Xu);if(i===null)throw new Error(nN);return tN(i,e,t)}function jt(){const e=te.useContext(Xu);if(e===null)throw new Error(nN);return te.useMemo(()=>({getState:e.getState,setState:e.setState,subscribe:e.subscribe}),[e])}const m1={display:"none"},L6={position:"absolute",width:1,height:1,margin:-1,border:0,padding:0,overflow:"hidden",clip:"rect(0px, 0px, 0px, 0px)",clipPath:"inset(100%)"},iN="react-flow__node-desc",rN="react-flow__edge-desc",j6="react-flow__aria-live",z6=e=>e.ariaLiveMessage,I6=e=>e.ariaLabelConfig;function B6({rfId:e}){const t=dt(z6);return g.jsx("div",{id:`${j6}-${e}`,"aria-live":"assertive","aria-atomic":"true",style:L6,children:t})}function U6({rfId:e,disableKeyboardA11y:t}){const i=dt(I6);return g.jsxs(g.Fragment,{children:[g.jsx("div",{id:`${iN}-${e}`,style:m1,children:t?i["node.a11yDescription.default"]:i["node.a11yDescription.keyboardDisabled"]}),g.jsx("div",{id:`${rN}-${e}`,style:m1,children:i["edge.a11yDescription.default"]}),!t&&g.jsx(B6,{rfId:e})]})}const Ku=te.forwardRef(({position:e="top-left",children:t,className:i,style:a,...s},l)=>{const c=`${e}`.split("-");return g.jsx("div",{className:on(["react-flow__panel",i,...c]),style:a,ref:l,...s,children:t})});Ku.displayName="Panel";const p1="https://reactflow.dev?utm_source=attribution";function H6({proOptions:e,position:t="bottom-right"}){return e!=null&&e.hideAttribution?null:g.jsx(Ku,{position:t,className:"react-flow__attribution","data-message":`Please only hide this attribution when you are subscribed to React Flow Pro: ${p1}`,children:g.jsx("a",{href:p1,target:"_blank",rel:"noopener noreferrer","aria-label":"React Flow attribution",children:"React Flow"})})}const $6=e=>{const t=[],i=[];for(const[,a]of e.nodeLookup)a.selected&&t.push(a.internals.userNode);for(const[,a]of e.edgeLookup)a.selected&&i.push(a);return{selectedNodes:t,selectedEdges:i}},iu=e=>e.id;function q6(e,t){return qt(e.selectedNodes.map(iu),t.selectedNodes.map(iu))&&qt(e.selectedEdges.map(iu),t.selectedEdges.map(iu))}function P6({onSelectionChange:e}){const t=jt(),{selectedNodes:i,selectedEdges:a}=dt($6,q6);return te.useEffect(()=>{const s={nodes:i,edges:a};e==null||e(s),t.getState().onSelectionChangeHandlers.forEach(l=>l(s))},[i,a,e]),null}const F6=e=>!!e.onSelectionChangeHandlers;function G6({onSelectionChange:e}){const t=dt(F6);return e||t?g.jsx(P6,{onSelectionChange:e}):null}const aN=[0,0],V6={x:0,y:0,zoom:1},Y6=["nodes","edges","defaultNodes","defaultEdges","onConnect","onConnectStart","onConnectEnd","onClickConnectStart","onClickConnectEnd","nodesDraLine truncated
|
||||
`).replace(`
|
||||
|
||||
`,`
|
||||
+`).split(`
|
||||
`)),m=h.reduce((p,x)=>p.concat(...x),[]);return[h,m]}return[[],[]]},[e]);return te.useEffect(()=>{const f=(t==null?void 0:t.target)??y1,h=(t==null?void 0:t.actInsideInputWithModifier)??!0;if(e!==null){const m=y=>{var S,w;if(s.current=y.ctrlKey||y.metaKey||y.shiftKey||y.altKey,(!s.current||s.current&&!h)&&BE(y))return!1;const N=_1(y.code,d);if(l.current.add(y[N]),v1(c,l.current,!1)){const k=((w=(S=y.composedPath)==null?void 0:S.call(y))==null?void 0:w[0])||y.target,E=(k==null?void 0:k.nodeName)==="BUTTON"||(k==null?void 0:k.nodeName)==="A";t.preventDefault!==!1&&(s.current||!E)&&y.preventDefault(),a(!0)}},p=y=>{const _=_1(y.code,d);v1(c,l.current,!0)?(a(!1),l.current.clear()):l.current.delete(y[_]),y.key==="Meta"&&l.current.clear(),s.current=!1},x=()=>{l.current.clear(),a(!1)};return f==null||f.addEventListener("keydown",m),f==null||f.addEventListener("keyup",p),window.addEventListener("blur",x),window.addEventListener("contextmenu",x),()=>{f==null||f.removeEventListener("keydown",m),f==null||f.removeEventListener("keyup",p),window.removeEventListener("blur",x),window.removeEventListener("contextmenu",x)}}},[e,a]),i}function v1(e,t,i){return e.filter(a=>i||a.length===t.size).some(a=>a.every(s=>t.has(s)))}function _1(e,t){return t.includes(e)?"code":"key"}const Q6=()=>{const e=jt();return te.useMemo(()=>({zoomIn:async t=>{const{panZoom:i}=e.getState();return i?i.scaleBy(1.2,t):!1},zoomOut:async t=>{const{panZoom:i}=e.getState();return i?i.scaleBy(1/1.2,t):!1},zoomTo:async(t,i)=>{const{panZoom:a}=e.getState();return a?a.scaleTo(t,i):!1},getZoom:()=>e.getState().transform[2],setViewport:async(t,i)=>{const{transform:[a,s,l],panZoom:c}=e.getState();return c?(await c.setViewport({x:t.x??a,y:t.y??s,zoom:t.zoom??l},i),!0):!1},getViewport:()=>{const[t,i,a]=e.getState().transform;return{x:t,y:i,zoom:a}},setCenter:async(t,i,a)=>e.getState().setCenter(t,i,a),fitBounds:async(t,i)=>{const{width:a,height:s,minZoom:l,maxZoom:c,panZoom:d}=e.getState(),f=Yp(t,a,s,l,c,(i==null?void 0:i.padding)??.1);return d?(await d.setViewport(f,{duration:i==null?void 0:i.duration,ease:i==null?void 0:i.ease,interpolate:i==null?void 0:i.interpolate}),!0):!1},screenToFlowPosition:(t,i={})=>{const{transform:a,snapGrid:s,snapToGrid:l,domNode:c}=e.getState();if(!c)return t;const{x:d,y:f}=c.getBoundingClientRect(),h={x:t.x-d,y:t.y-f},m=i.snapGrid??s,p=i.snapToGrid??l;return Ml(h,a,p,m)},flowToScreenPosition:t=>{const{transform:i,domNode:a}=e.getState();if(!a)return t;const{x:s,y:l}=a.getBoundingClientRect(),c=Vs(t,i);return{x:c.x+s,y:c.y+l}}}),[])};function sN(e,t){const i=[],a=new Map,s=[];for(const l of e)if(l.type==="add"){s.push(l);continue}else if(l.type==="remove"||l.type==="replace")a.set(l.id,[l]);else{const c=a.get(l.id);c?c.push(l):a.set(l.id,[l])}for(const l of t){const c=a.get(l.id);if(!c){i.push(l);continue}if(c[0].type==="remove")continue;if(c[0].type==="replace"){i.push({...c[0].item});continue}const d={...l};for(const f of c)W6(f,d);i.push(d)}return s.length&&s.forEach(l=>{l.index!==void 0?i.splice(l.index,0,{...l.item}):i.push({...l.item})}),i}function W6(e,t){switch(e.type){case"select":{t.selected=e.selected;break}case"position":{typeof e.position<"u"&&(t.position=e.position),typeof e.dragging<"u"&&(t.dragging=e.dragging);break}case"dimensions":{typeof e.dimensions<"u"&&(t.measured={...e.dimensions},e.setAttributes&&((e.setAttributes===!0||e.setAttributes==="width")&&(t.width=e.dimensions.width),(e.setAttributes===!0||e.setAttributes==="height")&&(t.height=e.dimensions.height))),typeof e.resizing=="boolean"&&(t.resizing=e.resizing);break}}}function oN(e,t){return sN(e,t)}function lN(e,t){return sN(e,t)}function Da(e,t){return{id:e,type:"select",selected:t}}function Ds(e,t=new Set,i=!1){const a=[];for(const[s,l]of e){const c=t.has(s);!(l.selected===void 0&&!c)&&l.selected!==c&&(i&&(l.selected=c),a.push(Da(l.id,c)))}return a}function w1({items:e=[],lookup:t}){var s;const i=[],a=new Map(e.map(l=>[l.id,l]));for(const[l,c]of e.entries()){const d=t.get(c.id),f=((s=d==null?void 0:d.internals)==null?void 0:s.userNode)??d;f!==void 0&&f!==c&&i.push({id:c.id,item:c,type:"replace"}),f===void 0&&i.push({item:c,type:"add",index:l})}for(const[l]of t)a.get(l)===void 0&&i.push({id:l,type:"remove"});return i}function E1(e){return{id:e.id,type:"remove"}}const J6=LE();function e8(e,t,i={}){return UI(e,t,{...i,onError:i.onError??J6})}const N1=e=>NI(e),t8=e=>AE(e);function cN(e){return te.forwardRef(e)}const uN=typeof window<"u"?te.useLayoutEffect:te.useEffect;function S1(e){const[t,i]=te.useState(BigInt(0)),[a]=te.useState(()=>n8(()=>i(s=>s+BigInt(1))));return uN(()=>{const s=a.get();s.length&&(e(s),a.reset())},[t]),a}function n8(e){let t=[];return{get:()=>t,reset:()=>{t=[]},push:i=>{t.push(i),e()}}}const dN=te.createContext(null);function i8({children:e}){const t=jt(),i=te.useCallback(d=>{const{nodes:f=[],setNodes:h,hasDefaultNodes:m,onNodesChange:p,nodeLookup:x,fitViewQueued:y,onNodesChangeMiddlewareMap:_}=t.getState();let N=f;for(const w of d)N=
Line truncated
|
||||
M${I.x},${I.y}h${I.width}v${I.height}h${-I.width}z`,fillRule:"evenodd",pointerEvents:"none"})]})})}IN.displayName="MiniMap";const QB=te.memo(IN),WB=e=>t=>e?`${Math.max(1/t.transform[2],1)}`:void 0,JB={[Ys.Line]:"right",[Ys.Handle]:"bottom-right"};function e9({nodeId:e,position:t,variant:i=Ys.Handle,className:a,style:s=void 0,children:l,color:c,minWidth:d=10,minHeight:f=10,maxWidth:h=Number.MAX_VALUE,maxHeight:m=Number.MAX_VALUE,keepAspectRatio:p=!1,resizeDirection:x,autoScale:y=!0,shouldResize:_,onResizeStart:N,onResize:S,onResizeEnd:w}){const k=mN(),E=typeof e=="string"?e:k,O=jt(),U=te.useRef(null),R=i===Ys.Handle,B=dt(te.useCallback(WB(R&&y),[R,y]),qt),I=te.useRef(null),Z=t??JB[i];te.useEffect(()=>{if(!(!U.current||!E))return I.current||(I.current=v6({domNode:U.current,nodeId:E,getStoreItems:()=>{const{nodeLookup:z,transform:K,snapGrid:G,snapToGrid:C,nodeOrigin:q,domNode:M}=O.getState();return{nodeLookup:z,transform:K,snapGrid:G,snapToGrid:C,nodeOrigin:q,paneDomNode:M}},onChange:(z,K)=>{const{triggerNodeChanges:G,nodeLookup:C,parentLookup:q,nodeOrigin:M}=O.getState(),H=[],X={x:z.x,y:z.y},Y=C.get(E);if(Y&&Y.expandParent&&Y.parentId){const A=Y.origin??M,j=z.width??Y.measured.width??0,V=z.height??Y.measured.height??0,D={id:Y.id,parentId:Y.parentId,rect:{width:j,height:V,...zE({x:z.x??Y.position.x,y:z.y??Y.position.y},{width:j,height:V},Y.parentId,C,A)}},F=Jp([D],C,q,M);H.push(...F),X.x=z.x?Math.max(A[0]*j,z.x):void 0,X.y=z.y?Math.max(A[1]*V,z.y):void 0}if(X.x!==void 0&&X.y!==void 0){const A={id:E,type:"position",position:{...X}};H.push(A)}if(z.width!==void 0&&z.height!==void 0){const j={id:E,type:"dimensions",resizing:!0,setAttributes:x?x==="horizontal"?"width":"height":!0,dimensions:{width:z.width,height:z.height}};H.push(j)}for(const A of K){const j={...A,type:"position"};H.push(j)}G(H)},onEnd:({width:z,height:K})=>{const G={id:E,type:"dimensions",resizing:!1,dimensions:{width:z,height:K}};O.getState().triggerNodeChanges([G])}})),I.current.update({controlPosition:Z,boundaries:{minWidth:d,minHeight:f,maxWidth:h,maxHeight:m},keepAspectRatio:p,resizeDirection:x,onResizeStart:N,onResize:S,onResizeEnd:w,shouldResize:_}),()=>{var z;(z=I.current)==null||z.destroy()}},[Z,d,f,h,m,p,N,S,w,_]);const L=Z.split("-");return g.jsx("div",{className:on(["react-flow__resize-control","nodrag",...L,i,a]),ref:U,style:{...s,scale:B,...c&&{[R?"backgroundColor":"borderColor"]:c}},children:l})}te.memo(e9);var vt=(e,t)=>()=>(t||e((t={exports:{}}).exports,t),t.exports),Di=vt((e,t)=>{var i=Object.defineProperty,a=(G,C,q)=>C in G?i(G,C,{enumerable:!0,configurable:!0,writable:!0,value:q}):G[C]=q,s=(G,C)=>()=>(C||G((C={exports:{}}).exports,C),C.exports),l=(G,C,q)=>a(G,typeof C!="symbol"?C+"":C,q),c=s((G,C)=>{var q="\0",M="\0",H="",X=class{constructor(F){l(this,"_isDirected",!0),l(this,"_isMultigraph",!1),l(this,"_isCompound",!1),l(this,"_label"),l(this,"_defaultNodeLabelFn",()=>{}),l(this,"_defaultEdgeLabelFn",()=>{}),l(this,"_nodes",{}),l(this,"_in",{}),l(this,"_preds",{}),l(this,"_out",{}),l(this,"_sucs",{}),l(this,"_edgeObjs",{}),l(this,"_edgeLabels",{}),l(this,"_nodeCount",0),l(this,"_edgeCount",0),l(this,"_parent"),l(this,"_children"),F&&(this._isDirected=Object.hasOwn(F,"directed")?F.directed:!0,this._isMultigraph=Object.hasOwn(F,"multigraph")?F.multigraph:!1,this._isCompound=Object.hasOwn(F,"compound")?F.compound:!1),this._isCompound&&(this._parent={},this._children={},this._children[M]={})}isDirected(){return this._isDirected}isMultigraph(){return this._isMultigraph}isCompound(){return this._isCompound}setGraph(F){return this._label=F,this}graph(){return this._label}setDefaultNodeLabel(F){return this._defaultNodeLabelFn=F,typeof F!="function"&&(this._defaultNodeLabelFn=()=>F),this}nodeCount(){return this._nodeCount}nodes(){return Object.keys(this._nodes)}sources(){var F=this;return this.nodes().filter($=>Object.keys(F._in[$]).length===0)}sinks(){var F=this;return this.nodes().filter($=>Object.keys(F._out[$]).length===0)}setNodes(F,$){var Q=arguments,J=this;return F.forEach(function(W){Q.length>1?J.setNode(W,$):J.setNode(W)}),this}setNode(F,$){return Object.hasOwn(this._nodes,F)?(arguments.length>1&&(this._nodes[F]=$),this):(this._nodes[F]=arguments.length>1?$:this._defaultNodeLabelFn(F),this._isCompound&&(this._parent[F]=M,this._children[F]={},this._children[M][F]=!0),this._in[F]={},this._preds[F]={},this._out[F]={},this._sucs[F]={},++this._nodeCount,this)}node(F){return this._nodes[F]}hasNode(F){return Object.hasOwn(this._nodes,F)}removeNode(F){var $=this;if(Object.hasOwn(this._nodes,F)){var Q=J=>$.removeEdge($._edgeObjs[J]);delete this._nodes[F],this._isCompound&&(this._removeFromParentsChildList(F),delete this._parent[F],this.children(F).forEach(function(J){$.setParent(J)}),delete this._children[F]),Object.keys(this._in[F]).forEach(Q),delete this._in[F],delete this._preds[F],Object.keys(this._out[F]).forEach(Q),delete this._out[F],delete this._sucs[F],--this._nodeCount}return this}setParent(F,$){if(!this._isCompound
Line truncated
|
||||
`).length>t;return g.jsxs("div",{children:[g.jsx("div",{className:i&&l?"max-h-[1200px] overflow-auto":"",style:!i&&l?{display:"-webkit-box",WebkitLineClamp:t,WebkitBoxOrient:"vertical",overflow:"hidden"}:void 0,children:g.jsx(Ls,{text:e})}),l&&g.jsx("button",{onClick:()=>a(!i),className:"text-xs text-[#555] hover:text-[#888] mt-1",children:i?"Show less":"Show more"})]})}function xr({children:e,className:t=""}){const[i,a]=te.useState(!1),l=typeof e=="string"?e.trimEnd().split(`
|
||||
`):null,c=l!==null&&l.length>q1,d=c&&!i?l.slice(0,q1).join(`
|
||||
`):e;return g.jsxs("div",{children:[g.jsx("pre",{className:`font-mono text-[13px] leading-relaxed whitespace-pre-wrap break-words mt-1 ${i?"overflow-auto max-h-[1200px]":"overflow-hidden"} ${t}`,children:d}),c&&g.jsx("button",{onClick:()=>a(!i),className:"text-xs text-[#555] hover:text-[#888] mt-0.5",children:i?"Show less":"Show more"})]})}function tg({code:e,language:t,className:i="",collapsible:a=!1}){const[s,l]=te.useState(!1),c=e.trimEnd().split(`
|
||||
`),d=a&&c.length>P1,f=d&&!s?c.slice(0,P1).join(`
|
||||
`):e;let h;try{h=t?Fn.highlight(f,{language:t,ignoreIllegals:!0}).value:Fn.highlightAuto(f).value}catch{h=Fn.highlightAuto(f).value}return g.jsxs("div",{children:[g.jsx("pre",{className:`font-mono text-[12px] leading-relaxed px-0 py-1 mt-1 whitespace-pre-wrap break-all ${a?s?"overflow-auto max-h-[1200px]":"overflow-hidden":"overflow-auto max-h-[400px]"} ${i}`,children:g.jsx("code",{dangerouslySetInnerHTML:{__html:h}})}),d&&g.jsx("button",{onClick:()=>l(!s),className:"text-xs text-[#555] hover:text-[#888] mt-0.5",children:s?"Show less":"Show more"})]})}const L9=50,F1=200,G1=25,V1=24,j9=[/\n?\[Command still running after [\d.]+s - showing output so far\.?\s*(?:Use C-c to interrupt if needed\.)?\]/g,/^\[Below is the output of the previous command\.\]\n?/gm,/^No command is currently running\. Cannot send input\.$/gm,/^A command is already running\. Use is_input=true to send input to it, or interrupt it first \(e\.g\., with C-c\)\.$/gm],z9=/^Chunk ID: [0-9a-f]+\s*$/,I9=[/^Wall time: [\d.]+ seconds\s*$/,/^Process exited with code -?\d+\s*$/,/^Process running with session ID \d+\s*$/,/^Original token count: \d+\s*$/];function B9(e){const t=[];for(let i=0;i<e.length;i++){if(z9.test(e[i])){let a=i+1;for(;a<e.length&&I9.some(s=>s.test(e[a]));)a++;a<e.length&&e[a].trim()==="Output:"&&a++,i=a-1;continue}t.push(e[i])}return t}function U9(e){return e.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")}function Em(e){return e.length>F1?e.slice(0,F1-3)+"...":e}function H9(e,t=""){let i=e.replace(/\x1b(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|\][^\x07]*\x07)/g,"").replace(/\r/g,"");for(const a of j9)i=i.replace(a,"");if(i.trim()){const a=B9(i.split(`
|
||||
`)),s=[];for(const l of a)s.length===0&&!l.trim()||/^\[STRIX_\d+\]\$\s*/.test(l)||t&&l.trim()===t.trim()||t&&new RegExp(`^[\\$#>]\\s*${U9(t.trim())}\\s*$`).test(l)||s.push(l);for(;s.length>0&&/^\[STRIX_\d+\]\$\s*/.test(s[s.length-1]);)s.pop();i=s.join(`
|
||||
`)}return i.trim()}function $9(e){const t=e.split(`
|
||||
`);if(t.length<=L9)return t.map(Em).join(`
|
||||
`);const i=t.length-G1-V1;return[...t.slice(0,G1).map(Em),`... ${i} lines truncated ...`,...t.slice(-V1).map(Em)].join(`
|
||||
`)}function q9({toolName:e,args:t,result:i}){const a=e==="write_stdin",s=a?t.chars??t.input??"":t.command??t.cmd??"",l=i;let c=null,d=null,f=null;if(l&&typeof l=="object"){c=typeof l.content=="string"?l.content:null,d=typeof l.error=="string"?l.error:null,f=typeof l.exit_code=="number"?l.exit_code:null;const m=typeof l.status=="string"?l.status:"";(m==="running"||m==="command still running")&&(c=null)}else typeof l=="string"&&(c=l);const h=c?$9(H9(c,s)):null;return g.jsxs("div",{children:[g.jsx("span",{className:"text-emerald-400/80 font-semibold text-sm",children:a?"Terminal input":"Terminal"}),s&&g.jsx(tg,{code:s,language:"bash",collapsible:!0}),d&&g.jsx(xr,{className:"text-red-400/70",children:d}),h&&g.jsx(xr,{className:"text-[#666]",children:h}),f!=null&&f!==0&&g.jsxs("div",{className:"font-mono text-[13px] text-red-400/70 mt-0.5",children:["exit code ",f]})]})}const Y1={back:"going back in browser history",forward:"going forward in browser history",scroll_down:"scrolling down",scroll_up:"scrolling up",refresh:"refreshing",close_tab:"closing tab",switch_tab:"switching tab",list_tabs:"listing tabs",view_source:"viewing page source",get_console_logs:"getting console logs",screenshot:"taking screenshot",wait:"waiting...",close:"closing"},X1={click:"clicking",double_click:"double clicking",hover:"hovering"};function Nm({prefix:e,url:t,suffix:i}){return g.jsxs("span",{className:"text-[#888] text-[13px]",children:[e,t&&g.jsx("a",{href:t,target:"_blank",rel:"noopener noreferrer",className:"text-cyan-400/80 hover:underline",children:t}),i]})}function P9(e){const t=e.action??"",i=e.url??void 0;if(t in Y1)return Y1[t];if(t==="launch")return i?g.jsx(Nm,{prefix:"launching ",url:i}):"launching";if(t==="goto"||t==="navigate")return g.jsx(Nm,{prefix:"navigating to ",url:i});if(t==="new_tab")return g.jsx(Nm,{prefix:"opening tab ",url:i});if(t in X1)return X1[t];if(t==="type")return`typing "${(e.text??"").slice(0,40)}"`;if(t==="press_key"||t==="key_press")return`pressing key ${e.key??""}`;if(t==="save_pdf"||t==="save_as_pdf"){const a=e.file_path??"";return`saving PDF${a?` to ${a}`:""}`}return t==="execute_js"?"executing javascript":t||"browser action"}function F9({args:e}){const i=(e.action??"")==="execute_js"?e.js_code??e.code??"":"",a=P9(e);return g.jsxs("div",{children:[g.jsxs("div",{className:"flex items-center gap-2 min-w-0",children:[g.jsx("span",{className:"text-blue-400/80 font-semibold text-sm shrink-0",children:"Browser"}),g.jsx("span",{className:"min-w-0 truncate text-[#888] text-[13px]",children:a})]}),i&&g.jsx(tg,{code:i,language:"javascript",collapsible:!0})]})}function ng(e){return e.length>60?"..."+e.slice(-57):e}const au=30;function G9({toolName:e,args:t}){const i=t.path??t.file_path??"",a=t.command??"",s=t.old_str??"",l=t.new_str??"",c=t.regex??"";let d;e==="list_files"?d="list":e==="search_files"?d="search":a==="view"?d="view":a==="create"?d="create":a==="str_replace"?d="edit":a==="undo_edit"?d="undo":a==="insert"?d="insert":d="file";const f=i?ng(i):"",h=c?` /${c}/`:"",m=s?s.split(`
|
||||
`):[],p=l?l.split(`
|
||||
`):[],x=m.length+p.length,y=x>au,_=y?Math.round(au*(m.length/x)):m.length,N=y?au-_:p.length;return g.jsxs("div",{children:[g.jsxs("div",{className:"flex items-baseline gap-2",children:[g.jsx("span",{className:"text-sky-400/80 font-semibold text-sm shrink-0",children:d}),f&&g.jsx("span",{className:"text-[#888] font-mono text-[13px] break-all",children:f})]}),h&&g.jsx("div",{className:"text-purple-400/60 font-mono text-[13px] break-all mt-0.5",children:h}),(s||l)&&g.jsxs("div",{className:"font-mono text-[13px] leading-relaxed mt-1.5",children:[m.slice(0,_).map((S,w)=>g.jsxs("div",{className:"text-red-400/60",children:[g.jsx("span",{className:"select-none text-red-400/30 mr-1",children:"-"}),S]},`o${w}`)),p.slice(0,N).map((S,w)=>g.jsxs("div",{className:"text-emerald-400/60",children:[g.jsx("span",{className:"select-none text-emerald-400/30 mr-1",children:"+"}),S]},`n${w}`)),y&&g.jsxs("div",{className:"text-[#444] mt-0.5",children:["... ",x-au," more lines"]})]})]})}const su=30,V9="*** Begin Patch",Y9="*** End Patch",K1="*** Add File: ",Z1="*** Update File: ",Q1="*** Delete File: ",X9={add:"create",update:"edit",delete:"delete"};function K9(e){const t=e.patch;return typeof t=="string"?t:t&&typeof t=="object"&&typeof t.patch=="string"?t.patch:typeof e.input=="string"?e.input:""}function Z9(e){const t=[];let i=null;const a=()=>{i&&t.push(i),i=null};for(const s of e.split(`
|
||||
`))if(!(s===V9||s===Y9))if(s.startsWith(K1))a(),i={kind:"add",path:s.slice(K1.length).trim(),oldLines:[],newLines:[]};else if(s.startsWith(Z1))a(),i={kind:"update",path:s.slice(Z1.length).trim(),oldLines:[],newLines:[]};else if(s.startsWith(Q1))a(),i={kind:"delete",path:s.slice(Q1.length).trim(),oldLines:[],newLines:[]};else if((i==null?void 0:i.kind)==="update"){if(s.startsWith("@@"))continue;s.startsWith("-")&&!s.startsWith("---")?i.oldLines.push(s.slice(1)):s.startsWith("+")&&!s.startsWith("+++")&&i.newLines.push(s.slice(1))}else(i==null?void 0:i.kind)==="add"&&(s.startsWith("+")?i.newLines.push(s.slice(1)):s.trim()&&i.newLines.push(s));return a(),t}function Q9({op:e}){const t=X9[e.kind]??"file",i=e.oldLines.length+e.newLines.length,a=i>su,s=a&&i>0?Math.round(su*(e.oldLines.length/i)):e.oldLines.length,l=a?su-s:e.newLines.length;return g.jsxs("div",{children:[g.jsxs("div",{className:"flex items-baseline gap-2",children:[g.jsx("span",{className:"text-sky-400/80 font-semibold text-sm shrink-0",children:t}),e.path&&g.jsx("span",{className:"text-[#888] font-mono text-[13px] break-all",children:ng(e.path)})]}),(e.oldLines.length>0||e.newLines.length>0)&&g.jsxs("div",{className:"font-mono text-[13px] leading-relaxed mt-1.5",children:[e.oldLines.slice(0,s).map((c,d)=>g.jsxs("div",{className:"text-red-400/60",children:[g.jsx("span",{className:"select-none text-red-400/30 mr-1",children:"-"}),c]},`o${d}`)),e.newLines.slice(0,l).map((c,d)=>g.jsxs("div",{className:"text-emerald-400/60",children:[g.jsx("span",{className:"select-none text-emerald-400/30 mr-1",children:"+"}),c]},`n${d}`)),a&&g.jsxs("div",{className:"text-[#444] mt-0.5",children:["... ",i-su," more lines"]})]})]})}function W9({args:e,result:t,status:i}){const a=Z9(K9(e));return a.length===0?g.jsxs("div",{children:[g.jsx("span",{className:"text-sky-400/80 font-semibold text-sm",children:"patch"}),i==="failed"&&typeof t=="string"&&t.trim()&&g.jsx("div",{className:"text-red-400/70 text-[13px] mt-1",children:t.trim()})]}):g.jsxs("div",{className:"space-y-2",children:[a.map((s,l)=>g.jsx(Q9,{op:s},l)),i==="failed"&&typeof t=="string"&&t.trim()&&g.jsx("div",{className:"text-red-400/70 text-[13px]",children:t.trim()})]})}function J9({args:e,result:t}){const i=(e.path??"").trim(),a=t;let s=null;if(typeof a=="string"){const l=a.trim();l&&!l.toLowerCase().startsWith("data:image/")&&!l.startsWith("{")&&(s=l)}return g.jsxs("div",{children:[g.jsxs("div",{className:"flex items-baseline gap-2",children:[g.jsx("span",{className:"text-sky-400/80 font-semibold text-sm shrink-0",children:"view image"}),i&&g.jsx("span",{className:"text-[#888] font-mono text-[13px] break-all",children:ng(i)})]}),s&&g.jsx("div",{className:"text-red-400/70 text-[13px] mt-1",children:s})]})}const eU={critical:"text-red-400",high:"text-orange-400",medium:"text-yellow-400",low:"text-blue-400",info:"text-cyan-400"};function tU({args:e,result:t}){const i=e.title??"",a=e.description??"",s=e.impact??"",l=e.target??"",c=e.endpoint??"",d=e.method??"",f=e.technical_analysis??"",h=e.poc_description??"",m=e.poc_script_code??"",p=e.remediation_steps??"",x=e.cve??"",y=e.cwe??"",_=t,N=(_&&typeof _=="object"?_.severity:null)??e.severity??"medium",S=String(N).toLowerCase(),w=(_&&typeof _=="object"?_.cvss_score:null)??e.cvss??null,k=eU[S]??"text-yellow-400";return g.jsxs("div",{className:"space-y-3",children:[g.jsxs("div",{className:"flex items-center gap-2 flex-wrap",children:[g.jsx("span",{className:`font-semibold text-sm ${k}`,children:S.toUpperCase()}),w!=null&&g.jsxs("span",{className:"text-[#888] text-[13px]",children:["CVSS ",w]}),x&&g.jsx("span",{className:"text-[#888] font-mono text-[13px]",children:x}),y&&g.jsx("span",{className:"text-[#888] font-mono text-[13px]",children:y})]}),i&&g.jsx("div",{className:"text-[15px] text-white/80 font-semibold",children:i}),(l||c)&&g.jsxs("div",{className:"text-[13px] text-[#888] font-mono",children:[l,c?` ${d} ${c}`:""]}),a&&g.jsx(Ln,{text:a,maxLines:20}),s&&g.jsxs("div",{children:[g.jsx("span",{className:"text-emerald-400/60 text-sm font-semibold",children:"Impact"}),g.jsx("div",{className:"mt-1",children:g.jsx(Ln,{text:s,maxLines:15})})]}),f&&g.jsxs("div",{children:[g.jsx("span",{className:"text-emerald-400/60 text-sm font-semibold",children:"Technical Analysis"}),g.jsx("div",{className:"mt-1",children:g.jsx(Ln,{text:f,maxLines:20})})]}),(h||m)&&g.jsxs("div",{children:[g.jsx("span",{className:"text-emerald-400/60 text-sm font-semibold",children:"Proof of Concept"}),h&&g.jsx("div",{className:"mt-1",children:g.jsx(Ls,{text:h})}),m&&g.jsx(Yw,{children:m})]}),p&&g.jsxs("div",{children:[g.jsx("span",{className:"text-emerald-400/60 text-sm font-semibold",children:"Remediation"}),g.jsx("div",{className:"mt-1",children:g.jsx(Ln,{text:p,maxLines:15})})]})]})}const UN=200,HN={GET:"text-emerald-400/80",POST:"text-blue-400/80",PUT:"text-yellow-400/80",PATCH:"text-orange-400/80",DELETE:"text-red-400/80"};function ig(e){return e<300?"text-emerald-400/80":e<400?"text-yellow-4
Line truncated
|
||||
`),a=i.slice(0,t).map(s=>Gi(s,UN-5)).join(`
|
||||
`);return i.length>t?a+`
|
||||
...`:a}function nU({args:e,result:t}){const i=e.httpql_filter??"",a=t,s=a?a.requests:null,l=Array.isArray(s)?s:[];return g.jsxs("div",{children:[g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsx("span",{className:"text-purple-400/80 font-semibold text-sm",children:"listing requests"}),i&&g.jsx("span",{className:"text-[#888] text-[13px]",children:Gi(i,150)})]}),l.length>0&&g.jsxs("div",{className:"mt-1.5 font-mono text-[13px] space-y-0.5",children:[l.slice(0,20).map((c,d)=>{const f=(c.method??"GET").toUpperCase(),h=c.host??"",m=c.path??"",p=c.response,x=(p==null?void 0:p.statusCode)??null;return g.jsxs("div",{className:"flex gap-2",children:[g.jsx("span",{className:`w-10 shrink-0 font-bold ${HN[f]??"text-[#888]"}`,children:f}),g.jsx("span",{className:"text-[#777] truncate",children:Gi(h+m,180)}),x!=null&&g.jsx("span",{className:`ml-auto shrink-0 ${ig(x)}`,children:x})]},d)}),l.length>20&&g.jsxs("div",{className:"text-[#555]",children:["... +",l.length-20," more"]})]})]})}function iU({args:e,result:t}){const i=e.request_id,a=e.part??"request",s=e.search_pattern??"",l=t,c=l?l.matches:null,d=Array.isArray(c)?c:[],f=l?l.content??null:null,h=l?!!l.has_more:!1;return g.jsxs("div",{children:[g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsxs("span",{className:"text-purple-400/80 font-semibold text-sm",children:[s?"searching":"viewing"," ",a]}),i!=null&&g.jsxs("span",{className:"text-[#888] text-[13px]",children:["#",i]}),s&&g.jsxs("span",{className:"text-[#666] font-mono text-[13px]",children:["/",Gi(s,100),"/"]})]}),d.length>0&&g.jsxs("div",{className:"mt-1.5 font-mono text-[13px] space-y-1",children:[d.slice(0,5).map((m,p)=>{const x=(m.before??"").replace(/\n/g," ").replace(/\r/g,"").slice(-100),y=(m.after??"").replace(/\n/g," ").replace(/\r/g,"").slice(0,100);return g.jsxs("div",{children:[x&&g.jsxs("span",{className:"text-[#555]",children:["...",x]}),g.jsx("span",{className:"text-amber-400/80 font-bold",children:m.match}),y&&g.jsxs("span",{className:"text-[#555]",children:[y,"..."]})]},p)}),d.length>5&&g.jsxs("div",{className:"text-[#555]",children:["... +",d.length-5," more matches"]})]}),f&&!d.length&&(()=>{const m=f.split(`
|
||||
`),p=m.slice(0,15).map(y=>Gi(y,UN)).join(`
|
||||
`),x=h||m.length>15;return g.jsx(xr,{className:"text-[#666]",children:p+(x?`
|
||||
... more content available`:"")})})()]})}function rU({args:e,result:t}){const i=(e.method??"GET").toUpperCase(),a=e.url??"",s=e.headers,l=e.body,c=typeof l=="string"?l:"",d=t,f=d?d.error??null:null,h=d?d.status_code??null:null,m=d?d.response_time_ms??null:null,p=d?d.body:null,x=typeof p=="string"?p:null;return g.jsxs("div",{children:[g.jsx("span",{className:"text-purple-400/80 font-semibold text-sm",children:"request"}),g.jsxs("div",{className:"mt-1.5 font-mono text-[13px] space-y-0.5",children:[g.jsxs("div",{children:[g.jsx("span",{className:"text-[#555] select-none mr-1",children:">>"}),g.jsx("span",{className:`font-bold ${HN[i]??"text-[#888]"}`,children:i}),g.jsx("span",{className:"text-[#888] ml-1 break-all",children:Gi(a,180)})]}),s&&typeof s=="object"&&Object.entries(s).slice(0,5).map(([y,_])=>g.jsxs("div",{className:"text-[#555] pl-5",children:[y,": ",ip(String(_),150)]},y))]}),c&&g.jsx(xr,{className:"text-[#888]",children:rp(c,4)}),f&&g.jsx("div",{className:"text-red-400/70 text-[13px] mt-1.5",children:ip(f,150)}),h!=null&&g.jsxs("div",{className:"font-mono text-[13px] mt-1.5",children:[g.jsx("span",{className:"text-[#555] select-none mr-1",children:"<<"}),g.jsx("span",{className:`font-bold ${ig(h)}`,children:h}),m!=null&&g.jsxs("span",{className:"text-[#555] ml-2",children:[m,"ms"]})]}),x&&g.jsx(xr,{className:"text-[#666]",children:rp(x,6)})]})}function aU({args:e,result:t}){const i=e.request_id,a=e.modifications,s=t,l=s?s.status_code??null:null,c=s?s.response_time_ms??null:null,d=s?s.body:null,f=typeof d=="string"?d:null;return g.jsxs("div",{children:[g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsx("span",{className:"text-purple-400/80 font-semibold text-sm",children:"repeating request"}),i!=null&&g.jsxs("span",{className:"text-[#888] text-[13px]",children:["#",i]})]}),a&&typeof a=="object"&&Object.keys(a).length>0&&g.jsx("div",{className:"mt-1.5 font-mono text-[13px] space-y-0.5",children:Object.entries(a).slice(0,5).map(([h,m])=>g.jsxs("div",{children:[g.jsxs("span",{className:"text-orange-400/60",children:[h,":"]})," ",g.jsx("span",{className:"text-[#777]",children:ip(typeof m=="string"?m:JSON.stringify(m),150)})]},h))}),l!=null&&g.jsxs("div",{className:"font-mono text-[13px] mt-1.5",children:[g.jsx("span",{className:"text-[#555] select-none mr-1",children:"<<"}),g.jsx("span",{className:`font-bold ${ig(l)}`,children:l}),c!=null&&g.jsxs("span",{className:"text-[#555] ml-2",children:[c,"ms"]})]}),f&&g.jsx(xr,{className:"text-[#666]",children:rp(f,5)})]})}const sU={get:"getting",list:"listing",create:"creating",update:"updating",delete:"deleting"};function oU({args:e}){const t=e.action??"",i=e.scope_name??"",a=sU[t]??(t||"managing");return g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsxs("span",{className:"text-purple-400/80 font-semibold text-sm",children:[a," proxy scope"]}),i&&g.jsx("span",{className:"text-[#888] text-[13px]",children:Gi(i,50)})]})}function lU({args:e}){const t=e.parent_id;return g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsx("span",{className:"text-purple-400/80 font-semibold text-sm",children:"listing sitemap"}),t&&g.jsxs("span",{className:"text-[#888] text-[13px]",children:["under #",Gi(String(t),20)]})]})}function cU({args:e}){const t=e.entry_id;return g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsx("span",{className:"text-purple-400/80 font-semibold text-sm",children:"viewing sitemap entry"}),t&&g.jsxs("span",{className:"text-[#888] text-[13px]",children:["#",Gi(String(t),20)]})]})}function uU(e){switch(e.toolName){case"list_requests":return g.jsx(nU,{...e});case"view_request":return g.jsx(iU,{...e});case"send_request":return g.jsx(rU,{...e});case"repeat_request":return g.jsx(aU,{...e});case"scope_rules":return g.jsx(oU,{...e});case"list_sitemap":return g.jsx(lU,{...e});case"view_sitemap_entry":return g.jsx(cU,{...e});default:return g.jsx("span",{className:"text-purple-400/80 font-semibold text-sm",children:e.toolName.replace(/_/g," ")})}}function dU({args:e}){const t=e.thought??e.content??"";return t?g.jsxs("div",{children:[g.jsx("span",{className:"text-purple-400/80 font-semibold text-sm",children:"Agent is thinking"}),g.jsx("div",{className:"mt-1.5 italic text-[#888]",children:g.jsx(Ln,{text:t,maxLines:20})})]}):null}function fU({toolName:e,args:t}){if(e==="create_agent"){const i=t.name??t.agent_name??"",a=t.task??"";return g.jsxs("div",{children:[g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsx("span",{className:"text-cyan-400/80 font-semibold text-sm",children:"spawning"}),i&&g.jsx("span",{className:"text-cyan-400 font-semibold text-sm",children:i})]}),a&&g.jsx("div",{className:"mt-1.5",children:g.jsx(Ln,{text:a,maxLines:15})})]})}if(e==="agent_finish"){const i=t.result_summary??"",a=t.success,s=t.findings,l=Array.isArray(s)?s:void 0;return g.jsxs("div",{children:[g.jsx("span",{className:`font-semibold text-sm ${a===!1?"text-red-400/80":"text-emerald-400/80"}`,children:a===!1?"A
Line truncated
|
||||
`);if(t.length<=mU)return t.map(Sm).join(`
|
||||
`);const i=t.length-J1-e_;return[...t.slice(0,J1).map(Sm),`... ${i} lines truncated ...`,...t.slice(-e_).map(Sm)].join(`
|
||||
`)}function vU({args:e,result:t}){const i=e.action??"",a=e.code??e.script??"",s=t;let l=null;s&&typeof s=="object"?l=typeof s.stdout=="string"?s.stdout:null:typeof s=="string"&&(l=s);const c=i==="new_session"?"new session":i==="close"?"close session":i==="list_sessions"?"list sessions":null,d=l?yU(xU(l)):null;return g.jsxs("div",{children:[g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsx("span",{className:"text-yellow-400/80 font-semibold text-sm",children:"Python"}),c&&g.jsx("span",{className:"text-[#888] text-[13px]",children:c})]}),a&&g.jsx(tg,{code:a,language:"python",collapsible:!0}),d&&g.jsx(xr,{className:"text-[#666]",children:d})]})}function _U({args:e}){const t=e.targets,a=(Array.isArray(t)?t:[]).map(s=>typeof s=="object"&&s?s.original??null:null).filter(Boolean);return g.jsxs("div",{children:[g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsx("span",{className:"text-emerald-400/80 font-semibold text-sm",children:"Starting penetration test"}),a.length===1&&g.jsxs("span",{className:"text-[#888] text-[13px]",children:["on ",a[0]]})]}),a.length>1&&g.jsx("div",{className:"mt-1.5 space-y-0.5",children:a.map((s,l)=>g.jsxs("div",{className:"text-[13px] text-[#888]",children:[g.jsx("span",{className:"text-[#555] mr-1",children:"•"}),s]},l))})]})}function wU({args:e}){const t=e.name??"Unknown Agent",i=e.task??"";return g.jsxs("div",{children:[g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsx("span",{className:"text-[#888] text-[13px]",children:"subagent"}),g.jsx("span",{className:"text-purple-400 font-semibold text-sm",children:t})]}),i&&g.jsx("div",{className:"mt-1.5",children:g.jsx(Ln,{text:i,maxLines:15})})]})}function EU(e){return e.toolName==="subagent_start_info"?g.jsx(wU,{...e}):g.jsx(_U,{...e})}function NU({args:e}){const t=e.executive_summary??"",i=e.methodology??"",a=e.technical_analysis??"",s=e.recommendations??"";return g.jsxs("div",{className:"space-y-3",children:[g.jsx("span",{className:"text-emerald-400/80 font-semibold text-sm",children:"Penetration test completed"}),t&&g.jsxs("div",{children:[g.jsx("span",{className:"text-emerald-400/60 text-sm font-semibold",children:"Executive Summary"}),g.jsx("div",{className:"mt-1",children:g.jsx(Ln,{text:t,maxLines:25})})]}),i&&g.jsxs("div",{children:[g.jsx("span",{className:"text-emerald-400/60 text-sm font-semibold",children:"Methodology"}),g.jsx("div",{className:"mt-1",children:g.jsx(Ln,{text:i,maxLines:25})})]}),a&&g.jsxs("div",{children:[g.jsx("span",{className:"text-emerald-400/60 text-sm font-semibold",children:"Technical Analysis"}),g.jsx("div",{className:"mt-1",children:g.jsx(Ln,{text:a,maxLines:25})})]}),s&&g.jsxs("div",{children:[g.jsx("span",{className:"text-emerald-400/60 text-sm font-semibold",children:"Recommendations"}),g.jsx("div",{className:"mt-1",children:g.jsx(Ln,{text:s,maxLines:25})})]}),!t&&!i&&!a&&!s&&g.jsx("div",{className:"text-[#555] text-xs",children:"Generating final report..."})]})}function SU({toolName:e,args:t,result:i}){if(e==="create_note"){const a=t.title??"",s=t.content??"",l=t.category??"general";return g.jsxs("div",{children:[g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsx("span",{className:"text-amber-400/80 font-semibold text-sm",children:"note"}),g.jsxs("span",{className:"text-[#555] text-[13px]",children:["(",l,")"]})]}),a&&g.jsx("div",{className:"mt-1.5 text-[#999] text-[13px]",children:a}),s&&g.jsx("div",{className:"mt-1",children:g.jsx(Ls,{text:s})})]})}if(e==="delete_note")return g.jsx("span",{className:"text-amber-400/80 font-semibold text-sm",children:"note removed"});if(e==="update_note"){const a=t.title??"",s=t.content??"";return g.jsxs("div",{children:[g.jsx("span",{className:"text-amber-400/80 font-semibold text-sm",children:"note updated"}),a&&g.jsx("div",{className:"mt-1.5 text-[#999] text-[13px]",children:a}),s&&g.jsx("div",{className:"mt-1",children:g.jsx(Ls,{text:s})})]})}if(e==="get_note"){const a=i,s=a&&typeof a=="object"&&a.success?a.note:void 0;return g.jsxs("div",{children:[g.jsx("span",{className:"text-amber-400/80 font-semibold text-sm",children:"note read"}),s&&g.jsxs(g.Fragment,{children:[g.jsxs("div",{className:"mt-1.5 text-[#999] text-[13px]",children:[s.title??"(untitled)",g.jsxs("span",{className:"text-[#555] ml-1",children:["(",s.category??"general",")"]})]}),s.content&&g.jsx("div",{className:"mt-1",children:g.jsx(Ls,{text:s.content})})]})]})}if(e==="list_notes"){const a=i;let s=[];if(a&&typeof a=="object"&&a.success){const l=a.notes;s=Array.isArray(l)?l:[]}return g.jsxs("div",{children:[g.jsx("span",{className:"text-amber-400/80 font-semibold text-sm",children:"notes"}),s.length>0?g.jsx("div",{className:"mt-1.5 space-y-0.5",children:s.map((l,c)=>g.jsxs("div",{className:"text-[13px]",children:[g.jsx("span",{className:"text-[#555] mr-1",children:"-"}),g.jsx("span",{className:"text-[#999]",children:l.title??"(untitled)"}),g.jsxs("span",{className:"text-[#555] ml-1",children:["(",l.category??"general",")"]}),l.content
Line truncated
|
||||
`)){const s=a.match(/^#{1,6}\s+(.*)$/);if(s){const l=s[1].trim().toLowerCase();if(l===i)continue;i=l}else a.trim()!==""&&(i=null);t.push(a)}return t.join(`
|
||||
`)}function UH({onOpenEmail:e}){return g.jsx("button",{onClick:e,className:"group w-full cursor-pointer rounded-xl border border-emerald-500/25 bg-emerald-500/[0.06] p-4 text-left transition-colors hover:border-emerald-500/40",children:g.jsxs("div",{className:"flex items-center gap-3",children:[g.jsx("div",{className:"flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg",style:{border:"1px solid rgba(16,185,129,0.3)",background:"rgba(16,185,129,0.08)"},children:g.jsx(up,{className:"h-4 w-4 text-emerald-400","aria-hidden":"true"})}),g.jsxs("div",{className:"min-w-0 flex-1",children:[g.jsx("p",{className:"text-sm font-semibold text-white",children:"Email an encrypted PDF report of this run"}),g.jsx("p",{className:"mt-0.5 text-xs text-[#888]",children:"Encrypted with a key only you can see, email verified with a one-time code before sending."})]}),g.jsx("span",{className:"flex-shrink-0 rounded-lg bg-white px-3 py-1.5 text-xs font-semibold text-black transition-opacity group-hover:opacity-90",children:"Email report"})]})})}function HH({summary:e,counts:t,total:i,reportMarkdown:a,onOpenEmail:s}){const l=[["Executive Summary",e.executiveSummary],["Technical Analysis",e.technicalAnalysis],["Methodology",e.methodology],["Recommendations",e.recommendations]].filter(([,c])=>!!c).map(([c,d])=>({title:c,content:IH(d)}));return g.jsxs("div",{className:"space-y-6",children:[i>0&&g.jsx("div",{className:"rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5",children:g.jsx(FD,{findings:{total:i,...t}})}),g.jsx(UH,{onOpenEmail:s}),l.length>0?g.jsx("div",{className:"rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5 space-y-8",children:l.map(c=>g.jsx(ia,{title:c.title,content:c.content},c.title))}):a?g.jsx("div",{className:"rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5",children:g.jsx(ia,{content:BH(a)})}):i===0&&g.jsx("p",{className:"text-sm text-[#888]",children:"No summary available for this run yet."}),g.jsx("div",{className:"grid grid-cols-1 sm:grid-cols-2 gap-3",children:AH.map(c=>g.jsx(Xx,{item:c,surface:"overview"},c.slug))}),g.jsxs("div",{children:[g.jsx("p",{className:"mb-2 text-sm font-semibold text-white",children:"Continuous coverage for your org"}),g.jsx("div",{className:"grid grid-cols-1 sm:grid-cols-3 gap-3",children:MH.map(c=>g.jsx(Xx,{item:c,surface:"overview"},c.slug))})]})]})}function Tm({active:e,onClick:t,children:i}){return g.jsxs("button",{onClick:t,className:`cursor-pointer relative pb-2.5 text-sm font-semibold transition-colors ${e?"text-white":"text-[#666] hover:text-white"}`,children:[i,e&&g.jsx("span",{className:"absolute bottom-0 inset-x-0 h-0.5 bg-white rounded-full"})]})}function $H({run:e,canSteer:t}){const{agents:i,events:a}=e.transcript,s=te.useMemo(()=>VU(i,a),[i,a]),[l,c]=te.useState(null),d=l?i.find(h=>h.id===l)??null:null,f=t&&!e.finished;return g.jsxs("div",{className:"space-y-5",children:[g.jsxs("div",{className:"rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5",children:[g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsx(S_,{className:"w-4 h-4 text-[#888]","aria-hidden":"true"}),g.jsx("h2",{className:"text-sm font-semibold text-white",children:"Agent graph"}),g.jsxs("span",{className:"text-xs text-[#666]",children:[i.length," agent",i.length===1?"":"s"]})]}),g.jsx("p",{className:"mt-1 mb-4 text-xs text-[#666]",children:"Click an agent to open its full transcript."}),g.jsx("div",{className:"h-[480px] rounded-lg border border-[#1a1a1a] overflow-hidden",children:g.jsx(D9,{agents:s,selectedAgentId:l,onSelectAgent:h=>c(h),eventsLoaded:!0,eventsEmpty:s.size===0,scanCompleted:e.finished})})]}),f&&g.jsx(QN,{agents:i}),g.jsxs("div",{className:"rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5",children:[g.jsx("p",{className:"text-sm font-semibold text-white",children:"Run this scan with more depth"}),g.jsx("p",{className:"mt-0.5 text-xs text-[#666]",children:"Re-run this scan on managed infra in the cloud."}),g.jsx("div",{className:"mt-3 flex flex-wrap gap-2.5",children:g.jsx(el,{label:"Re-run in Strix Cloud with more depth",desc:"Run this scan on managed infra with more depth.",slug:"live_scan",surface:"agents",icon:FT})})]}),d&&g.jsx(fH,{agent:d,events:a,steerable:f,onClose:()=>c(null)})]})}c2.createRoot(document.getElementById("root")).render(g.jsx(te.StrictMode,{children:g.jsx(OH,{})}));
|
||||
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/png" href="./logo.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<title>Strix Results</title>
|
||||
<script type="module" crossorigin src="./assets/index-Cmmg8DTB.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-B-nLXAmX.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.7 KiB |
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/png" href="./logo.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<title>Strix Results</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+4272
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "strix-viewer",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dagrejs/dagre": "^2.0.4",
|
||||
"@xyflow/react": "^12.10.1",
|
||||
"clsx": "^2.1.1",
|
||||
"diff": "^8.0.3",
|
||||
"highlight.js": "^11.11.1",
|
||||
"lucide-react": "^0.563.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"tailwind-merge": "^3.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.1.0",
|
||||
"@types/diff": "^7.0.2",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"tailwindcss": "^4.1.0",
|
||||
"typescript": "^5.6.0",
|
||||
"vite": "^6.0.0"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.7 KiB |
@@ -0,0 +1,806 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
ShieldCheck,
|
||||
ArrowLeft,
|
||||
AlertCircle,
|
||||
Waypoints,
|
||||
Mail,
|
||||
ChevronDown,
|
||||
Wrench,
|
||||
FileCheck2,
|
||||
CalendarClock,
|
||||
Radar,
|
||||
GitPullRequest,
|
||||
Rocket,
|
||||
ArrowUpRight,
|
||||
History,
|
||||
} from "lucide-react";
|
||||
import type { Vulnerability, VulnerabilitySeverity } from "@/types/issues";
|
||||
import { SEVERITY_COLORS } from "@/types/issues";
|
||||
import { getSeverityDot } from "@/lib/vulnerability-utils";
|
||||
import VulnerabilityDetail from "@/components/vulnerability/VulnerabilityDetail";
|
||||
import { ContentSection } from "@/components/vulnerability/ContentSection";
|
||||
import { IssueSeveritySummary } from "@/components/IssueSeveritySummary";
|
||||
import AgentGraph from "@/components/live/AgentGraph";
|
||||
import { buildGraphAgents } from "@/components/live/AgentTranscript";
|
||||
import AgentDetailModal from "@/components/live/AgentDetailModal";
|
||||
import { ScanPromptComposer } from "@/components/live/ScanPromptComposer";
|
||||
import { severityCounts, type ParsedRunSummary } from "@/lib/local-run-parser";
|
||||
import {
|
||||
fetchAll,
|
||||
fetchAuthStatus,
|
||||
fetchCapabilities,
|
||||
fetchRunSummary,
|
||||
fetchRuns,
|
||||
fetchTranscript,
|
||||
fetchVulnerabilities,
|
||||
forgetAuth,
|
||||
type AuthStatus,
|
||||
type LoadedRun,
|
||||
type RunsPayload,
|
||||
} from "@/data/serverSource";
|
||||
import { SIGNUP_URL, ctaUrl, trackCta } from "@/lib/cta";
|
||||
import { runTitle } from "@/lib/target-utils";
|
||||
import Sidebar from "@/components/Sidebar";
|
||||
import PastRunsView from "@/components/PastRunsView";
|
||||
import EmailReportView from "@/components/EmailReportView";
|
||||
import FeatureDetail from "@/components/FeatureDetail";
|
||||
import { ProTile, ProInlineCta, type ProItem } from "@/components/ProCta";
|
||||
import { FEATURES } from "@/lib/pro-features";
|
||||
|
||||
export type View = "overview" | "issues" | "agents" | "history" | "feature" | "email";
|
||||
|
||||
const TRUST_BANNER =
|
||||
"Your findings stay on your machine. They're rendered here locally in your browser and never uploaded or stored by Strix. Emailing a report is an explicit opt-in that sends an encrypted copy only you can open.";
|
||||
|
||||
const SEVERITY_ORDER: VulnerabilitySeverity[] = ["critical", "high", "medium", "low"];
|
||||
const POLL_MS = 500;
|
||||
|
||||
// Curated inline CTAs. Continuous-coverage row on Overview (the restyled upsell
|
||||
// tiles), plus the recommendations pairing.
|
||||
const RECOMMENDATION_CTAS: ProItem[] = [
|
||||
{ title: "One-click autofix + open a fix PR", desc: "Fix it for you and open a PR, retested.", slug: "autofix", icon: Wrench },
|
||||
{ title: "Export SOC 2 / ISO 27001 report", desc: "Share an auditor-ready report with your team.", slug: "compliance", icon: FileCheck2 },
|
||||
];
|
||||
const COVERAGE_CTAS: ProItem[] = [
|
||||
{ title: "Scheduled pentesting", desc: "Continuous coverage for your whole org.", slug: "scheduled", icon: CalendarClock },
|
||||
{ title: "Attack surface monitoring", desc: "Continuous coverage for your whole org.", slug: "asm", icon: Radar },
|
||||
{ title: "PR reviews", desc: "Pentest every pull request your team opens.", slug: "pr_reviews", icon: GitPullRequest },
|
||||
];
|
||||
|
||||
export default function App() {
|
||||
const [activeRun, setActiveRun] = useState<string | null>(null);
|
||||
const [run, setRun] = useState<LoadedRun | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [view, setView] = useState<View>("overview");
|
||||
const [activeFeature, setActiveFeature] = useState<string | null>(null);
|
||||
const [auth, setAuth] = useState<AuthStatus | null>(null);
|
||||
const [runs, setRuns] = useState<RunsPayload | null>(null);
|
||||
const [emailPurpose, setEmailPurpose] = useState<"report" | "verify">("report");
|
||||
const [emailSkipDisclosure, setEmailSkipDisclosure] = useState(false);
|
||||
// Whether this viewer can steer a live scan (true only inside the in-TUI
|
||||
// launcher that shares the running scan's coordinator + event loop).
|
||||
const [canSteer, setCanSteer] = useState(false);
|
||||
|
||||
const refreshAuth = useCallback(async () => {
|
||||
try {
|
||||
setAuth(await fetchAuthStatus());
|
||||
} catch {
|
||||
/* auth status is best-effort; the launched run stays viewable */
|
||||
}
|
||||
}, []);
|
||||
|
||||
const refreshRuns = useCallback(async () => {
|
||||
try {
|
||||
setRuns(await fetchRuns());
|
||||
} catch {
|
||||
/* history list is best-effort */
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshAuth();
|
||||
void refreshRuns();
|
||||
// Capabilities never change over a session, so fetch once on mount.
|
||||
fetchCapabilities()
|
||||
.then((caps) => setCanSteer(caps.can_steer))
|
||||
.catch(() => {
|
||||
/* absence of steering is the safe default */
|
||||
});
|
||||
}, [refreshAuth, refreshRuns]);
|
||||
|
||||
// Live polling, scoped to the active run. Re-runs when the active run changes
|
||||
// so switching to a past run (?run=<name>) reloads its data; a finished run
|
||||
// does a single full fetch and stops.
|
||||
const finishedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
finishedRef.current = false;
|
||||
|
||||
const schedule = () => {
|
||||
timer = setTimeout(tick, POLL_MS);
|
||||
};
|
||||
|
||||
const tick = async () => {
|
||||
if (cancelled) return;
|
||||
try {
|
||||
const { summary, raw, finished } = await fetchRunSummary(activeRun);
|
||||
if (cancelled) return;
|
||||
if (finished && !finishedRef.current) {
|
||||
finishedRef.current = true;
|
||||
const full = await fetchAll(activeRun);
|
||||
if (!cancelled) setRun(full);
|
||||
return; // stop polling
|
||||
}
|
||||
const [transcript, vulnerabilities] = await Promise.all([
|
||||
fetchTranscript(activeRun).catch(() => ({ agents: [], events: [] })),
|
||||
fetchVulnerabilities(summary.runId, activeRun).catch(() => [] as Vulnerability[]),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
setRun((prev) => ({
|
||||
summary,
|
||||
raw,
|
||||
finished,
|
||||
transcript,
|
||||
vulnerabilities,
|
||||
reportMarkdown: prev?.reportMarkdown ?? null,
|
||||
}));
|
||||
schedule();
|
||||
} catch (e) {
|
||||
if (cancelled) return;
|
||||
setError(e instanceof Error ? e.message : "Could not load run data.");
|
||||
schedule();
|
||||
}
|
||||
};
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const full = await fetchAll(activeRun);
|
||||
if (cancelled) return;
|
||||
setRun(full);
|
||||
if (full.finished) {
|
||||
finishedRef.current = true;
|
||||
} else {
|
||||
schedule();
|
||||
}
|
||||
} catch (e) {
|
||||
if (cancelled) return;
|
||||
setError(e instanceof Error ? e.message : "Could not load run data.");
|
||||
schedule();
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (timer) clearTimeout(timer);
|
||||
};
|
||||
}, [activeRun]);
|
||||
|
||||
const counts = useMemo(
|
||||
() => (run ? severityCounts(run.vulnerabilities) : null),
|
||||
[run]
|
||||
);
|
||||
const selected = run?.vulnerabilities.find((v) => v.id === selectedId) ?? null;
|
||||
const agentCount = run?.transcript.agents.length ?? 0;
|
||||
const verified = auth?.verified === true;
|
||||
|
||||
// Per-run guard for the default view: land on Agents while a scan is live,
|
||||
// Overview once it finishes. Applied at most once per run and never once the
|
||||
// user has navigated manually (userSetView flips the guard).
|
||||
const initialViewAppliedRef = useRef(false);
|
||||
|
||||
// Reset the guard whenever the active run changes so the newly selected run
|
||||
// gets its own default.
|
||||
useEffect(() => {
|
||||
initialViewAppliedRef.current = false;
|
||||
}, [activeRun]);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialViewAppliedRef.current || !run) return;
|
||||
if (run.finished) {
|
||||
initialViewAppliedRef.current = true;
|
||||
setView("overview");
|
||||
} else if (agentCount > 0) {
|
||||
// Live and agents have appeared: default to the agent graph. If it is
|
||||
// live but no agents exist yet, wait (do not apply, do not set the flag).
|
||||
initialViewAppliedRef.current = true;
|
||||
setView("agents");
|
||||
}
|
||||
}, [run, agentCount]);
|
||||
|
||||
// User-initiated navigation: mark the default guard applied so the per-run
|
||||
// default effect never yanks the user off the view they chose.
|
||||
const userSetView = useCallback((v: View) => {
|
||||
initialViewAppliedRef.current = true;
|
||||
setView(v);
|
||||
}, []);
|
||||
|
||||
const selectRun = useCallback((name: string) => {
|
||||
setActiveRun(name);
|
||||
setSelectedId(null);
|
||||
setRun(null);
|
||||
setError(null);
|
||||
// Reset the guard so the per-run default applies to the newly selected run.
|
||||
initialViewAppliedRef.current = false;
|
||||
}, []);
|
||||
|
||||
const goEmail = useCallback((skipDisclosure: boolean, surface: string) => {
|
||||
trackCta("email_report", surface);
|
||||
setEmailPurpose("report");
|
||||
setEmailSkipDisclosure(skipDisclosure);
|
||||
userSetView("email");
|
||||
}, [userSetView]);
|
||||
|
||||
// Sidebar entry keeps the disclosure (first place those users see it);
|
||||
const openEmail = useCallback(() => goEmail(false, "sidebar"), [goEmail]);
|
||||
// the Overview CTA already states the tradeoff, so it starts the flow directly.
|
||||
const openEmailFromOverview = useCallback(() => goEmail(true, "overview"), [goEmail]);
|
||||
|
||||
const openHistory = useCallback(() => {
|
||||
void refreshRuns();
|
||||
userSetView("history");
|
||||
}, [refreshRuns, userSetView]);
|
||||
|
||||
const onPastRunsVerified = useCallback(async () => {
|
||||
await refreshAuth();
|
||||
await refreshRuns();
|
||||
}, [refreshAuth, refreshRuns]);
|
||||
|
||||
const selectFeature = useCallback((slug: string) => {
|
||||
trackCta(slug, "sidebar_nav");
|
||||
setActiveFeature(slug);
|
||||
userSetView("feature");
|
||||
}, [userSetView]);
|
||||
|
||||
const onForget = useCallback(async () => {
|
||||
await forgetAuth();
|
||||
await refreshAuth();
|
||||
await refreshRuns();
|
||||
}, [refreshAuth, refreshRuns]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-black text-white flex">
|
||||
<Sidebar
|
||||
view={view}
|
||||
onSelectView={(v) => {
|
||||
if (v === "history") openHistory();
|
||||
else userSetView(v);
|
||||
}}
|
||||
activeFeature={activeFeature}
|
||||
onSelectFeature={selectFeature}
|
||||
issuesCount={run?.vulnerabilities.length ?? 0}
|
||||
agentCount={agentCount}
|
||||
runCount={runs?.count ?? 0}
|
||||
verified={verified}
|
||||
email={auth?.email ?? null}
|
||||
onOpenEmail={openEmail}
|
||||
onOpenHistory={openHistory}
|
||||
onForget={() => void onForget()}
|
||||
/>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Top bar */}
|
||||
<div className="border-b border-[#222]">
|
||||
<div className="max-w-[72rem] mx-auto px-6 py-4 flex items-center gap-1.5">
|
||||
<a
|
||||
href={ctaUrl("https://app.strix.ai", "logo")}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => trackCta("logo", "topbar")}
|
||||
className="flex items-center gap-1.5 opacity-90 transition-opacity hover:opacity-100 lg:hidden"
|
||||
title="Open Strix Cloud"
|
||||
>
|
||||
<img src="./logo.png" alt="Strix" className="w-10 h-8 object-cover" />
|
||||
<div className="text-base text-white font-medium tracking-tight">Strix</div>
|
||||
</a>
|
||||
<span className="text-xs text-[#666]">Local results</span>
|
||||
{run && <LiveIndicator finished={run.finished} />}
|
||||
<div className="ml-auto flex items-center gap-3">
|
||||
{verified && runs && !runs.locked && runs.runs.length > 0 && (
|
||||
<RunSwitcher
|
||||
runs={runs}
|
||||
activeRun={activeRun}
|
||||
launchedName={runTitle(run?.summary.targets[0] ?? null, run?.summary.runName ?? run?.summary.runId ?? "Current run")}
|
||||
onSelect={selectRun}
|
||||
/>
|
||||
)}
|
||||
<a
|
||||
href={ctaUrl(SIGNUP_URL, "run_in_cloud")}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => trackCta("run_in_cloud", "topbar")}
|
||||
className="inline-flex items-center gap-1 rounded-lg bg-white px-3 py-1.5 text-xs font-semibold text-black transition-opacity hover:opacity-90"
|
||||
>
|
||||
Run in the cloud
|
||||
<ArrowUpRight className="w-3 h-3" aria-hidden="true" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-[72rem] mx-auto px-6 py-8 space-y-6">
|
||||
{/* Trust banner (not on the Pro feature or email pages) */}
|
||||
{view !== "feature" && view !== "email" && (
|
||||
<div className="rounded-lg px-4 py-3 flex gap-3 items-start" style={{ border: "1px solid rgba(255,255,255,0.08)" }}>
|
||||
<ShieldCheck className="w-5 h-5 flex-shrink-0 mt-0.5 text-emerald-400" aria-hidden="true" />
|
||||
<p className="text-sm text-[#aaa] leading-relaxed">{TRUST_BANNER}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && !run && view !== "history" && view !== "email" && view !== "feature" && (
|
||||
<div className="rounded-lg px-4 py-3 flex gap-3 items-start border border-red-500/30 bg-red-500/5">
|
||||
<AlertCircle className="w-5 h-5 flex-shrink-0 mt-0.5 text-red-400" aria-hidden="true" />
|
||||
<p className="text-sm text-red-300">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view === "email" ? (
|
||||
<EmailReportView
|
||||
activeRun={activeRun}
|
||||
auth={auth}
|
||||
purpose={emailPurpose}
|
||||
skipDisclosure={emailSkipDisclosure}
|
||||
onAuthChanged={() => {
|
||||
void refreshAuth();
|
||||
void refreshRuns();
|
||||
}}
|
||||
onExit={(dest) => setView(dest === "history" ? "history" : "overview")}
|
||||
/>
|
||||
) : view === "feature" && activeFeature && FEATURES[activeFeature] ? (
|
||||
<FeatureDetail feature={FEATURES[activeFeature]} />
|
||||
) : view === "history" ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<History className="w-5 h-5 text-[#888]" aria-hidden="true" />
|
||||
<h1 className="text-2xl font-semibold text-white">Past runs</h1>
|
||||
</div>
|
||||
<PastRunsView
|
||||
runs={runs}
|
||||
activeRun={activeRun}
|
||||
onSelectRun={selectRun}
|
||||
onVerified={() => void onPastRunsVerified()}
|
||||
/>
|
||||
</div>
|
||||
) : !run && !error ? (
|
||||
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-10 text-center">
|
||||
<div className="w-6 h-6 mx-auto mb-3 rounded-full border-2 border-[#333] border-t-white animate-spin" />
|
||||
<p className="text-sm text-[#888]">Loading run data…</p>
|
||||
</div>
|
||||
) : run && counts ? (
|
||||
<>
|
||||
<SummaryHeader summary={run.summary} />
|
||||
|
||||
{/* Tab strip: shown on small screens where the sidebar is hidden. */}
|
||||
<div className="flex gap-5 border-b border-[#2a2a2a] lg:hidden">
|
||||
<TabButton active={view === "overview"} onClick={() => userSetView("overview")}>
|
||||
Overview
|
||||
</TabButton>
|
||||
<TabButton active={view === "issues"} onClick={() => userSetView("issues")}>
|
||||
Issues{run.vulnerabilities.length > 0 ? ` (${run.vulnerabilities.length})` : ""}
|
||||
</TabButton>
|
||||
{agentCount > 0 && (
|
||||
<TabButton active={view === "agents"} onClick={() => userSetView("agents")}>
|
||||
Agents ({agentCount})
|
||||
</TabButton>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{view === "overview" ? (
|
||||
<OverviewTab
|
||||
summary={run.summary}
|
||||
counts={counts}
|
||||
total={run.vulnerabilities.length}
|
||||
reportMarkdown={run.reportMarkdown}
|
||||
onOpenEmail={openEmailFromOverview}
|
||||
/>
|
||||
) : view === "agents" && agentCount > 0 ? (
|
||||
<AgentsTab run={run} canSteer={canSteer} />
|
||||
) : selected ? (
|
||||
<div className="space-y-4">
|
||||
<button
|
||||
onClick={() => setSelectedId(null)}
|
||||
className="cursor-pointer inline-flex items-center gap-1.5 text-sm text-[#888] hover:text-white transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" /> Back to all findings
|
||||
</button>
|
||||
<VulnerabilityDetail vulnerability={selected} />
|
||||
</div>
|
||||
) : (
|
||||
<FindingsList
|
||||
vulnerabilities={run.vulnerabilities}
|
||||
finished={run.finished}
|
||||
onSelect={(id) => setSelectedId(id)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RunSwitcher({
|
||||
runs,
|
||||
activeRun,
|
||||
launchedName,
|
||||
onSelect,
|
||||
}: {
|
||||
runs: RunsPayload;
|
||||
activeRun: string | null;
|
||||
launchedName: string;
|
||||
onSelect: (name: string) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const activeEntry = runs.runs.find((r) => r.name === activeRun);
|
||||
const current = activeEntry ? runTitle(activeEntry.target, activeEntry.name) : launchedName;
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
onBlur={() => setTimeout(() => setOpen(false), 150)}
|
||||
className="flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs text-[#aaa] transition-colors hover:text-white"
|
||||
style={{ border: "1px solid #2a2a2a" }}
|
||||
>
|
||||
<History className="w-3.5 h-3.5" aria-hidden="true" />
|
||||
<span className="max-w-[160px] truncate">{current}</span>
|
||||
<ChevronDown className="w-3.5 h-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
className="absolute right-0 z-50 mt-1.5 max-h-80 w-64 overflow-y-auto rounded-lg py-1 shadow-xl"
|
||||
style={{ border: "1px solid #2a2a2a", background: "#0a0a0a" }}
|
||||
>
|
||||
{runs.runs.map((r) => {
|
||||
const active = r.name === activeRun;
|
||||
return (
|
||||
<button
|
||||
key={r.name}
|
||||
onMouseDown={() => onSelect(r.name)}
|
||||
className={`flex w-full items-center gap-2 px-3 py-2 text-left text-xs transition-colors hover:bg-[rgba(255,255,255,0.06)] ${
|
||||
active ? "text-white" : "text-[#aaa]"
|
||||
}`}
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate">{runTitle(r.target, r.name)}</span>
|
||||
{r.target && <span className="block truncate font-mono text-[#666]">{r.target}</span>}
|
||||
</span>
|
||||
{active && <span className="h-1.5 w-1.5 flex-shrink-0 rounded-full bg-emerald-400" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LiveIndicator({ finished }: { finished: boolean }) {
|
||||
if (finished) {
|
||||
return (
|
||||
<span className="ml-3 inline-flex items-center gap-1.5 text-xs text-[#888]">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-[#555]" />
|
||||
Complete
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="ml-3 inline-flex items-center gap-1.5 text-xs text-emerald-400">
|
||||
<span className="relative flex h-1.5 w-1.5">
|
||||
<span className="absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75 animate-ping" />
|
||||
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-emerald-400" />
|
||||
</span>
|
||||
Live
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number | null): string | null {
|
||||
if (seconds == null) return null;
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const m = Math.floor(seconds / 60);
|
||||
if (m < 60) return `${m}m`;
|
||||
const h = Math.floor(m / 60);
|
||||
return `${h}h ${m % 60}m`;
|
||||
}
|
||||
|
||||
function SummaryHeader({ summary }: { summary: ParsedRunSummary }) {
|
||||
const duration = formatDuration(summary.durationSeconds);
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-white">
|
||||
{runTitle(summary.targets[0] ?? null, summary.runName ?? summary.runId ?? "Scan results")}
|
||||
</h1>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-[#888]">
|
||||
{summary.targets.length > 0 && (
|
||||
<span className="font-mono text-[#aaa]">{summary.targets.join(", ")}</span>
|
||||
)}
|
||||
{summary.scanMode && <Meta label={summary.scanMode} />}
|
||||
{duration && <Meta label={duration} />}
|
||||
{summary.status && <Meta label={summary.status} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Meta({ label }: { label: string }) {
|
||||
return (
|
||||
<>
|
||||
<span className="text-[#333]">·</span>
|
||||
<span className="capitalize">{label}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function FindingsList({
|
||||
vulnerabilities,
|
||||
finished,
|
||||
onSelect,
|
||||
}: {
|
||||
vulnerabilities: Vulnerability[];
|
||||
finished: boolean;
|
||||
onSelect: (id: string) => void;
|
||||
}) {
|
||||
const sorted = [...vulnerabilities].sort(
|
||||
(a, b) => SEVERITY_ORDER.indexOf(a.severity) - SEVERITY_ORDER.indexOf(b.severity)
|
||||
);
|
||||
if (sorted.length === 0) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-8 text-center text-sm text-[#888]">
|
||||
{finished ? "No findings in this run." : "No findings yet. The scan is still running…"}
|
||||
</div>
|
||||
{finished && (
|
||||
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5">
|
||||
<p className="text-sm font-medium text-white">Stay ahead of new exposures</p>
|
||||
<p className="mt-0.5 mb-3 text-xs text-[#666]">
|
||||
Attack surface monitoring catches new exposures for your org over time.
|
||||
</p>
|
||||
<ProInlineCta
|
||||
label="Attack surface monitoring"
|
||||
desc="Continuous coverage for your whole org."
|
||||
slug="asm"
|
||||
surface="empty_state"
|
||||
icon={Radar}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{sorted.map((v) => (
|
||||
<button
|
||||
key={v.id}
|
||||
onClick={() => onSelect(v.id)}
|
||||
className="cursor-pointer w-full text-left rounded-lg border border-[#222] hover:border-[#444] bg-[rgba(255,255,255,0.02)] px-4 py-3 transition-colors flex items-center gap-3"
|
||||
>
|
||||
<span className={`w-2.5 h-2.5 rounded-full flex-shrink-0 ${getSeverityDot(v.severity)}`} aria-hidden="true" />
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className="block text-sm font-medium text-white truncate">{v.title}</span>
|
||||
{v.target && (
|
||||
<span className="block text-xs text-[#666] font-mono truncate">{v.target}</span>
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className={`text-xs font-semibold px-2 py-0.5 rounded-full border capitalize ${SEVERITY_COLORS[v.severity]}`}
|
||||
>
|
||||
{v.severity}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Strip a single leading markdown heading (report sections embed their own). */
|
||||
function stripLeadingHeading(md: string): string {
|
||||
return md.replace(/^\s*#{1,6}[ \t]+.*(?:\r?\n)+/, "").trimStart();
|
||||
}
|
||||
|
||||
function dedupeHeadings(md: string): string {
|
||||
const out: string[] = [];
|
||||
let lastHeading: string | null = null;
|
||||
for (const line of md.split("\n")) {
|
||||
const m = line.match(/^#{1,6}\s+(.*)$/);
|
||||
if (m) {
|
||||
const norm = m[1].trim().toLowerCase();
|
||||
if (norm === lastHeading) continue;
|
||||
lastHeading = norm;
|
||||
} else if (line.trim() !== "") {
|
||||
lastHeading = null;
|
||||
}
|
||||
out.push(line);
|
||||
}
|
||||
return out.join("\n");
|
||||
}
|
||||
|
||||
/** Primary local CTA: email an encrypted PDF. Verify-email affordance, no lock. */
|
||||
function EmailReportCta({ onOpenEmail }: { onOpenEmail: () => void }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onOpenEmail}
|
||||
className="group w-full cursor-pointer rounded-xl border border-emerald-500/25 bg-emerald-500/[0.06] p-4 text-left transition-colors hover:border-emerald-500/40"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg"
|
||||
style={{ border: "1px solid rgba(16,185,129,0.3)", background: "rgba(16,185,129,0.08)" }}
|
||||
>
|
||||
<Mail className="h-4 w-4 text-emerald-400" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-semibold text-white">Email an encrypted PDF report of this run</p>
|
||||
<p className="mt-0.5 text-xs text-[#888]">
|
||||
Encrypted with a key only you can see, email verified with a one-time code before sending.
|
||||
</p>
|
||||
</div>
|
||||
<span className="flex-shrink-0 rounded-lg bg-white px-3 py-1.5 text-xs font-semibold text-black transition-opacity group-hover:opacity-90">
|
||||
Email report
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function OverviewTab({
|
||||
summary,
|
||||
counts,
|
||||
total,
|
||||
reportMarkdown,
|
||||
onOpenEmail,
|
||||
}: {
|
||||
summary: ParsedRunSummary;
|
||||
counts: Record<VulnerabilitySeverity, number>;
|
||||
total: number;
|
||||
reportMarkdown: string | null;
|
||||
onOpenEmail: () => void;
|
||||
}) {
|
||||
const sections = (
|
||||
[
|
||||
["Executive Summary", summary.executiveSummary],
|
||||
["Technical Analysis", summary.technicalAnalysis],
|
||||
["Methodology", summary.methodology],
|
||||
["Recommendations", summary.recommendations],
|
||||
] as const
|
||||
)
|
||||
.filter(([, content]) => !!content)
|
||||
.map(([title, content]) => ({ title, content: stripLeadingHeading(content as string) }));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{total > 0 && (
|
||||
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5">
|
||||
<IssueSeveritySummary findings={{ total, ...counts }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Primary CTA: the one primary on Overview. */}
|
||||
<EmailReportCta onOpenEmail={onOpenEmail} />
|
||||
|
||||
{sections.length > 0 ? (
|
||||
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5 space-y-8">
|
||||
{sections.map((s) => (
|
||||
<ContentSection key={s.title} title={s.title} content={s.content} />
|
||||
))}
|
||||
</div>
|
||||
) : reportMarkdown ? (
|
||||
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5">
|
||||
<ContentSection content={dedupeHeadings(reportMarkdown)} />
|
||||
</div>
|
||||
) : (
|
||||
total === 0 && (
|
||||
<p className="text-sm text-[#888]">No summary available for this run yet.</p>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Near Recommendations: act on the fixes. */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{RECOMMENDATION_CTAS.map((item) => (
|
||||
<ProTile key={item.slug} item={item} surface="overview" />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Continuous coverage for your org (restyled upsell tiles). */}
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-semibold text-white">Continuous coverage for your org</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
{COVERAGE_CTAS.map((item) => (
|
||||
<ProTile key={item.slug} item={item} surface="overview" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TabButton({
|
||||
active,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`cursor-pointer relative pb-2.5 text-sm font-semibold transition-colors ${
|
||||
active ? "text-white" : "text-[#666] hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
{active && <span className="absolute bottom-0 inset-x-0 h-0.5 bg-white rounded-full" />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentsTab({ run, canSteer }: { run: LoadedRun; canSteer: boolean }) {
|
||||
const { agents, events } = run.transcript;
|
||||
const graphAgents = useMemo(() => buildGraphAgents(agents, events), [agents, events]);
|
||||
// Clicking a graph node opens the agent's transcript in a modal (matching the
|
||||
// cloud app); no node selected means no modal.
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const selectedAgent = selectedId ? (agents.find((a) => a.id === selectedId) ?? null) : null;
|
||||
|
||||
// Live steering is only possible in-process (canSteer) while the scan runs.
|
||||
const steerable = canSteer && !run.finished;
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Waypoints className="w-4 h-4 text-[#888]" aria-hidden="true" />
|
||||
<h2 className="text-sm font-semibold text-white">Agent graph</h2>
|
||||
<span className="text-xs text-[#666]">
|
||||
{agents.length} agent{agents.length === 1 ? "" : "s"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 mb-4 text-xs text-[#666]">
|
||||
Click an agent to open its full transcript.
|
||||
</p>
|
||||
<div className="h-[480px] rounded-lg border border-[#1a1a1a] overflow-hidden">
|
||||
<AgentGraph
|
||||
agents={graphAgents}
|
||||
selectedAgentId={selectedId}
|
||||
onSelectAgent={(id) => setSelectedId(id)}
|
||||
eventsLoaded
|
||||
eventsEmpty={graphAgents.size === 0}
|
||||
scanCompleted={run.finished}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Live steering: only in-process while the scan runs. Otherwise omitted. */}
|
||||
{steerable && <ScanPromptComposer agents={agents} />}
|
||||
|
||||
{/* Re-run always routes to Strix Cloud. */}
|
||||
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5">
|
||||
<p className="text-sm font-semibold text-white">Run this scan with more depth</p>
|
||||
<p className="mt-0.5 text-xs text-[#666]">Re-run this scan on managed infra in the cloud.</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2.5">
|
||||
<ProInlineCta
|
||||
label="Re-run in Strix Cloud with more depth"
|
||||
desc="Run this scan on managed infra with more depth."
|
||||
slug="live_scan"
|
||||
surface="agents"
|
||||
icon={Rocket}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedAgent && (
|
||||
<AgentDetailModal
|
||||
agent={selectedAgent}
|
||||
events={events}
|
||||
steerable={steerable}
|
||||
onClose={() => setSelectedId(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// Extracted ProviderIcon from strix-app's AddRepositoryDialog. The dialog itself
|
||||
// (and its next/link dependency) is dropped; the IssueSidebar only needs this SVG
|
||||
// switch to badge a finding's source-control provider. Web-app targets resolve to
|
||||
// provider === null and never reach here (they render a globe icon instead).
|
||||
import { Github, Gitlab } from "lucide-react";
|
||||
|
||||
function BitbucketIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" className={className} aria-hidden="true">
|
||||
<path d="M2.65 3a.72.72 0 0 0-.72.83l2.86 17.39a.98.98 0 0 0 .96.82h13.72a.72.72 0 0 0 .72-.6l2.86-17.4A.72.72 0 0 0 22.3 3H2.65Zm12.1 12.53H9.3L8.06 8.9h7.8l-1.11 6.63Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProviderIcon({ provider, className }: { provider: string; className?: string }) {
|
||||
const cls = className ?? "w-4 h-4";
|
||||
if (provider === "gitlab") return <Gitlab className={`${cls} text-orange-400`} />;
|
||||
if (provider === "bitbucket") return <BitbucketIcon className={`${cls} text-blue-400`} />;
|
||||
return <Github className={`${cls} text-white`} />;
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Mail, ShieldCheck, Lock, Copy, Check, Loader2, AlertCircle, ArrowLeft } from "lucide-react";
|
||||
import {
|
||||
otpStart,
|
||||
otpVerify,
|
||||
sendReport,
|
||||
type AuthStatus,
|
||||
} from "@/data/serverSource";
|
||||
import { track } from "@/lib/cta";
|
||||
|
||||
/**
|
||||
* The email-report / email-verification flow rendered as its own page (not a
|
||||
* modal, so it never floats over another surface). Report mode ends in the
|
||||
* one-time password panel; verify mode just confirms the email and returns to
|
||||
* the caller. The page unmounts when you navigate away, so state resets each
|
||||
* time it is opened.
|
||||
*/
|
||||
|
||||
type Step = "disclosure" | "email" | "code" | "sending" | "password";
|
||||
|
||||
interface EmailReportViewProps {
|
||||
activeRun: string | null;
|
||||
auth: AuthStatus | null;
|
||||
purpose: "report" | "verify";
|
||||
/**
|
||||
* Skip the report disclosure and start the flow directly (used by the
|
||||
* Overview CTA, which already states the tradeoff). Unverified users land on
|
||||
* the email step; already-verified users send immediately.
|
||||
*/
|
||||
skipDisclosure?: boolean;
|
||||
/** Refresh auth + runs after a successful verify (lifts state to App). */
|
||||
onAuthChanged: () => void;
|
||||
/** Leave this page (report "Done" -> overview; verify success -> history). */
|
||||
onExit: (dest: "overview" | "history") => void;
|
||||
}
|
||||
|
||||
const OTP_START_ERRORS: Record<string, string> = {
|
||||
work_email_required: "Please use your work email, not a personal one.",
|
||||
rate_limited: "Too many requests. Wait a minute and try again.",
|
||||
invalid_email: "That email does not look right. Check it and try again.",
|
||||
unavailable: "The email service is unavailable right now. Try again shortly.",
|
||||
};
|
||||
|
||||
const SEND_ERRORS: Record<string, string> = {
|
||||
forbidden: "This email was unsubscribed from Strix, so we cannot send to it.",
|
||||
too_large: "This report is too large to email. Try a smaller run.",
|
||||
unavailable: "The email service is unavailable right now. Try again shortly.",
|
||||
};
|
||||
|
||||
// A small set of common personal providers for instant client-side feedback.
|
||||
// The relay is authoritative (it checks the full free-email-domains list).
|
||||
const COMMON_FREE_DOMAINS = new Set([
|
||||
"gmail.com", "googlemail.com", "yahoo.com", "ymail.com", "outlook.com",
|
||||
"hotmail.com", "live.com", "icloud.com", "me.com", "aol.com", "proton.me",
|
||||
"protonmail.com", "gmx.com", "mail.com",
|
||||
]);
|
||||
|
||||
export default function EmailReportView({
|
||||
activeRun,
|
||||
auth,
|
||||
purpose,
|
||||
skipDisclosure = false,
|
||||
onAuthChanged,
|
||||
onExit,
|
||||
}: EmailReportViewProps) {
|
||||
const verified = auth?.verified === true;
|
||||
const verifyOnly = purpose === "verify";
|
||||
// Verify mode (and the Overview CTA, which skips the disclosure) start on the
|
||||
// email step; a verified user who skips the disclosure sends immediately.
|
||||
const [step, setStep] = useState<Step>(() => {
|
||||
if (verifyOnly) return "email";
|
||||
if (skipDisclosure) return verified ? "sending" : "email";
|
||||
return "disclosure";
|
||||
});
|
||||
const [email, setEmail] = useState(auth?.email ?? "");
|
||||
const [code, setCode] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [password, setPassword] = useState("");
|
||||
const [filename, setFilename] = useState("");
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [sentTo, setSentTo] = useState("");
|
||||
const autoSentRef = useRef(false);
|
||||
|
||||
const doSend = async () => {
|
||||
setStep("sending");
|
||||
setError(null);
|
||||
const result = await sendReport(activeRun);
|
||||
if (result.ok) {
|
||||
track("report_sent");
|
||||
setPassword(result.password);
|
||||
setFilename(result.filename);
|
||||
setStep("password");
|
||||
return;
|
||||
}
|
||||
if (result.error === "reverify" || result.error === "unverified") {
|
||||
setNotice("Your verification expired. Enter your email to verify again.");
|
||||
setStep("email");
|
||||
return;
|
||||
}
|
||||
setError(SEND_ERRORS[result.error] ?? "Could not send the report. Try again.");
|
||||
setStep("disclosure");
|
||||
};
|
||||
|
||||
const startFlow = () => {
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
if (verified) void doSend();
|
||||
else setStep("email");
|
||||
};
|
||||
|
||||
// A verified user who skipped the disclosure (Overview CTA) sends on arrival.
|
||||
useEffect(() => {
|
||||
if (!verifyOnly && skipDisclosure && verified && !autoSentRef.current) {
|
||||
autoSentRef.current = true;
|
||||
void doSend();
|
||||
}
|
||||
// Run once on mount; the page remounts fresh each time it is opened.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const submitEmail = async () => {
|
||||
const value = email.trim();
|
||||
if (!value) {
|
||||
setError("Enter your email to continue.");
|
||||
return;
|
||||
}
|
||||
const domain = value.slice(value.lastIndexOf("@") + 1).toLowerCase();
|
||||
if (COMMON_FREE_DOMAINS.has(domain)) {
|
||||
track("work_email_required");
|
||||
setError(OTP_START_ERRORS.work_email_required);
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
const result = await otpStart(value);
|
||||
setBusy(false);
|
||||
if (result.ok) {
|
||||
track("email_submitted", { purpose });
|
||||
setNotice(`We sent a 6-digit code to ${value}.`);
|
||||
setStep("code");
|
||||
} else {
|
||||
if (result.error === "work_email_required") track("work_email_required");
|
||||
setError(OTP_START_ERRORS[result.error] ?? "Could not send a code. Try again.");
|
||||
}
|
||||
};
|
||||
|
||||
const submitCode = async () => {
|
||||
const value = code.trim();
|
||||
if (value.length < 4) {
|
||||
setError("Enter the 6-digit code from your email.");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
const result = await otpVerify(email.trim(), value);
|
||||
setBusy(false);
|
||||
if (!result.verified) {
|
||||
setError("That code did not match. Check it and try again.");
|
||||
return;
|
||||
}
|
||||
track("email_verified", { purpose });
|
||||
setSentTo(result.email);
|
||||
onAuthChanged();
|
||||
if (verifyOnly) onExit("history");
|
||||
else void doSend();
|
||||
};
|
||||
|
||||
const copyPassword = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(password);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
/* clipboard may be unavailable; the password is visible to copy manually */
|
||||
}
|
||||
};
|
||||
|
||||
const confirmationEmail = sentTo || auth?.email || email.trim();
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-md space-y-4">
|
||||
<button
|
||||
onClick={() => onExit(verifyOnly ? "history" : "overview")}
|
||||
className="cursor-pointer inline-flex items-center gap-1.5 text-sm text-[#888] transition-colors hover:text-white"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
{verifyOnly ? "Back to past runs" : "Back to results"}
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Mail className="h-5 w-5 text-[#888]" aria-hidden="true" />
|
||||
<h1 className="text-2xl font-semibold text-white">
|
||||
{verifyOnly ? "Verify your email" : "Email report"}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="w-full rounded-2xl bg-[rgba(255,255,255,0.02)] p-6"
|
||||
style={{ border: "1px solid #2a2a2a" }}
|
||||
>
|
||||
<p className="mb-4 text-xs text-[#666]">
|
||||
{verifyOnly
|
||||
? "We send a one-time code to confirm it is you."
|
||||
: "Verified by a one-time code sent to your email"}
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 flex items-start gap-2 rounded-lg border border-red-500/30 bg-red-500/5 px-3 py-2">
|
||||
<AlertCircle className="mt-0.5 h-4 w-4 flex-shrink-0 text-red-400" aria-hidden="true" />
|
||||
<p className="text-xs text-red-300">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
{notice && !error && step !== "password" && (
|
||||
<p className="mb-4 text-xs text-[#888]">{notice}</p>
|
||||
)}
|
||||
|
||||
{step === "disclosure" && (
|
||||
<div className="space-y-4">
|
||||
<div
|
||||
className="space-y-2.5 rounded-lg p-3.5"
|
||||
style={{ border: "1px solid #222", background: "rgba(255,255,255,0.02)" }}
|
||||
>
|
||||
<div className="flex items-start gap-2.5">
|
||||
<ShieldCheck className="mt-0.5 h-4 w-4 flex-shrink-0 text-emerald-400" aria-hidden="true" />
|
||||
<p className="text-xs leading-relaxed text-[#aaa]">
|
||||
Viewing stays local and nothing is uploaded. Emailing is an explicit
|
||||
opt-in: we send an <span className="text-white">encrypted PDF</span>.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-start gap-2.5">
|
||||
<Lock className="mt-0.5 h-4 w-4 flex-shrink-0 text-[#888]" aria-hidden="true" />
|
||||
<p className="text-xs leading-relaxed text-[#aaa]">
|
||||
The report is encrypted with a password that only you hold. Strix
|
||||
cannot read it and never stores it. We collect only your email so we
|
||||
can send it.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={startFlow}
|
||||
className="w-full cursor-pointer rounded-lg bg-white px-4 py-2.5 text-sm font-semibold text-black transition-opacity hover:opacity-90"
|
||||
>
|
||||
{verified ? "Email me the encrypted PDF" : "Continue with your email"}
|
||||
</button>
|
||||
{verified && auth?.email && (
|
||||
<p className="text-center text-xs text-[#666]">Sending to {auth.email}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "email" && (
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void submitEmail();
|
||||
}}
|
||||
>
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-xs text-[#888]">Your work email</span>
|
||||
<input
|
||||
type="email"
|
||||
autoFocus
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="[email protected]"
|
||||
className="w-full rounded-lg bg-black px-3 py-2.5 text-sm text-white outline-none transition-colors focus:border-[#444]"
|
||||
style={{ border: "1px solid #2a2a2a" }}
|
||||
/>
|
||||
<span className="mt-1.5 block text-[11px] text-[#666]">Use your work email.</span>
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy}
|
||||
className="flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-white px-4 py-2.5 text-sm font-semibold text-black transition-opacity hover:opacity-90 disabled:opacity-60"
|
||||
>
|
||||
{busy && <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />}
|
||||
Send me a code
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{step === "code" && (
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void submitCode();
|
||||
}}
|
||||
>
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-xs text-[#888]">6-digit code</span>
|
||||
<input
|
||||
inputMode="numeric"
|
||||
autoFocus
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.replace(/\D/g, "").slice(0, 6))}
|
||||
placeholder="123456"
|
||||
className="w-full rounded-lg bg-black px-3 py-2.5 text-center text-lg font-mono tracking-[0.4em] text-white outline-none transition-colors focus:border-[#444]"
|
||||
style={{ border: "1px solid #2a2a2a" }}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy}
|
||||
className="flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-white px-4 py-2.5 text-sm font-semibold text-black transition-opacity hover:opacity-90 disabled:opacity-60"
|
||||
>
|
||||
{busy && <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />}
|
||||
{verifyOnly ? "Verify" : "Verify and send"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setStep("email");
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
}}
|
||||
className="w-full cursor-pointer text-center text-xs text-[#666] transition-colors hover:text-[#aaa]"
|
||||
>
|
||||
Use a different email
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{step === "sending" && (
|
||||
<div className="flex flex-col items-center gap-3 py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-white" aria-hidden="true" />
|
||||
<p className="text-sm text-[#aaa]">Generating and encrypting locally...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "password" && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-2.5 rounded-lg border border-emerald-500/30 bg-emerald-500/5 px-3 py-2.5">
|
||||
<Check className="mt-0.5 h-4 w-4 flex-shrink-0 text-emerald-400" aria-hidden="true" />
|
||||
<p className="text-xs text-emerald-200">
|
||||
Sent to {confirmationEmail}. Open the attached PDF with this password.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="mb-1.5 block text-xs text-[#888]">Your one-time password</span>
|
||||
<div
|
||||
className="flex items-center gap-2 rounded-lg bg-black p-3"
|
||||
style={{ border: "1px solid #2a2a2a" }}
|
||||
>
|
||||
<code className="flex-1 break-all font-mono text-base text-white">{password}</code>
|
||||
<button
|
||||
onClick={copyPassword}
|
||||
className="flex cursor-pointer items-center gap-1 rounded-md px-2 py-1 text-xs text-[#aaa] transition-colors hover:bg-[rgba(255,255,255,0.06)] hover:text-white"
|
||||
style={{ border: "1px solid #2a2a2a" }}
|
||||
>
|
||||
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
|
||||
{copied ? "Copied" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-[#666]">
|
||||
Save this now. Strix never stores it, so we cannot show it again. File:{" "}
|
||||
<span className="font-mono text-[#888]">{filename}</span>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onExit("overview")}
|
||||
className="w-full cursor-pointer rounded-lg px-4 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-[rgba(255,255,255,0.06)]"
|
||||
style={{ border: "1px solid #2a2a2a" }}
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { useState } from "react";
|
||||
import { Loader2, AlertCircle } from "lucide-react";
|
||||
import { otpStart, otpVerify } from "@/data/serverSource";
|
||||
import { track } from "@/lib/cta";
|
||||
|
||||
/**
|
||||
* Compact inline email -> 6-digit-code verify flow. Unlike EmailReportView this
|
||||
* has no page chrome, no report send, and no password panel: it just confirms
|
||||
* the email so the past-runs list can unlock in place. On success it calls
|
||||
* `onVerified` (the parent refreshes auth + runs).
|
||||
*/
|
||||
|
||||
const OTP_START_ERRORS: Record<string, string> = {
|
||||
work_email_required: "Please use your work email, not a personal one.",
|
||||
rate_limited: "Too many requests. Wait a minute and try again.",
|
||||
invalid_email: "That email does not look right. Check it and try again.",
|
||||
unavailable: "The email service is unavailable right now. Try again shortly.",
|
||||
};
|
||||
|
||||
// A small set of common personal providers for instant client-side feedback.
|
||||
// The relay is authoritative (it checks the full free-email-domains list).
|
||||
const COMMON_FREE_DOMAINS = new Set([
|
||||
"gmail.com", "googlemail.com", "yahoo.com", "ymail.com", "outlook.com",
|
||||
"hotmail.com", "live.com", "icloud.com", "me.com", "aol.com", "proton.me",
|
||||
"protonmail.com", "gmx.com", "mail.com",
|
||||
]);
|
||||
|
||||
export default function EmailVerifyInline({ onVerified }: { onVerified: () => void }) {
|
||||
const [step, setStep] = useState<"email" | "code">("email");
|
||||
const [email, setEmail] = useState("");
|
||||
const [code, setCode] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
|
||||
const submitEmail = async () => {
|
||||
const value = email.trim();
|
||||
if (!value) {
|
||||
setError("Enter your email to continue.");
|
||||
return;
|
||||
}
|
||||
const domain = value.slice(value.lastIndexOf("@") + 1).toLowerCase();
|
||||
if (COMMON_FREE_DOMAINS.has(domain)) {
|
||||
track("work_email_required");
|
||||
setError(OTP_START_ERRORS.work_email_required);
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
const result = await otpStart(value);
|
||||
setBusy(false);
|
||||
if (result.ok) {
|
||||
track("email_submitted", { purpose: "verify" });
|
||||
setNotice(`We sent a 6-digit code to ${value}.`);
|
||||
setStep("code");
|
||||
} else {
|
||||
if (result.error === "work_email_required") track("work_email_required");
|
||||
setError(OTP_START_ERRORS[result.error] ?? "Could not send a code. Try again.");
|
||||
}
|
||||
};
|
||||
|
||||
const submitCode = async () => {
|
||||
const value = code.trim();
|
||||
if (value.length < 4) {
|
||||
setError("Enter the 6-digit code from your email.");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
const result = await otpVerify(email.trim(), value);
|
||||
setBusy(false);
|
||||
if (!result.verified) {
|
||||
setError("That code did not match. Check it and try again.");
|
||||
return;
|
||||
}
|
||||
track("email_verified", { purpose: "verify" });
|
||||
onVerified();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto mt-5 max-w-sm text-left">
|
||||
{error && (
|
||||
<div className="mb-3 flex items-start gap-2 rounded-lg border border-red-500/30 bg-red-500/5 px-3 py-2">
|
||||
<AlertCircle className="mt-0.5 h-4 w-4 flex-shrink-0 text-red-400" aria-hidden="true" />
|
||||
<p className="text-xs text-red-300">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
{notice && !error && <p className="mb-3 text-xs text-[#888]">{notice}</p>}
|
||||
|
||||
{step === "email" ? (
|
||||
<form
|
||||
className="space-y-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void submitEmail();
|
||||
}}
|
||||
>
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-xs text-[#888]">Your work email</span>
|
||||
<input
|
||||
type="email"
|
||||
autoFocus
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="[email protected]"
|
||||
className="w-full rounded-lg bg-black px-3 py-2.5 text-sm text-white outline-none transition-colors focus:border-[#444]"
|
||||
style={{ border: "1px solid #2a2a2a" }}
|
||||
/>
|
||||
<span className="mt-1.5 block text-[11px] text-[#666]">Use your work email.</span>
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy}
|
||||
className="flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-white px-4 py-2.5 text-sm font-semibold text-black transition-opacity hover:opacity-90 disabled:opacity-60"
|
||||
>
|
||||
{busy && <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />}
|
||||
Send me a code
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<form
|
||||
className="space-y-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void submitCode();
|
||||
}}
|
||||
>
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-xs text-[#888]">6-digit code</span>
|
||||
<input
|
||||
inputMode="numeric"
|
||||
autoFocus
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.replace(/\D/g, "").slice(0, 6))}
|
||||
placeholder="123456"
|
||||
className="w-full rounded-lg bg-black px-3 py-2.5 text-center text-lg font-mono tracking-[0.4em] text-white outline-none transition-colors focus:border-[#444]"
|
||||
style={{ border: "1px solid #2a2a2a" }}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy}
|
||||
className="flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-white px-4 py-2.5 text-sm font-semibold text-black transition-opacity hover:opacity-90 disabled:opacity-60"
|
||||
>
|
||||
{busy && <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />}
|
||||
Verify
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setStep("email");
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
}}
|
||||
className="w-full cursor-pointer text-center text-xs text-[#666] transition-colors hover:text-[#aaa]"
|
||||
>
|
||||
Use a different email
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import {
|
||||
CalendarClock,
|
||||
WandSparkles,
|
||||
Puzzle,
|
||||
Users,
|
||||
ArrowUpRight,
|
||||
} from "lucide-react";
|
||||
import { SIGNUP_URL, PRICING_URL, ctaUrl, trackCta } from "@/lib/cta";
|
||||
import type { ProFeature } from "@/lib/pro-features";
|
||||
import { ProTag } from "@/components/ProCta";
|
||||
|
||||
/**
|
||||
* In-app upsell page for a single platform feature. Modeled on the cloud app's
|
||||
* Networks upsell: a centered bordered card with an icon medallion, tier pill,
|
||||
* headline, one-line description, a shared "Included in Strix Pro" bullet list,
|
||||
* then a primary sign-up CTA and a secondary link to all plans.
|
||||
*/
|
||||
|
||||
const INCLUDED = [
|
||||
{
|
||||
icon: CalendarClock,
|
||||
text: "Continuous coverage: scheduled pentests and attack surface monitoring",
|
||||
},
|
||||
{ icon: WandSparkles, text: "One-click autofix that opens a retested pull request" },
|
||||
{ icon: Puzzle, text: "Two-way sync to Jira, Linear, and Slack" },
|
||||
{ icon: Users, text: "Your whole team, with roles and shared history" },
|
||||
];
|
||||
|
||||
export default function FeatureDetail({ feature }: { feature: ProFeature }) {
|
||||
const Icon = feature.icon;
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-lg">
|
||||
<div className="rounded-2xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-8 text-center">
|
||||
<div
|
||||
className="mx-auto flex h-12 w-12 items-center justify-center rounded-xl"
|
||||
style={{ border: "1px solid #2a2a2a", background: "rgba(255,255,255,0.04)" }}
|
||||
>
|
||||
<Icon className="h-5 w-5 text-[#888]" aria-hidden="true" />
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-center">
|
||||
<ProTag label={feature.tier} />
|
||||
</div>
|
||||
|
||||
<h2 className="mt-3 text-2xl font-semibold text-white">{feature.headline}</h2>
|
||||
<p className="mx-auto mt-2 max-w-md text-sm text-[#888]">{feature.description}</p>
|
||||
|
||||
<div
|
||||
className="mt-6 rounded-xl p-4 text-left"
|
||||
style={{ border: "1px solid #222", background: "rgba(255,255,255,0.02)" }}
|
||||
>
|
||||
<p className="mb-3 text-xs font-semibold uppercase tracking-wide text-[#666]">
|
||||
Included in Strix Pro
|
||||
</p>
|
||||
<ul className="space-y-2.5">
|
||||
{INCLUDED.map((item) => {
|
||||
const BulletIcon = item.icon;
|
||||
return (
|
||||
<li key={item.text} className="flex items-start gap-2.5">
|
||||
<BulletIcon
|
||||
className="mt-0.5 h-4 w-4 flex-shrink-0 text-[#888]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="text-sm text-[#aaa]">{item.text}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex flex-col items-center gap-3">
|
||||
<a
|
||||
href={ctaUrl(SIGNUP_URL, feature.slug)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => trackCta(feature.slug, "feature_page")}
|
||||
className="inline-flex w-full items-center justify-center gap-1.5 rounded-lg bg-white px-4 py-2.5 text-sm font-semibold text-black transition-opacity hover:opacity-90"
|
||||
>
|
||||
Start free
|
||||
<ArrowUpRight className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</a>
|
||||
<a
|
||||
href={ctaUrl(PRICING_URL, feature.slug)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => trackCta(feature.slug, "feature_page_plans")}
|
||||
className="inline-flex items-center gap-1 text-xs text-[#888] transition-colors hover:text-white"
|
||||
>
|
||||
View all plans
|
||||
<ArrowUpRight className="h-3 w-3" aria-hidden="true" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface IssueSeveritySummaryFindings {
|
||||
total: number;
|
||||
critical: number;
|
||||
high: number;
|
||||
medium: number;
|
||||
low: number;
|
||||
}
|
||||
|
||||
interface IssueSeveritySummaryProps {
|
||||
findings: IssueSeveritySummaryFindings;
|
||||
className?: string;
|
||||
/** Noun for the total count (e.g. "issues", "CVEs"). Defaults to "issues". */
|
||||
unit?: string;
|
||||
/** Optional content rendered at the end of the count row (e.g. a KEV badge). */
|
||||
trailing?: React.ReactNode;
|
||||
}
|
||||
|
||||
const SEVERITIES = [
|
||||
{ key: "critical", label: "critical", dotClass: "bg-red-500", textClass: "text-red-500" },
|
||||
{ key: "high", label: "high", dotClass: "bg-orange-500", textClass: "text-orange-500" },
|
||||
{ key: "medium", label: "medium", dotClass: "bg-yellow-500", textClass: "text-yellow-500" },
|
||||
{ key: "low", label: "low", dotClass: "bg-blue-500", textClass: "text-blue-500" },
|
||||
] as const;
|
||||
|
||||
export function IssueSeveritySummary({
|
||||
findings,
|
||||
className,
|
||||
unit = "issues",
|
||||
trailing,
|
||||
}: IssueSeveritySummaryProps) {
|
||||
if (findings.total <= 0) return null;
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-3", className)}>
|
||||
<div className="flex flex-wrap items-center gap-x-8 gap-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-2xl font-semibold text-white tabular-nums">{findings.total}</span>
|
||||
<span className="text-sm text-[#666]">{unit}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-x-6 gap-y-2">
|
||||
{SEVERITIES.map(({ key, label, dotClass, textClass }) => {
|
||||
const count = findings[key];
|
||||
if (count <= 0) return null;
|
||||
|
||||
return (
|
||||
<div key={key} className="flex items-center gap-1.5">
|
||||
<div className={cn("w-2 h-2 rounded-full", dotClass)} aria-hidden="true" />
|
||||
<span className={cn("text-sm tabular-nums", textClass)}>{count}</span>
|
||||
<span className="text-xs text-[#555]">{label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{trailing ? <div className="flex items-center gap-2">{trailing}</div> : null}
|
||||
</div>
|
||||
|
||||
<div className="h-1.5 rounded-full bg-[#222] overflow-hidden flex">
|
||||
{SEVERITIES.map(({ key, dotClass }) => {
|
||||
const count = findings[key];
|
||||
if (count <= 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className={cn("h-full", dotClass)}
|
||||
style={{ width: `${(count / findings.total) * 100}%` }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { useState } from "react";
|
||||
import { History, ChevronRight, Terminal } from "lucide-react";
|
||||
import type { RunListEntry, RunsPayload, RunSeverityCounts } from "@/data/serverSource";
|
||||
import { runTitle } from "@/lib/target-utils";
|
||||
import { trackCta } from "@/lib/cta";
|
||||
import EmailVerifyInline from "@/components/EmailVerifyInline";
|
||||
|
||||
/**
|
||||
* "Past runs" panel. Unverified users see a tease with the run count and a
|
||||
* verify affordance (the launched run stays fully visible; the CLI
|
||||
* `strix view <name>` still works). Verified users get the full history and can
|
||||
* switch the active run, which threads ?run=<name> through the data fetches.
|
||||
*/
|
||||
|
||||
const SEV = [
|
||||
{ key: "critical", dot: "bg-red-500", text: "text-red-500" },
|
||||
{ key: "high", dot: "bg-orange-500", text: "text-orange-500" },
|
||||
{ key: "medium", dot: "bg-yellow-500", text: "text-yellow-500" },
|
||||
{ key: "low", dot: "bg-blue-500", text: "text-blue-500" },
|
||||
] as const;
|
||||
|
||||
function SeverityChips({ counts }: { counts: RunSeverityCounts }) {
|
||||
const shown = SEV.filter((s) => counts[s.key] > 0);
|
||||
if (shown.length === 0) {
|
||||
return <span className="text-xs text-[#555]">No findings</span>;
|
||||
}
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
{shown.map((s) => (
|
||||
<div key={s.key} className="flex items-center gap-1.5">
|
||||
<span className={`h-2 w-2 rounded-full ${s.dot}`} aria-hidden="true" />
|
||||
<span className={`text-xs tabular-nums ${s.text}`}>{counts[s.key]}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDate(iso: string | null): string | null {
|
||||
if (!iso) return null;
|
||||
const normalized = iso.trim().replace(" UTC", "Z").replace(" ", "T");
|
||||
const d = new Date(normalized);
|
||||
if (Number.isNaN(d.getTime())) return null;
|
||||
return d.toLocaleString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Relative time ("just now" / "5m ago" / "3h ago" / "2d ago"), falling back to
|
||||
* the absolute date for anything older than a week (mirrors the pro app).
|
||||
*/
|
||||
function formatTimeAgo(iso: string | null): string | null {
|
||||
if (!iso) return null;
|
||||
const normalized = iso.trim().replace(" UTC", "Z").replace(" ", "T");
|
||||
const d = new Date(normalized);
|
||||
if (Number.isNaN(d.getTime())) return null;
|
||||
const diffMs = Date.now() - d.getTime();
|
||||
const mins = Math.floor(diffMs / 60000);
|
||||
if (mins < 1) return "just now";
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hours = Math.floor(mins / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days < 7) return `${days}d ago`;
|
||||
return formatDate(iso);
|
||||
}
|
||||
|
||||
interface PastRunsViewProps {
|
||||
runs: RunsPayload | null;
|
||||
activeRun: string | null;
|
||||
onSelectRun: (name: string) => void;
|
||||
onVerified: () => void;
|
||||
}
|
||||
|
||||
export default function PastRunsView({
|
||||
runs,
|
||||
activeRun,
|
||||
onSelectRun,
|
||||
onVerified,
|
||||
}: PastRunsViewProps) {
|
||||
const count = runs?.count ?? 0;
|
||||
const [showVerify, setShowVerify] = useState(false);
|
||||
|
||||
if (!runs || runs.locked) {
|
||||
return (
|
||||
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-8 text-center">
|
||||
<div
|
||||
className="mx-auto mb-4 flex h-11 w-11 items-center justify-center rounded-xl"
|
||||
style={{ border: "1px solid #2a2a2a", background: "rgba(255,255,255,0.04)" }}
|
||||
>
|
||||
<History className="h-5 w-5 text-[#888]" aria-hidden="true" />
|
||||
</div>
|
||||
<h2 className="text-base font-semibold text-white">Browse every run on this machine</h2>
|
||||
<p className="mx-auto mt-1.5 max-w-md text-sm text-[#888]">
|
||||
You have {count} past {count === 1 ? "run" : "runs"} on this machine.
|
||||
</p>
|
||||
{showVerify ? (
|
||||
<>
|
||||
<p className="mx-auto mt-3 max-w-sm text-xs text-[#666]">
|
||||
Verify your email with a one-time code to unlock the full history.
|
||||
</p>
|
||||
<EmailVerifyInline onVerified={onVerified} />
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => {
|
||||
trackCta("history_unlock", "past_runs");
|
||||
setShowVerify(true);
|
||||
}}
|
||||
className="mt-4 cursor-pointer rounded-lg bg-white px-4 py-2 text-sm font-semibold text-black transition-opacity hover:opacity-90"
|
||||
>
|
||||
View runs
|
||||
</button>
|
||||
)}
|
||||
<p className="mt-4 flex items-center justify-center gap-1.5 text-xs text-[#555]">
|
||||
<Terminal className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
Or open one from the CLI with{" "}
|
||||
<code className="font-mono text-[#888]">strix view <name></code>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (runs.runs.length === 0) {
|
||||
return (
|
||||
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-8 text-center text-sm text-[#888]">
|
||||
No past runs found on this machine yet.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{runs.runs.map((run: RunListEntry) => {
|
||||
const active = run.name === activeRun;
|
||||
const date = formatTimeAgo(run.start_time) ?? formatTimeAgo(run.end_time);
|
||||
const title = runTitle(run.target, run.name);
|
||||
return (
|
||||
<button
|
||||
key={run.name}
|
||||
onClick={() => onSelectRun(run.name)}
|
||||
className={`group flex w-full cursor-pointer items-center gap-4 rounded-lg border px-4 py-3 text-left transition-colors ${
|
||||
active
|
||||
? "border-[#444] bg-[rgba(255,255,255,0.04)]"
|
||||
: "border-[#222] bg-[rgba(255,255,255,0.02)] hover:border-[#444]"
|
||||
}`}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate text-sm font-medium text-white">{title}</span>
|
||||
{active && (
|
||||
<span className="rounded-full px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-emerald-400" style={{ border: "1px solid rgba(16,185,129,0.3)" }}>
|
||||
Active
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-[#666]">
|
||||
{run.scan_mode && <span className="capitalize">{run.scan_mode}</span>}
|
||||
{run.scan_mode && (date || run.status) && <span className="text-[#333]">·</span>}
|
||||
{date && <span>{date}</span>}
|
||||
{date && run.status && <span className="text-[#333]">·</span>}
|
||||
{run.status && <span className="capitalize">{run.status}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<SeverityChips counts={run.severity_counts} />
|
||||
<ChevronRight className="h-4 w-4 flex-shrink-0 text-[#555] transition-colors group-hover:text-[#aaa]" aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import React, { useState } from "react";
|
||||
import { ArrowUpRight } from "lucide-react";
|
||||
import { SIGNUP_URL, ctaUrl, trackCta } from "@/lib/cta";
|
||||
import type { ProFeature } from "@/lib/pro-features";
|
||||
|
||||
/**
|
||||
* Shared Pro CTA primitives. Every Pro item is a direct link-out to the cloud
|
||||
* sign-up in a new tab with a hover tooltip one-liner (no modal, no lock icon).
|
||||
* Built once here and reused by the sidebar Platform section, the top upsell
|
||||
* row, and the inline CTAs in the tabs.
|
||||
*/
|
||||
|
||||
/** Small tier pill ("Pro" or "Enterprise"). Deliberately not a padlock. */
|
||||
export function ProTag({ label = "Pro", className = "" }: { label?: string; className?: string }) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-[#aaa] ${className}`}
|
||||
style={{ border: "1px solid #2a2a2a", background: "rgba(255,255,255,0.04)" }}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight hover tooltip. Wraps a trigger and reveals `text` above it on
|
||||
* hover/focus. Plain Tailwind + local state (no radix vendored).
|
||||
*/
|
||||
export function Tooltip({
|
||||
text,
|
||||
children,
|
||||
className = "",
|
||||
}: {
|
||||
text: string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<span
|
||||
className={`relative inline-flex ${className}`}
|
||||
onMouseEnter={() => setOpen(true)}
|
||||
onMouseLeave={() => setOpen(false)}
|
||||
onFocus={() => setOpen(true)}
|
||||
onBlur={() => setOpen(false)}
|
||||
>
|
||||
{children}
|
||||
{open && (
|
||||
<span
|
||||
role="tooltip"
|
||||
className="pointer-events-none absolute bottom-full left-1/2 z-50 mb-2 w-max max-w-[240px] -translate-x-1/2 rounded-md px-2.5 py-1.5 text-xs text-[#ddd] shadow-lg"
|
||||
style={{ border: "1px solid #2a2a2a", background: "#0a0a0a" }}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export interface ProItem {
|
||||
title: string;
|
||||
desc: string;
|
||||
slug: string;
|
||||
icon: React.ElementType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Card-style Pro feature tile: icon + name + one-liner + Pro tag + arrow.
|
||||
* Used in the top upsell row and inline CTA grids.
|
||||
*/
|
||||
export function ProTile({ item, surface }: { item: ProItem; surface?: string }) {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<a
|
||||
href={ctaUrl(SIGNUP_URL, item.slug)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => trackCta(item.slug, surface)}
|
||||
title={item.desc}
|
||||
className="group block cursor-pointer rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-4 text-left transition-colors hover:border-[#444]"
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<Icon className="h-4 w-4 text-[#888] transition-colors group-hover:text-white" aria-hidden="true" />
|
||||
<div className="flex items-center gap-1.5">
|
||||
<ProTag />
|
||||
<ArrowUpRight className="h-3.5 w-3.5 text-[#555] transition-colors group-hover:text-[#aaa]" aria-hidden="true" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-white">{item.title}</p>
|
||||
<p className="mt-0.5 text-xs text-[#666]">{item.desc}</p>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sidebar-row Pro item: a two-line row (icon + label + short one-liner
|
||||
* underneath) with a small right-aligned tier tag. Opens the in-app
|
||||
* FeatureDetail view via onClick (no link-out) so it sits uniformly beside the
|
||||
* run/local rows in the themed nav list.
|
||||
*/
|
||||
export function ProNavItem({
|
||||
feature,
|
||||
active,
|
||||
onClick,
|
||||
collapsed = false,
|
||||
}: {
|
||||
feature: ProFeature;
|
||||
active?: boolean;
|
||||
onClick: () => void;
|
||||
collapsed?: boolean;
|
||||
}) {
|
||||
const Icon = feature.icon;
|
||||
if (collapsed) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
title={`${feature.title} (${feature.tier})`}
|
||||
className={`group flex w-full cursor-pointer items-center justify-center rounded-md px-2.5 py-2 transition-colors ${
|
||||
active
|
||||
? "text-white"
|
||||
: "text-[#888] hover:bg-[rgba(255,255,255,0.06)] hover:text-white"
|
||||
}`}
|
||||
style={active ? { background: "rgba(255,255,255,0.12)" } : undefined}
|
||||
>
|
||||
<Icon className="h-4 w-4 flex-shrink-0" aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`group flex w-full cursor-pointer items-start gap-2.5 rounded-md px-2.5 py-1.5 text-left transition-colors ${
|
||||
active
|
||||
? "text-white"
|
||||
: "text-[#888] hover:bg-[rgba(255,255,255,0.06)] hover:text-white"
|
||||
}`}
|
||||
style={active ? { background: "rgba(255,255,255,0.12)" } : undefined}
|
||||
>
|
||||
<Icon className="mt-0.5 h-4 w-4 flex-shrink-0" aria-hidden="true" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="flex-1 truncate text-sm">{feature.title}</span>
|
||||
<ProTag label={feature.tier} />
|
||||
</span>
|
||||
<span className="mt-0.5 block text-[11px] leading-snug text-[#666]">{feature.navDesc}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline Pro CTA button (compact). Used in the finding detail and per-surface
|
||||
* rows where a full card is too heavy.
|
||||
*/
|
||||
export function ProInlineCta({
|
||||
label,
|
||||
desc,
|
||||
slug,
|
||||
icon: Icon,
|
||||
surface,
|
||||
}: {
|
||||
label: string;
|
||||
desc: string;
|
||||
slug: string;
|
||||
icon: React.ElementType;
|
||||
surface?: string;
|
||||
}) {
|
||||
return (
|
||||
<Tooltip text={desc}>
|
||||
<a
|
||||
href={ctaUrl(SIGNUP_URL, slug)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => trackCta(slug, surface)}
|
||||
className="group inline-flex items-center gap-2 rounded-lg border border-[#222] bg-[rgba(255,255,255,0.02)] px-3 py-2 text-sm text-[#aaa] transition-colors hover:border-[#444] hover:text-white"
|
||||
>
|
||||
<Icon className="h-4 w-4 text-[#888] transition-colors group-hover:text-white" aria-hidden="true" />
|
||||
<span>{label}</span>
|
||||
<ProTag className="ml-0.5" />
|
||||
</a>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import {
|
||||
FileText,
|
||||
Bug,
|
||||
Waypoints,
|
||||
History,
|
||||
Mail,
|
||||
ArrowUpRight,
|
||||
LogOut,
|
||||
ShieldCheck,
|
||||
PanelLeftClose,
|
||||
PanelLeft,
|
||||
} from "lucide-react";
|
||||
import { SIGNUP_URL, ctaUrl, trackCta } from "@/lib/cta";
|
||||
import { ProNavItem } from "@/components/ProCta";
|
||||
import { FEATURES, PLATFORM_ORDER } from "@/lib/pro-features";
|
||||
import type { View } from "@/App";
|
||||
|
||||
/**
|
||||
* Persistent left rail. A single, ungrouped, ordered list of uniform two-line
|
||||
* rows (icon + label + short one-liner): the current run's views, the local
|
||||
* run-history + email-report actions, then the platform features. No section
|
||||
* headers. Tier is shown only by the inline Pro/Enterprise tag on platform
|
||||
* rows. Matches App.tsx's dark palette.
|
||||
*
|
||||
* Can collapse to a narrow icon-only rail; the collapsed state persists in
|
||||
* localStorage and each icon row keeps a `title` tooltip so the labels stay
|
||||
* discoverable.
|
||||
*/
|
||||
|
||||
const COLLAPSE_KEY = "strix_viewer_sidebar_collapsed";
|
||||
|
||||
interface SidebarProps {
|
||||
view: View;
|
||||
onSelectView: (view: View) => void;
|
||||
activeFeature: string | null;
|
||||
onSelectFeature: (slug: string) => void;
|
||||
issuesCount: number;
|
||||
agentCount: number;
|
||||
runCount: number;
|
||||
verified: boolean;
|
||||
email: string | null;
|
||||
onOpenEmail: () => void;
|
||||
onOpenHistory: () => void;
|
||||
onForget: () => void;
|
||||
}
|
||||
|
||||
export default function Sidebar({
|
||||
view,
|
||||
onSelectView,
|
||||
activeFeature,
|
||||
onSelectFeature,
|
||||
issuesCount,
|
||||
agentCount,
|
||||
runCount,
|
||||
verified,
|
||||
email,
|
||||
onOpenEmail,
|
||||
onOpenHistory,
|
||||
onForget,
|
||||
}: SidebarProps) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
setCollapsed(localStorage.getItem(COLLAPSE_KEY) === "1");
|
||||
} catch {
|
||||
/* localStorage may be unavailable; default to expanded */
|
||||
}
|
||||
}, []);
|
||||
|
||||
const toggleCollapsed = () => {
|
||||
setCollapsed((prev) => {
|
||||
const next = !prev;
|
||||
try {
|
||||
localStorage.setItem(COLLAPSE_KEY, next ? "1" : "0");
|
||||
} catch {
|
||||
/* best-effort persistence */
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={`hidden flex-shrink-0 border-r border-[#222] lg:block ${collapsed ? "w-14" : "w-72"}`}
|
||||
>
|
||||
<div className="sticky top-0 flex h-screen flex-col overflow-y-auto px-3 py-4">
|
||||
{/* Header: wordmark + Explore full platform + signed-in chip */}
|
||||
<div className="px-1.5">
|
||||
<div className={`flex items-center ${collapsed ? "flex-col gap-2" : "justify-between"}`}>
|
||||
<a
|
||||
href={ctaUrl("https://app.strix.ai", "logo")}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => trackCta("logo", "sidebar")}
|
||||
className="flex items-center gap-1.5 opacity-90 transition-opacity hover:opacity-100"
|
||||
title="Open Strix Cloud"
|
||||
>
|
||||
<img src="./logo.png" alt="Strix" className="h-8 w-10 object-cover" />
|
||||
{!collapsed && (
|
||||
<span className="text-base font-medium tracking-tight text-white">Strix</span>
|
||||
)}
|
||||
</a>
|
||||
<button
|
||||
onClick={toggleCollapsed}
|
||||
title={collapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||
aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||
className="flex-shrink-0 cursor-pointer rounded-md p-1.5 text-[#666] transition-colors hover:bg-[rgba(255,255,255,0.06)] hover:text-white"
|
||||
>
|
||||
{collapsed ? (
|
||||
<PanelLeft className="h-4 w-4" aria-hidden="true" />
|
||||
) : (
|
||||
<PanelLeftClose className="h-4 w-4" aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<a
|
||||
href={ctaUrl(SIGNUP_URL, "sidebar_start_free")}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => trackCta("sidebar_start_free", "sidebar")}
|
||||
title="Explore full platform"
|
||||
className={`mt-3 flex w-full cursor-pointer items-center justify-center gap-1.5 rounded-lg bg-white font-semibold text-black transition-opacity hover:opacity-90 ${
|
||||
collapsed ? "px-0 py-2" : "px-3 py-2 text-sm"
|
||||
}`}
|
||||
>
|
||||
{!collapsed && "Explore full platform"}
|
||||
<ArrowUpRight className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</a>
|
||||
{verified && email && (
|
||||
collapsed ? (
|
||||
<div
|
||||
className="mt-2.5 flex items-center justify-center rounded-lg py-2"
|
||||
style={{ border: "1px solid #222", background: "rgba(255,255,255,0.02)" }}
|
||||
title={`Linked email: ${email}`}
|
||||
>
|
||||
<ShieldCheck className="h-3.5 w-3.5 flex-shrink-0 text-emerald-400" aria-hidden="true" />
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="mt-2.5 flex items-center gap-2 rounded-lg px-2.5 py-2"
|
||||
style={{ border: "1px solid #222", background: "rgba(255,255,255,0.02)" }}
|
||||
>
|
||||
<ShieldCheck className="h-3.5 w-3.5 flex-shrink-0 text-emerald-400" aria-hidden="true" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-[11px] text-[#666]">Linked email</p>
|
||||
<p className="truncate text-xs text-[#aaa]" title={email}>{email}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onForget}
|
||||
title="Forget this email on this machine"
|
||||
className="flex-shrink-0 cursor-pointer text-[#666] transition-colors hover:text-white"
|
||||
aria-label="Forget"
|
||||
>
|
||||
<LogOut className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* One single ordered list, no section headers. */}
|
||||
<div className="mt-6 space-y-0.5">
|
||||
<NavItem
|
||||
icon={FileText}
|
||||
label="Overview"
|
||||
desc="This run's executive report"
|
||||
active={view === "overview"}
|
||||
onClick={() => onSelectView("overview")}
|
||||
collapsed={collapsed}
|
||||
/>
|
||||
<NavItem
|
||||
icon={Bug}
|
||||
label="Issues"
|
||||
desc="Findings from this run"
|
||||
count={issuesCount > 0 ? issuesCount : undefined}
|
||||
active={view === "issues"}
|
||||
onClick={() => onSelectView("issues")}
|
||||
collapsed={collapsed}
|
||||
/>
|
||||
{agentCount > 0 && (
|
||||
<NavItem
|
||||
icon={Waypoints}
|
||||
label="Agents"
|
||||
desc="What each agent did"
|
||||
count={agentCount}
|
||||
active={view === "agents"}
|
||||
onClick={() => onSelectView("agents")}
|
||||
collapsed={collapsed}
|
||||
/>
|
||||
)}
|
||||
<NavItem
|
||||
icon={History}
|
||||
label="Past runs"
|
||||
desc="Every run on this machine"
|
||||
count={runCount > 0 ? runCount : undefined}
|
||||
active={view === "history"}
|
||||
onClick={onOpenHistory}
|
||||
collapsed={collapsed}
|
||||
/>
|
||||
<NavItem
|
||||
icon={Mail}
|
||||
label="Email report"
|
||||
desc="Get an encrypted PDF by email"
|
||||
active={view === "email"}
|
||||
onClick={onOpenEmail}
|
||||
collapsed={collapsed}
|
||||
/>
|
||||
|
||||
{PLATFORM_ORDER.map((slug) => {
|
||||
const feature = FEATURES[slug];
|
||||
if (!feature) return null;
|
||||
return (
|
||||
<ProNavItem
|
||||
key={slug}
|
||||
feature={feature}
|
||||
active={view === "feature" && activeFeature === slug}
|
||||
onClick={() => onSelectFeature(slug)}
|
||||
collapsed={collapsed}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function NavItem({
|
||||
icon: Icon,
|
||||
label,
|
||||
desc,
|
||||
count,
|
||||
active,
|
||||
onClick,
|
||||
collapsed = false,
|
||||
}: {
|
||||
icon: React.ElementType;
|
||||
label: string;
|
||||
desc: string;
|
||||
count?: number;
|
||||
active?: boolean;
|
||||
onClick: () => void;
|
||||
collapsed?: boolean;
|
||||
}) {
|
||||
if (collapsed) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
title={count != null ? `${label} (${count})` : label}
|
||||
className={`flex w-full cursor-pointer items-center justify-center rounded-md px-2.5 py-2 transition-colors ${
|
||||
active
|
||||
? "text-white"
|
||||
: "text-[#888] hover:bg-[rgba(255,255,255,0.06)] hover:text-white"
|
||||
}`}
|
||||
style={active ? { background: "rgba(255,255,255,0.12)" } : undefined}
|
||||
>
|
||||
<Icon className="h-4 w-4 flex-shrink-0" aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`flex w-full cursor-pointer items-start gap-2.5 rounded-md px-2.5 py-1.5 text-left transition-colors ${
|
||||
active
|
||||
? "text-white"
|
||||
: "text-[#888] hover:bg-[rgba(255,255,255,0.06)] hover:text-white"
|
||||
}`}
|
||||
style={active ? { background: "rgba(255,255,255,0.12)" } : undefined}
|
||||
>
|
||||
<Icon className="mt-0.5 h-4 w-4 flex-shrink-0" aria-hidden="true" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="flex-1 truncate text-sm">{label}</span>
|
||||
{count != null && <span className="text-xs text-[#666] tabular-nums">{count}</span>}
|
||||
</span>
|
||||
<span className="mt-0.5 block text-[11px] leading-snug text-[#666]">{desc}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { X } from "lucide-react";
|
||||
import { AgentTranscript } from "./AgentTranscript";
|
||||
import { ScanPromptComposer } from "./ScanPromptComposer";
|
||||
import type { TranscriptAgent, TranscriptEvent } from "@/data/serverSource";
|
||||
|
||||
/** Status -> the small leading dot color, matching the graph node styling. */
|
||||
const STATUS_DOT: Record<string, string> = {
|
||||
completed: "bg-emerald-400",
|
||||
running: "bg-blue-400",
|
||||
waiting: "bg-yellow-400",
|
||||
stopped: "bg-[#888]",
|
||||
crashed: "bg-red-400",
|
||||
failed: "bg-red-400",
|
||||
};
|
||||
|
||||
/** Consider the user "at the bottom" within this many px. */
|
||||
const NEAR_BOTTOM_PX = 80;
|
||||
|
||||
/**
|
||||
* Overlay modal showing a single agent's full transcript. Matches the cloud
|
||||
* app: a fixed-size panel with a pinned header (status dot + agent name), the
|
||||
* transcript scrolling beneath it, and a footer. Auto-scrolls to follow new
|
||||
* activity while the user is near the bottom (so a live run trails). Closes on
|
||||
* backdrop click, the X button, or Escape.
|
||||
*/
|
||||
export function AgentDetailModal({
|
||||
agent,
|
||||
events,
|
||||
steerable,
|
||||
onClose,
|
||||
}: {
|
||||
agent: TranscriptAgent;
|
||||
events: TranscriptEvent[];
|
||||
steerable: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const nearBottom = useRef(false);
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
nearBottom.current = el.scrollHeight - el.scrollTop - el.clientHeight < NEAR_BOTTOM_PX;
|
||||
}, []);
|
||||
|
||||
// Follow new activity when the user is near the bottom (live trailing).
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el || !nearBottom.current) return;
|
||||
requestAnimationFrame(() => {
|
||||
el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
|
||||
});
|
||||
}, [events]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
const prevOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKey);
|
||||
document.body.style.overflow = prevOverflow;
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4 sm:p-8"
|
||||
onClick={onClose}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={`Agent ${agent.name}`}
|
||||
>
|
||||
<div
|
||||
className="relative flex h-[80vh] w-full max-w-5xl flex-col rounded-xl border border-[#222] bg-[#0a0a0a] shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3 border-b border-[#222] px-5 py-3.5">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
className={`h-2 w-2 flex-shrink-0 rounded-full ${STATUS_DOT[agent.status] ?? "bg-[#888]"}`}
|
||||
/>
|
||||
<span className="truncate text-sm font-semibold text-white">{agent.name}</span>
|
||||
<span className="flex-shrink-0 font-mono text-xs text-[#555]">{agent.id}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
className="flex-shrink-0 rounded-md p-1 text-[#888] transition-colors hover:bg-[#1a1a1a] hover:text-white"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div ref={scrollRef} onScroll={handleScroll} className="flex-1 overflow-y-auto p-5">
|
||||
<AgentTranscript agent={agent} events={events} showHeader={false} />
|
||||
</div>
|
||||
|
||||
{steerable && (
|
||||
<div className="border-t border-[#222] px-5 py-3">
|
||||
<ScanPromptComposer
|
||||
agents={[agent]}
|
||||
fixedAgentId={agent.id}
|
||||
className="mt-0"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AgentDetailModal;
|
||||
@@ -0,0 +1,254 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
ReactFlow,
|
||||
Background,
|
||||
Controls,
|
||||
MiniMap,
|
||||
useNodesState,
|
||||
useEdgesState,
|
||||
useReactFlow,
|
||||
type Node,
|
||||
type Edge,
|
||||
} from "@xyflow/react";
|
||||
import dagre from "@dagrejs/dagre";
|
||||
import AgentNodeComponent from "./AgentNode";
|
||||
import GraphSkeleton from "./GraphSkeleton";
|
||||
import type { AgentNode } from "@/types/events";
|
||||
|
||||
import "@xyflow/react/dist/style.css";
|
||||
|
||||
const NODE_WIDTH = 260;
|
||||
const NODE_HEIGHT = 80;
|
||||
|
||||
const nodeTypes = { agentNode: AgentNodeComponent };
|
||||
|
||||
function getLayoutedElements(
|
||||
agents: Map<string, AgentNode>,
|
||||
selectedAgentId: string | null
|
||||
) {
|
||||
const g = new dagre.graphlib.Graph();
|
||||
g.setDefaultEdgeLabel(() => ({}));
|
||||
g.setGraph({ rankdir: "TB", nodesep: 60, ranksep: 80 });
|
||||
|
||||
const nodes: Node[] = [];
|
||||
const edges: Edge[] = [];
|
||||
|
||||
for (const [id, agent] of agents) {
|
||||
g.setNode(id, { width: NODE_WIDTH, height: NODE_HEIGHT });
|
||||
nodes.push({
|
||||
id,
|
||||
type: "agentNode",
|
||||
position: { x: 0, y: 0 },
|
||||
data: { ...agent, isSelected: id === selectedAgentId },
|
||||
});
|
||||
|
||||
if (agent.parentId && agents.has(agent.parentId)) {
|
||||
const edgeId = `${agent.parentId}->${id}`;
|
||||
g.setEdge(agent.parentId, id);
|
||||
edges.push({
|
||||
id: edgeId,
|
||||
source: agent.parentId,
|
||||
target: id,
|
||||
style: { stroke: "#2a2a2a", strokeWidth: 1.5 },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
dagre.layout(g);
|
||||
|
||||
for (const node of nodes) {
|
||||
const pos = g.node(node.id);
|
||||
if (pos) {
|
||||
node.position = {
|
||||
x: pos.x - NODE_WIDTH / 2,
|
||||
y: pos.y - NODE_HEIGHT / 2,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { nodes, edges };
|
||||
}
|
||||
|
||||
const ZOOM_DURATION = 300;
|
||||
|
||||
|
||||
/** Centers viewport on the root node (no parentId) at a fixed zoom — only once on first load */
|
||||
function CenterOnRoot({ nodes }: { nodes: Node[] }) {
|
||||
const { setCenter } = useReactFlow();
|
||||
const hasCentered = useRef(false);
|
||||
useEffect(() => {
|
||||
if (nodes.length > 0 && !hasCentered.current) {
|
||||
const root = nodes.find((n) => !(n.data as Record<string, unknown>).parentId);
|
||||
const target = root ?? nodes[0];
|
||||
hasCentered.current = true;
|
||||
const cx = target.position.x + NODE_WIDTH / 2;
|
||||
const cy = target.position.y + NODE_HEIGHT / 2;
|
||||
setTimeout(() => setCenter(cx, cy, { zoom: 0.85, duration: 400 }), 60);
|
||||
}
|
||||
}, [nodes, setCenter]);
|
||||
return null;
|
||||
}
|
||||
|
||||
function SmoothControls() {
|
||||
const { zoomIn, zoomOut, fitView } = useReactFlow();
|
||||
return (
|
||||
<Controls
|
||||
position="bottom-right"
|
||||
showZoom={false}
|
||||
showFitView={false}
|
||||
showInteractive={false}
|
||||
className="!bg-transparent !border-none !shadow-none"
|
||||
>
|
||||
<div className="flex flex-col overflow-hidden rounded-lg border border-[#222]">
|
||||
<button onClick={() => zoomIn({ duration: ZOOM_DURATION })} className="flex items-center justify-center w-7 h-7 bg-[#111] text-white hover:bg-[#2a2a2a] transition-colors" title="Zoom in">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} className="w-3.5 h-3.5"><path d="M12 5v14M5 12h14" /></svg>
|
||||
</button>
|
||||
<button onClick={() => zoomOut({ duration: ZOOM_DURATION })} className="flex items-center justify-center w-7 h-7 bg-[#111] text-white hover:bg-[#2a2a2a] border-y border-[#222] transition-colors" title="Zoom out">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} className="w-3.5 h-3.5"><path d="M5 12h14" /></svg>
|
||||
</button>
|
||||
<button onClick={() => fitView({ padding: 0.3, duration: ZOOM_DURATION })} className="flex items-center justify-center w-7 h-7 bg-[#111] text-white hover:bg-[#2a2a2a] transition-colors" title="Fit view">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} className="w-3.5 h-3.5"><path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
</Controls>
|
||||
);
|
||||
}
|
||||
|
||||
interface AgentGraphProps {
|
||||
agents: Map<string, AgentNode>;
|
||||
selectedAgentId: string | null;
|
||||
onSelectAgent: (id: string | null) => void;
|
||||
eventsLoaded?: boolean;
|
||||
eventsEmpty?: boolean;
|
||||
scanCompleted?: boolean;
|
||||
}
|
||||
|
||||
export default function AgentGraph({
|
||||
agents,
|
||||
selectedAgentId,
|
||||
onSelectAgent,
|
||||
eventsLoaded,
|
||||
eventsEmpty,
|
||||
scanCompleted,
|
||||
}: AgentGraphProps) {
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState<Node>([]);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (agents.size === 0) return;
|
||||
const { nodes: ln, edges: le } = getLayoutedElements(agents, selectedAgentId);
|
||||
setNodes(ln);
|
||||
setEdges(le);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [agents.size, setNodes, setEdges]);
|
||||
|
||||
// Sync agent data (status, name, etc.) into existing nodes without re-layout
|
||||
useEffect(() => {
|
||||
if (agents.size === 0) return;
|
||||
setNodes((nds) =>
|
||||
nds.map((n) => {
|
||||
const agent = agents.get(n.id);
|
||||
if (!agent) return n;
|
||||
return { ...n, data: { ...agent, isSelected: n.id === selectedAgentId } };
|
||||
})
|
||||
);
|
||||
}, [agents, selectedAgentId, setNodes]);
|
||||
|
||||
const nodeClickedRef = useRef(false);
|
||||
|
||||
const onNodeClick = useCallback(
|
||||
(_: React.MouseEvent, node: Node) => {
|
||||
nodeClickedRef.current = true;
|
||||
onSelectAgent(node.id);
|
||||
},
|
||||
[onSelectAgent]
|
||||
);
|
||||
|
||||
const onPaneClick = useCallback(() => {
|
||||
if (nodeClickedRef.current) {
|
||||
nodeClickedRef.current = false;
|
||||
return;
|
||||
}
|
||||
onSelectAgent(null);
|
||||
}, [onSelectAgent]);
|
||||
|
||||
// Convex responded, zero events — show empty state (not skeleton)
|
||||
if (agents.size === 0 && eventsLoaded && eventsEmpty) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center px-4">
|
||||
<div className="w-10 h-10 mb-3 rounded-full bg-[#111] flex items-center justify-center">
|
||||
{scanCompleted ? (
|
||||
<svg className="w-5 h-5 text-[#444]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6A2.25 2.25 0 0 1 6 3.75h2.25A2.25 2.25 0 0 1 10.5 6v2.25a2.25 2.25 0 0 1-2.25 2.25H6a2.25 2.25 0 0 1-2.25-2.25V6ZM3.75 15.75A2.25 2.25 0 0 1 6 13.5h2.25a2.25 2.25 0 0 1 2.25 2.25V18a2.25 2.25 0 0 1-2.25 2.25H6A2.25 2.25 0 0 1 3.75 18v-2.25ZM13.5 6a2.25 2.25 0 0 1 2.25-2.25H18A2.25 2.25 0 0 1 20.25 6v2.25A2.25 2.25 0 0 1 18 10.5h-2.25a2.25 2.25 0 0 1-2.25-2.25V6ZM13.5 15.75a2.25 2.25 0 0 1 2.25-2.25H18a2.25 2.25 0 0 1 2.25 2.25V18A2.25 2.25 0 0 1 18 20.25h-2.25a2.25 2.25 0 0 1-2.25-2.25v-2.25Z" />
|
||||
</svg>
|
||||
) : (
|
||||
<div className="w-2 h-2 rounded-full bg-blue-500 animate-pulse" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-[#555]">
|
||||
{scanCompleted
|
||||
? "Agent trace data is not available for this pentest"
|
||||
: "Waiting for agent data\u2026"}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const showGraph = agents.size > 0;
|
||||
|
||||
return (
|
||||
<div className="relative h-full">
|
||||
{/* Skeleton overlay — fades out when graph is ready */}
|
||||
<div
|
||||
className={`absolute inset-0 z-10 transition-opacity duration-500 ${
|
||||
showGraph ? "opacity-0 pointer-events-none" : "opacity-100"
|
||||
}`}
|
||||
>
|
||||
<GraphSkeleton />
|
||||
</div>
|
||||
|
||||
{/* Graph — fades in */}
|
||||
<div
|
||||
className={`h-full transition-opacity duration-500 ${
|
||||
showGraph ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
>
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onNodeClick={onNodeClick}
|
||||
onPaneClick={onPaneClick}
|
||||
nodeTypes={nodeTypes}
|
||||
nodesConnectable={false}
|
||||
edgesFocusable={false}
|
||||
edgesReconnectable={false}
|
||||
minZoom={0.15}
|
||||
maxZoom={1.5}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
className="bg-black"
|
||||
>
|
||||
<Background color="#111" gap={20} />
|
||||
<CenterOnRoot nodes={nodes} />
|
||||
<SmoothControls />
|
||||
<MiniMap
|
||||
position="bottom-left"
|
||||
nodeColor={(n) => {
|
||||
const status = (n.data as Record<string, unknown>)?.status as string;
|
||||
if (status === "running") return "#3b82f6";
|
||||
if (status === "completed") return "#10b981";
|
||||
if (status === "failed" || status === "error") return "#ef4444";
|
||||
return "#555";
|
||||
}}
|
||||
maskColor="rgba(0,0,0,0.8)"
|
||||
style={{ width: 80, height: 50 }}
|
||||
className="!bg-[#0a0a0a] !border-[#222]"
|
||||
/>
|
||||
</ReactFlow>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { Handle, Position, type NodeProps } from "@xyflow/react";
|
||||
import type { AgentNode as AgentNodeData } from "@/types/events";
|
||||
|
||||
const STATUS_STYLES: Record<string, string> = {
|
||||
running: "bg-blue-500",
|
||||
completed: "bg-emerald-500",
|
||||
failed: "bg-red-500",
|
||||
error: "bg-red-500",
|
||||
};
|
||||
|
||||
function AgentNodeComponent({ data, selected }: NodeProps) {
|
||||
const agent = data as unknown as AgentNodeData & { isSelected: boolean };
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`w-[260px] rounded-lg border px-4 py-3 transition-colors ${
|
||||
agent.isSelected || selected
|
||||
? "border-white/30 bg-[#0a0a0a]"
|
||||
: "border-[#222] bg-black hover:border-[#333]"
|
||||
}`}
|
||||
>
|
||||
<Handle type="target" position={Position.Top} isConnectable={false} className={`!w-1.5 !h-1.5 !border-0 ${agent.parentId ? "!bg-[#444]" : "!bg-transparent"}`} />
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="relative flex h-2 w-2 shrink-0">
|
||||
<span
|
||||
className={`absolute inline-flex h-full w-full rounded-full opacity-75 ${STATUS_STYLES[agent.status] ?? "bg-gray-500"} ${
|
||||
agent.status === "running" ? "animate-ping" : ""
|
||||
}`}
|
||||
/>
|
||||
<span
|
||||
className={`relative inline-flex h-2 w-2 rounded-full ${STATUS_STYLES[agent.status] ?? "bg-gray-500"}`}
|
||||
/>
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-white leading-snug line-clamp-3">
|
||||
{agent.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Handle type="source" position={Position.Bottom} isConnectable={false} className={`!w-1.5 !h-1.5 !border-0 ${agent.children && agent.children.length > 0 ? "!bg-[#444]" : "!bg-transparent"}`} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(AgentNodeComponent);
|
||||
@@ -0,0 +1,302 @@
|
||||
import { Component, useMemo, type ReactNode } from "react";
|
||||
import { Brain, Bot } from "lucide-react";
|
||||
import { getToolRenderer, getToolIcon } from "./tool-renderers";
|
||||
import ChatBubble from "./tool-renderers/ChatBubble";
|
||||
import type { ToolRendererProps, AgentNode as GraphAgentNode } from "@/types/events";
|
||||
import type { TranscriptAgent, TranscriptEvent } from "@/data/serverSource";
|
||||
|
||||
/* ---------- Error boundary so one bad event never blanks the transcript ---------- */
|
||||
class RendererErrorBoundary extends Component<
|
||||
{ toolName: string; children: ReactNode },
|
||||
{ hasError: boolean }
|
||||
> {
|
||||
constructor(props: { toolName: string; children: ReactNode }) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
static getDerivedStateFromError() {
|
||||
return { hasError: true };
|
||||
}
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<span className="text-[#555] font-semibold text-sm">
|
||||
{this.props.toolName.replace(/_/g, " ")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
function SafeToolRenderer(props: ToolRendererProps) {
|
||||
const Renderer = getToolRenderer(props.toolName);
|
||||
return (
|
||||
<RendererErrorBoundary toolName={props.toolName}>
|
||||
<Renderer {...props} />
|
||||
</RendererErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- Value coercion ----------
|
||||
* args/result arrive as either a JSON object or a Python-repr string
|
||||
* ("{'thought': '...'}"). Try JSON, then a naive python->json pass, then wrap
|
||||
* the raw string so the fallback renderer can display it. Never throws. */
|
||||
function coerce(value: unknown): unknown {
|
||||
if (value == null || typeof value !== "string") return value;
|
||||
const t = value.trim();
|
||||
if (!t) return value;
|
||||
try {
|
||||
return JSON.parse(t);
|
||||
} catch {
|
||||
/* not JSON */
|
||||
}
|
||||
try {
|
||||
const jsonish = t
|
||||
.replace(/\bNone\b/g, "null")
|
||||
.replace(/\bTrue\b/g, "true")
|
||||
.replace(/\bFalse\b/g, "false")
|
||||
.replace(/'/g, '"');
|
||||
return JSON.parse(jsonish);
|
||||
} catch {
|
||||
return { __raw: value };
|
||||
}
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
const c = coerce(value);
|
||||
if (c && typeof c === "object" && !Array.isArray(c)) return c as Record<string, unknown>;
|
||||
if (c == null) return {};
|
||||
return { __raw: typeof c === "string" ? c : JSON.stringify(c) };
|
||||
}
|
||||
|
||||
/** Numeric suffix of an event id ("tool_37" -> 37) for stable ordering. */
|
||||
function eventSeq(id: string): number {
|
||||
const m = /(\d+)$/.exec(id);
|
||||
return m ? parseInt(m[1], 10) : 0;
|
||||
}
|
||||
|
||||
/** A chat event whose author is the human/user (vs an assistant "thinking"). */
|
||||
function isUserChat(event: TranscriptEvent): boolean {
|
||||
const role = event.data?.role;
|
||||
return event.type === "chat" && (role === "user" || role === "human");
|
||||
}
|
||||
|
||||
/**
|
||||
* Inter-agent message deliveries land in the recipient's session as user-role
|
||||
* items prefixed with a header (see the engine's message formatter). They are
|
||||
* already represented via the sending agent's tool renderer, so we never render
|
||||
* them as chat bubbles here.
|
||||
*/
|
||||
function isInterAgentDelivery(event: TranscriptEvent): boolean {
|
||||
return isUserChat(event) && String(event.data?.content ?? "").startsWith("[Message from ");
|
||||
}
|
||||
|
||||
/**
|
||||
* The reconstructed SDK session records every incoming user-role item for an
|
||||
* agent: its initial input (the root's assembled brief, or a subagent's spawn /
|
||||
* inherited-context prompt), inter-agent deliveries, AND genuine human steering
|
||||
* messages sent live from the viewer or TUI. We only want the last group. Given
|
||||
* an agent's events in order, hide the first user message (its initial input)
|
||||
* and every inter-agent delivery; keep the rest, which are the human's live
|
||||
* instructions, rendered as "User" bubbles.
|
||||
*/
|
||||
function hiddenUserEventIds(agentEventsInOrder: TranscriptEvent[]): Set<string> {
|
||||
const hidden = new Set<string>();
|
||||
let sawInitialInput = false;
|
||||
for (const e of agentEventsInOrder) {
|
||||
if (!isUserChat(e)) continue;
|
||||
if (isInterAgentDelivery(e)) {
|
||||
hidden.add(e.id);
|
||||
continue;
|
||||
}
|
||||
if (!sawInitialInput) {
|
||||
sawInitialInput = true;
|
||||
hidden.add(e.id);
|
||||
}
|
||||
}
|
||||
return hidden;
|
||||
}
|
||||
|
||||
const STATUS_STYLE: Record<string, string> = {
|
||||
completed: "text-emerald-400 border-emerald-500/30 bg-emerald-500/10",
|
||||
running: "text-blue-400 border-blue-500/30 bg-blue-500/10",
|
||||
waiting: "text-yellow-400 border-yellow-500/30 bg-yellow-500/10",
|
||||
stopped: "text-[#aaa] border-[#333] bg-[#1a1a1a]",
|
||||
crashed: "text-red-400 border-red-500/30 bg-red-500/10",
|
||||
failed: "text-red-400 border-red-500/30 bg-red-500/10",
|
||||
};
|
||||
|
||||
/** Map our engine agent statuses onto the graph node's status union. */
|
||||
function graphStatus(status: string): GraphAgentNode["status"] {
|
||||
if (status === "completed") return "completed";
|
||||
if (status === "running") return "running";
|
||||
if (status === "failed" || status === "crashed") return "failed";
|
||||
// waiting / stopped / unknown → keep the raw string; AgentNode/MiniMap fall
|
||||
// back to a neutral gray for anything they don't explicitly style.
|
||||
return status as GraphAgentNode["status"];
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapt transcript agents + events into the Map<id, AgentNode> that the live
|
||||
* AgentGraph renders: children from parent_id, tool/message counts by scanning
|
||||
* events, and a task pulled from the spawning create_agent call where present.
|
||||
*/
|
||||
export function buildGraphAgents(
|
||||
agents: TranscriptAgent[],
|
||||
events: TranscriptEvent[]
|
||||
): Map<string, GraphAgentNode> {
|
||||
const childrenOf = new Map<string, string[]>();
|
||||
for (const a of agents) {
|
||||
if (a.parent_id) {
|
||||
const arr = childrenOf.get(a.parent_id) ?? [];
|
||||
arr.push(a.id);
|
||||
childrenOf.set(a.parent_id, arr);
|
||||
}
|
||||
}
|
||||
|
||||
const toolCount = new Map<string, number>();
|
||||
const messageCount = new Map<string, number>();
|
||||
// A create_agent call names the child but not its id, so map spawned tasks by
|
||||
// agent NAME (best-effort — used only for the graph node subtitle).
|
||||
const taskByName = new Map<string, string>();
|
||||
for (const e of events) {
|
||||
if (e.type === "tool") {
|
||||
toolCount.set(e.agent_id, (toolCount.get(e.agent_id) ?? 0) + 1);
|
||||
if (e.data?.tool_name === "create_agent") {
|
||||
const args = asRecord(e.data.args);
|
||||
const name = (args.name as string) ?? (args.agent_name as string) ?? "";
|
||||
const task = (args.task as string) ?? "";
|
||||
if (name && task) taskByName.set(name, task);
|
||||
}
|
||||
} else if (!isUserChat(e)) {
|
||||
// Count only assistant messages for the graph node subtitle.
|
||||
messageCount.set(e.agent_id, (messageCount.get(e.agent_id) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
const map = new Map<string, GraphAgentNode>();
|
||||
for (const a of agents) {
|
||||
map.set(a.id, {
|
||||
id: a.id,
|
||||
name: a.name,
|
||||
task: taskByName.get(a.name) ?? "",
|
||||
status: graphStatus(a.status),
|
||||
parentId: a.parent_id,
|
||||
children: childrenOf.get(a.id) ?? [],
|
||||
createdAt: a.created_at,
|
||||
toolCount: toolCount.get(a.id) ?? 0,
|
||||
messageCount: messageCount.get(a.id) ?? 0,
|
||||
});
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/* ---------- Per-agent transcript ---------- */
|
||||
export function AgentTranscript({
|
||||
agent,
|
||||
events,
|
||||
showHeader = true,
|
||||
}: {
|
||||
agent: TranscriptAgent;
|
||||
events: TranscriptEvent[];
|
||||
showHeader?: boolean;
|
||||
}) {
|
||||
const mine = useMemo(() => {
|
||||
const ordered = events
|
||||
.filter((e) => e.agent_id === agent.id)
|
||||
.sort((a, b) => eventSeq(a.id) - eventSeq(b.id));
|
||||
const hidden = hiddenUserEventIds(ordered);
|
||||
return ordered.filter((e) => !hidden.has(e.id));
|
||||
}, [events, agent.id]);
|
||||
|
||||
const toolCount = mine.filter((e) => e.type === "tool").length;
|
||||
const msgCount = mine.length - toolCount;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{showHeader && (
|
||||
<>
|
||||
<div className="flex items-center gap-2 flex-wrap mb-1">
|
||||
<span className="text-base font-semibold text-white truncate">{agent.name}</span>
|
||||
<span
|
||||
className={`flex-shrink-0 text-xs font-medium capitalize px-2 py-0.5 rounded-full border ${
|
||||
STATUS_STYLE[agent.status] ?? "text-[#aaa] border-[#333] bg-[#1a1a1a]"
|
||||
}`}
|
||||
>
|
||||
{agent.status}
|
||||
</span>
|
||||
<span className="font-mono text-xs text-[#555]">{agent.id}</span>
|
||||
</div>
|
||||
<p className="text-xs text-[#666] mb-4">
|
||||
{msgCount} message{msgCount === 1 ? "" : "s"} · {toolCount} tool call
|
||||
{toolCount === 1 ? "" : "s"}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{mine.length === 0 ? (
|
||||
<p className="text-sm text-[#666]">No recorded activity for this agent.</p>
|
||||
) : (
|
||||
<div className="py-1">
|
||||
{mine.map((event, i) => {
|
||||
const isLast = i === mine.length - 1;
|
||||
const isTool = event.type === "tool";
|
||||
const toolName = isTool ? String(event.data?.tool_name ?? "tool") : "";
|
||||
const role = !isTool ? String(event.data?.role ?? "assistant") : "";
|
||||
|
||||
let Icon;
|
||||
let iconColor: string;
|
||||
if (isTool) {
|
||||
const meta = getToolIcon(toolName);
|
||||
Icon = meta.icon;
|
||||
iconColor = meta.color;
|
||||
} else {
|
||||
const isUser = role === "user" || role === "human";
|
||||
Icon = isUser ? Bot : Brain;
|
||||
iconColor = isUser ? "text-blue-400" : "text-purple-400";
|
||||
}
|
||||
|
||||
const status = isTool ? String(event.data?.status ?? "completed") : "completed";
|
||||
|
||||
return (
|
||||
<div key={event.id} className="flex gap-3">
|
||||
<div className="flex flex-col items-center shrink-0">
|
||||
<div
|
||||
className={`w-[30px] h-[30px] rounded-full bg-black border flex items-center justify-center shrink-0 ${
|
||||
isTool && status === "running"
|
||||
? "border-blue-500/40 animate-pulse"
|
||||
: isTool && status === "failed"
|
||||
? "border-red-500/30"
|
||||
: "border-[#222]"
|
||||
}`}
|
||||
>
|
||||
<Icon className={`w-3.5 h-3.5 ${iconColor}`} />
|
||||
</div>
|
||||
{!isLast && <div className="w-px flex-1 bg-[#1a1a1a] mt-1" />}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 pt-[5px] pb-6">
|
||||
{isTool ? (
|
||||
<SafeToolRenderer
|
||||
toolName={toolName}
|
||||
args={asRecord(event.data?.args)}
|
||||
result={coerce(event.data?.result) ?? null}
|
||||
status={
|
||||
status as ToolRendererProps["status"]
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<ChatBubble
|
||||
role={role}
|
||||
content={String(event.data?.content ?? "")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
"use client";
|
||||
|
||||
function SkeletonNode({ w = 24 }: { w?: number }) {
|
||||
return (
|
||||
<div className="w-[180px] h-[72px] rounded-lg border border-[#222] bg-[#0a0a0a] px-3 py-2 shrink-0">
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<div className="w-2 h-2 rounded-full bg-[#2a2a2a]" />
|
||||
<div className="h-3 rounded bg-[#252525]" style={{ width: `${w * 4}px` }} />
|
||||
</div>
|
||||
<div className="h-2 w-28 rounded bg-[#1e1e1e] mb-1.5" />
|
||||
<div className="flex gap-3">
|
||||
<div className="h-2 w-8 rounded bg-[#1e1e1e]" />
|
||||
<div className="h-2 w-8 rounded bg-[#1e1e1e]" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function VLine() {
|
||||
return <div className="w-px h-6 bg-[#2a2a2a]" />;
|
||||
}
|
||||
|
||||
function HBranch({ count }: { count: number }) {
|
||||
return (
|
||||
<div className="relative flex justify-center">
|
||||
<div className="absolute top-0 h-px bg-[#2a2a2a]" style={{ width: `${(count - 1) * 220}px` }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function GraphSkeleton() {
|
||||
return (
|
||||
<div className="h-full bg-black overflow-hidden">
|
||||
<div className="flex flex-col items-center pt-10 animate-pulse">
|
||||
<SkeletonNode w={20} />
|
||||
<VLine />
|
||||
<HBranch count={3} />
|
||||
<div className="flex gap-10">
|
||||
{[18, 22, 16].map((w, i) => (
|
||||
<div key={i} className="flex flex-col items-center">
|
||||
<VLine />
|
||||
<SkeletonNode w={w} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-10 w-full justify-center">
|
||||
<div className="flex flex-col items-center">
|
||||
<VLine />
|
||||
<HBranch count={2} />
|
||||
<div className="flex gap-10">
|
||||
{[14, 20].map((w, i) => (
|
||||
<div key={i} className="flex flex-col items-center">
|
||||
<VLine />
|
||||
<SkeletonNode w={w} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-center">
|
||||
<VLine />
|
||||
<SkeletonNode w={18} />
|
||||
<VLine />
|
||||
<SkeletonNode w={12} />
|
||||
</div>
|
||||
<div className="w-[180px]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { ArrowUp, ChevronDown, ChevronUp, Loader2, Sparkles } from "lucide-react";
|
||||
import { steerAgent, type TranscriptAgent } from "@/data/serverSource";
|
||||
import { track } from "@/lib/cta";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const ROOT_TARGET_VALUE = "__root__";
|
||||
|
||||
interface ScanPromptComposerProps {
|
||||
/** All agents in the run; used to resolve the root and running children. */
|
||||
agents: TranscriptAgent[];
|
||||
/**
|
||||
* Single-agent (modal) mode: pins the composer to one agent and shows a
|
||||
* static "Target: <name>" pill instead of the dropdown. Omit for the
|
||||
* multi-agent graph variant.
|
||||
*/
|
||||
fixedAgentId?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Faithful port of the pro app's ScanPromptComposer for the local viewer.
|
||||
* Collapsed by default into a "Guide the agent" pill; expands into a card with
|
||||
* an auto-resizing textarea and a target control. The viewer's steering is
|
||||
* immediate (no Enterprise lock, no bridge-connecting state), so this is only
|
||||
* rendered by callers when steering is available. Sends via steerAgent, which
|
||||
* requires a concrete agent id, so "Root agent" resolves to the root agent's id.
|
||||
*/
|
||||
export function ScanPromptComposer({
|
||||
agents,
|
||||
fixedAgentId,
|
||||
className,
|
||||
}: ScanPromptComposerProps) {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [focused, setFocused] = useState(false);
|
||||
const [value, setValue] = useState("");
|
||||
const [sending, setSending] = useState(false);
|
||||
const [feedback, setFeedback] = useState<string | null>(null);
|
||||
|
||||
const isModal = fixedAgentId != null;
|
||||
|
||||
// Root = the agent with no parent; fall back to the first agent.
|
||||
const rootAgent = useMemo(
|
||||
() => agents.find((a) => !a.parent_id) ?? agents[0] ?? null,
|
||||
[agents]
|
||||
);
|
||||
|
||||
// Multi-agent dropdown options: running child agents plus Root (added in JSX).
|
||||
const targetOptions = useMemo(
|
||||
() => agents.filter((a) => a.parent_id && a.status === "running"),
|
||||
[agents]
|
||||
);
|
||||
|
||||
// Selected target for the multi-agent variant. ROOT sentinel by default.
|
||||
const [selectedTarget, setSelectedTarget] = useState<string>(ROOT_TARGET_VALUE);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
// If the selected child target disappears (finished), fall back to Root.
|
||||
useEffect(() => {
|
||||
if (
|
||||
selectedTarget !== ROOT_TARGET_VALUE &&
|
||||
!targetOptions.some((a) => a.id === selectedTarget)
|
||||
) {
|
||||
setSelectedTarget(ROOT_TARGET_VALUE);
|
||||
}
|
||||
}, [selectedTarget, targetOptions]);
|
||||
|
||||
// Resolve the concrete agent id + display name for the current target.
|
||||
const { targetId, targetName } = useMemo(() => {
|
||||
if (isModal) {
|
||||
const agent = agents.find((a) => a.id === fixedAgentId) ?? null;
|
||||
return {
|
||||
targetId: fixedAgentId ?? null,
|
||||
targetName: agent?.name ?? "this agent",
|
||||
};
|
||||
}
|
||||
if (selectedTarget === ROOT_TARGET_VALUE) {
|
||||
return {
|
||||
targetId: rootAgent?.id ?? null,
|
||||
targetName: "Root agent",
|
||||
};
|
||||
}
|
||||
const agent = agents.find((a) => a.id === selectedTarget) ?? null;
|
||||
return {
|
||||
targetId: agent?.id ?? rootAgent?.id ?? null,
|
||||
targetName: agent?.name ?? "Root agent",
|
||||
};
|
||||
}, [agents, fixedAgentId, isModal, rootAgent, selectedTarget]);
|
||||
|
||||
const empty = value.trim().length === 0;
|
||||
|
||||
// Grow the textarea with its content, capped by max-h via CSS.
|
||||
useLayoutEffect(() => {
|
||||
const el = textareaRef.current;
|
||||
if (!el) return;
|
||||
el.style.height = "auto";
|
||||
el.style.height = `${el.scrollHeight}px`;
|
||||
}, [value]);
|
||||
|
||||
const handleExpand = useCallback(() => {
|
||||
setExpanded(true);
|
||||
requestAnimationFrame(() => textareaRef.current?.focus());
|
||||
}, []);
|
||||
|
||||
const handleCollapse = useCallback(() => {
|
||||
setExpanded(false);
|
||||
setFocused(false);
|
||||
setMenuOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
if (sending) return;
|
||||
const message = value.trim();
|
||||
if (!message || !targetId) return;
|
||||
|
||||
setSending(true);
|
||||
setFeedback(null);
|
||||
const name = targetName;
|
||||
const res = await steerAgent(targetId, message);
|
||||
setSending(false);
|
||||
if (res.ok) {
|
||||
setValue("");
|
||||
setFeedback(`Sent to ${name}`);
|
||||
track("agent_steered");
|
||||
} else if (res.error === "not_delivered") {
|
||||
setFeedback("Could not reach that agent (it may have finished).");
|
||||
} else {
|
||||
setFeedback("Could not send that message. Try again.");
|
||||
}
|
||||
}, [sending, value, targetId, targetName]);
|
||||
|
||||
if (!expanded) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleExpand}
|
||||
className={cn(
|
||||
"mt-4 flex w-full items-center justify-between gap-3 rounded-2xl border border-white/[0.08] bg-[#050505] px-5 py-3 text-left transition-colors duration-300 hover:border-white/[0.12] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/20",
|
||||
className
|
||||
)}
|
||||
aria-expanded={false}
|
||||
aria-label="Expand live prompt composer"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Sparkles className="h-4 w-4 shrink-0 text-[#666]" />
|
||||
<span className="truncate text-sm font-medium text-white">Guide the agent</span>
|
||||
</div>
|
||||
<ChevronUp className="h-4 w-4 shrink-0 text-[#777]" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"mt-4 rounded-2xl border border-white/[0.08] bg-[#050505] overflow-hidden transition-colors duration-300",
|
||||
focused ? "border-white/[0.18]" : "hover:border-white/[0.12]",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3 border-b border-white/[0.06] px-5 py-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4 text-[#666]" />
|
||||
<p className="text-sm font-medium text-white">Live prompt</p>
|
||||
</div>
|
||||
<p className="mt-0.5 text-xs text-[#777]">Connected</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{isModal ? (
|
||||
<div className="rounded-full border border-white/[0.08] bg-white/[0.03] px-3 py-1 text-xs text-[#aaa]">
|
||||
Target: <span className="text-white">{targetName}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs text-[#aaa]">Target:</span>
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMenuOpen((o) => !o)}
|
||||
onBlur={() => requestAnimationFrame(() => setMenuOpen(false))}
|
||||
className="inline-flex h-7 items-center gap-1 rounded-full border border-white/[0.08] bg-white/[0.03] px-3 text-xs text-white transition-colors hover:border-white/[0.16] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/20"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={menuOpen}
|
||||
>
|
||||
<span className="max-w-[140px] truncate">{targetName}</span>
|
||||
<ChevronDown className="h-3.5 w-3.5 text-[#999]" />
|
||||
</button>
|
||||
{menuOpen && (
|
||||
<div
|
||||
className="absolute right-0 z-10 mt-1 min-w-[160px] overflow-hidden rounded-lg border border-[#333] bg-[#0a0a0a] py-1 shadow-xl"
|
||||
role="listbox"
|
||||
>
|
||||
<TargetMenuItem
|
||||
label="Root agent"
|
||||
active={selectedTarget === ROOT_TARGET_VALUE}
|
||||
onSelect={() => {
|
||||
setSelectedTarget(ROOT_TARGET_VALUE);
|
||||
setMenuOpen(false);
|
||||
}}
|
||||
/>
|
||||
{targetOptions.map((option) => (
|
||||
<TargetMenuItem
|
||||
key={option.id}
|
||||
label={option.name}
|
||||
active={selectedTarget === option.id}
|
||||
onSelect={() => {
|
||||
setSelectedTarget(option.id);
|
||||
setMenuOpen(false);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCollapse}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-full text-[#777] transition-colors hover:bg-white/[0.06] hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/20"
|
||||
aria-label="Collapse live prompt composer"
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-5 pt-4 pb-3">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
rows={1}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onFocus={() => setFocused(true)}
|
||||
onBlur={() => setFocused(false)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
void handleSend();
|
||||
}
|
||||
}}
|
||||
placeholder="Send a live prompt to the running scan…"
|
||||
maxLength={4000}
|
||||
disabled={sending}
|
||||
className="block w-full resize-none border-0 bg-transparent p-0 text-[15px] leading-6 text-white placeholder:text-[#444] focus:outline-none disabled:opacity-60 max-h-[160px] overflow-y-auto"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3 px-4 pb-4">
|
||||
<div className="text-xs text-[#666]">{feedback ?? "Press Enter to send."}</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void handleSend();
|
||||
}}
|
||||
disabled={sending || empty}
|
||||
className={cn(
|
||||
"inline-flex h-10 min-w-[112px] items-center justify-center gap-2 rounded-full px-4 text-sm font-medium transition-colors",
|
||||
sending || empty
|
||||
? "bg-white/[0.08] text-[#666]"
|
||||
: "bg-white text-black hover:bg-neutral-200"
|
||||
)}
|
||||
>
|
||||
{sending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<ArrowUp className="h-4 w-4" strokeWidth={2.5} />
|
||||
)}
|
||||
<span>Send prompt</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TargetMenuItem({
|
||||
label,
|
||||
active,
|
||||
onSelect,
|
||||
}: {
|
||||
label: string;
|
||||
active: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={active}
|
||||
// onMouseDown so the click lands before the trigger's onBlur closes the menu.
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
onSelect();
|
||||
}}
|
||||
className={cn(
|
||||
"block w-full truncate px-3 py-1.5 text-left text-xs transition-colors hover:bg-white/[0.06]",
|
||||
active ? "text-white" : "text-[#aaa]"
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default ScanPromptComposer;
|
||||
@@ -0,0 +1,92 @@
|
||||
"use client";
|
||||
|
||||
import type { ToolRendererProps } from "@/types/events";
|
||||
import { TruncatedText } from "./ToolCard";
|
||||
|
||||
export default function AgentCommsRenderer({ toolName, args }: ToolRendererProps) {
|
||||
if (toolName === "create_agent") {
|
||||
const name = (args.name as string) ?? (args.agent_name as string) ?? "";
|
||||
const task = (args.task as string) ?? "";
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-cyan-400/80 font-semibold text-sm">spawning</span>
|
||||
{name && <span className="text-cyan-400 font-semibold text-sm">{name}</span>}
|
||||
</div>
|
||||
{task && <div className="mt-1.5"><TruncatedText text={task} maxLines={15} /></div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (toolName === "agent_finish") {
|
||||
const summary = (args.result_summary as string) ?? "";
|
||||
const success = args.success as boolean | undefined;
|
||||
const rawFindings = args.findings;
|
||||
const findings = Array.isArray(rawFindings) ? rawFindings as string[] : undefined;
|
||||
return (
|
||||
<div>
|
||||
<span className={`font-semibold text-sm ${success === false ? "text-red-400/80" : "text-emerald-400/80"}`}>
|
||||
{success === false ? "Agent failed" : "Agent completed"}
|
||||
</span>
|
||||
{summary && <div className="mt-1.5"><TruncatedText text={summary} maxLines={20} /></div>}
|
||||
{findings && findings.length > 0 && (
|
||||
<div className="mt-1.5 space-y-0.5">
|
||||
{findings.map((f, i) => (
|
||||
<div key={i} className="text-[13px] text-[#888]"><span className="text-red-400/50 mr-1">•</span>{typeof f === "string" ? f : JSON.stringify(f)}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (toolName === "send_message_to_agent") {
|
||||
const message = (args.message as string) ?? "";
|
||||
const agentId = (args.target_agent_id as string) ?? (args.agent_id as string) ?? "";
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-cyan-400/80 font-semibold text-sm">message</span>
|
||||
{agentId && <span className="text-[#888] text-[13px]">to {agentId.slice(0, 16)}</span>}
|
||||
</div>
|
||||
{message && <div className="mt-1.5"><TruncatedText text={message} maxLines={20} /></div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (toolName === "wait_for_message") {
|
||||
const reason = (args.reason as string) ?? "";
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-cyan-400/80 font-semibold text-sm">waiting</span>
|
||||
{reason && <span className="text-[#888] text-[13px] truncate">{reason}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (toolName === "stop_agent") {
|
||||
const targetAgentId = (args.target_agent_id as string) ?? "";
|
||||
const cascade = args.cascade !== false;
|
||||
const reason = (args.reason as string) ?? "";
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-red-400/80 font-semibold text-sm">stopping</span>
|
||||
{targetAgentId && <span className="text-[#888] text-[13px]">{targetAgentId.slice(0, 16)}</span>}
|
||||
{cascade && <span className="text-[#555] text-[13px] italic">+ descendants</span>}
|
||||
</div>
|
||||
{reason && <div className="mt-1.5 text-[#888] text-[13px]">{reason}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (toolName === "view_agent_graph") {
|
||||
return (
|
||||
<span className="text-cyan-400/80 font-semibold text-sm">viewing agents graph</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="text-cyan-400/80 font-semibold text-sm">{toolName.replace(/_/g, " ")}</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
"use client";
|
||||
|
||||
import type { ToolRendererProps } from "@/types/events";
|
||||
import { shortPath } from "./utils";
|
||||
|
||||
const DIFF_PREVIEW_LINES = 30;
|
||||
|
||||
const BEGIN_PATCH = "*** Begin Patch";
|
||||
const END_PATCH = "*** End Patch";
|
||||
const ADD_FILE = "*** Add File: ";
|
||||
const UPDATE_FILE = "*** Update File: ";
|
||||
const DELETE_FILE = "*** Delete File: ";
|
||||
|
||||
const OP_LABEL: Record<string, string> = { add: "create", update: "edit", delete: "delete" };
|
||||
|
||||
interface PatchOp {
|
||||
kind: "add" | "update" | "delete";
|
||||
path: string;
|
||||
oldLines: string[];
|
||||
newLines: string[];
|
||||
}
|
||||
|
||||
/** apply_patch args arrive as {patch: text} (chat-completions FunctionTool) or
|
||||
* {input: text} (CustomTool). Mirrors the OSS `_extract_patch_text`. */
|
||||
function extractPatchText(args: Record<string, unknown>): string {
|
||||
const raw = args.patch;
|
||||
if (typeof raw === "string") return raw;
|
||||
if (raw && typeof raw === "object" && typeof (raw as Record<string, unknown>).patch === "string") {
|
||||
return (raw as Record<string, string>).patch;
|
||||
}
|
||||
return typeof args.input === "string" ? args.input : "";
|
||||
}
|
||||
|
||||
/** Parse V4A patch text into per-file operations (mirrors `_parse_patch_operations`). */
|
||||
function parsePatchOperations(patchText: string): PatchOp[] {
|
||||
const ops: PatchOp[] = [];
|
||||
let current: PatchOp | null = null;
|
||||
|
||||
const flush = () => {
|
||||
if (current) ops.push(current);
|
||||
current = null;
|
||||
};
|
||||
|
||||
for (const line of patchText.split("\n")) {
|
||||
if (line === BEGIN_PATCH || line === END_PATCH) continue;
|
||||
if (line.startsWith(ADD_FILE)) {
|
||||
flush();
|
||||
current = { kind: "add", path: line.slice(ADD_FILE.length).trim(), oldLines: [], newLines: [] };
|
||||
} else if (line.startsWith(UPDATE_FILE)) {
|
||||
flush();
|
||||
current = { kind: "update", path: line.slice(UPDATE_FILE.length).trim(), oldLines: [], newLines: [] };
|
||||
} else if (line.startsWith(DELETE_FILE)) {
|
||||
flush();
|
||||
current = { kind: "delete", path: line.slice(DELETE_FILE.length).trim(), oldLines: [], newLines: [] };
|
||||
} else if (current?.kind === "update") {
|
||||
if (line.startsWith("@@")) continue;
|
||||
if (line.startsWith("-") && !line.startsWith("---")) current.oldLines.push(line.slice(1));
|
||||
else if (line.startsWith("+") && !line.startsWith("+++")) current.newLines.push(line.slice(1));
|
||||
} else if (current?.kind === "add") {
|
||||
if (line.startsWith("+")) current.newLines.push(line.slice(1));
|
||||
else if (line.trim()) current.newLines.push(line);
|
||||
}
|
||||
}
|
||||
flush();
|
||||
return ops;
|
||||
}
|
||||
|
||||
function Operation({ op }: { op: PatchOp }) {
|
||||
const label = OP_LABEL[op.kind] ?? "file";
|
||||
const total = op.oldLines.length + op.newLines.length;
|
||||
const truncated = total > DIFF_PREVIEW_LINES;
|
||||
const oldBudget = truncated && total > 0 ? Math.round(DIFF_PREVIEW_LINES * (op.oldLines.length / total)) : op.oldLines.length;
|
||||
const newBudget = truncated ? DIFF_PREVIEW_LINES - oldBudget : op.newLines.length;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-sky-400/80 font-semibold text-sm shrink-0">{label}</span>
|
||||
{op.path && <span className="text-[#888] font-mono text-[13px] break-all">{shortPath(op.path)}</span>}
|
||||
</div>
|
||||
{(op.oldLines.length > 0 || op.newLines.length > 0) && (
|
||||
<div className="font-mono text-[13px] leading-relaxed mt-1.5">
|
||||
{op.oldLines.slice(0, oldBudget).map((line, i) => (
|
||||
<div key={`o${i}`} className="text-red-400/60">
|
||||
<span className="select-none text-red-400/30 mr-1">-</span>{line}
|
||||
</div>
|
||||
))}
|
||||
{op.newLines.slice(0, newBudget).map((line, i) => (
|
||||
<div key={`n${i}`} className="text-emerald-400/60">
|
||||
<span className="select-none text-emerald-400/30 mr-1">+</span>{line}
|
||||
</div>
|
||||
))}
|
||||
{truncated && <div className="text-[#444] mt-0.5">... {total - DIFF_PREVIEW_LINES} more lines</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ApplyPatchRenderer({ args, result, status }: ToolRendererProps) {
|
||||
const ops = parsePatchOperations(extractPatchText(args));
|
||||
|
||||
if (ops.length === 0) {
|
||||
return (
|
||||
<div>
|
||||
<span className="text-sky-400/80 font-semibold text-sm">patch</span>
|
||||
{status === "failed" && typeof result === "string" && result.trim() && (
|
||||
<div className="text-red-400/70 text-[13px] mt-1">{result.trim()}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{ops.map((op, i) => (
|
||||
<Operation key={i} op={op} />
|
||||
))}
|
||||
{status === "failed" && typeof result === "string" && result.trim() && (
|
||||
<div className="text-red-400/70 text-[13px]">{result.trim()}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
import type { ToolRendererProps } from "@/types/events";
|
||||
import { SyntaxBlock } from "./ToolCard";
|
||||
|
||||
const SIMPLE_ACTIONS: Record<string, string> = {
|
||||
back: "going back in browser history",
|
||||
forward: "going forward in browser history",
|
||||
scroll_down: "scrolling down",
|
||||
scroll_up: "scrolling up",
|
||||
refresh: "refreshing",
|
||||
close_tab: "closing tab",
|
||||
switch_tab: "switching tab",
|
||||
list_tabs: "listing tabs",
|
||||
view_source: "viewing page source",
|
||||
get_console_logs: "getting console logs",
|
||||
screenshot: "taking screenshot",
|
||||
wait: "waiting...",
|
||||
close: "closing",
|
||||
};
|
||||
|
||||
const CLICK_ACTIONS: Record<string, string> = {
|
||||
click: "clicking",
|
||||
double_click: "double clicking",
|
||||
hover: "hovering",
|
||||
};
|
||||
|
||||
function UrlLabel({ prefix, url, suffix }: { prefix: string; url?: string; suffix?: string }) {
|
||||
return (
|
||||
<span className="text-[#888] text-[13px]">
|
||||
{prefix}
|
||||
{url && (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-cyan-400/80 hover:underline"
|
||||
>
|
||||
{url}
|
||||
</a>
|
||||
)}
|
||||
{suffix}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function describeAction(args: Record<string, unknown>): React.ReactNode {
|
||||
const action = (args.action as string) ?? "";
|
||||
const url = (args.url as string) ?? undefined;
|
||||
|
||||
// Simple actions (no extra args)
|
||||
if (action in SIMPLE_ACTIONS) return SIMPLE_ACTIONS[action];
|
||||
|
||||
// URL actions: launch, goto, new_tab
|
||||
if (action === "launch") {
|
||||
if (!url) return "launching";
|
||||
return <UrlLabel prefix="launching " url={url} />;
|
||||
}
|
||||
if (action === "goto" || action === "navigate") {
|
||||
return <UrlLabel prefix="navigating to " url={url} />;
|
||||
}
|
||||
if (action === "new_tab") {
|
||||
return <UrlLabel prefix="opening tab " url={url} />;
|
||||
}
|
||||
|
||||
// Click actions
|
||||
if (action in CLICK_ACTIONS) return CLICK_ACTIONS[action];
|
||||
|
||||
// Type
|
||||
if (action === "type") {
|
||||
const text = ((args.text as string) ?? "").slice(0, 40);
|
||||
return `typing "${text}"`;
|
||||
}
|
||||
|
||||
// Key press
|
||||
if (action === "press_key" || action === "key_press") {
|
||||
return `pressing key ${(args.key as string) ?? ""}`;
|
||||
}
|
||||
|
||||
// Save PDF
|
||||
if (action === "save_pdf" || action === "save_as_pdf") {
|
||||
const path = (args.file_path as string) ?? "";
|
||||
return `saving PDF${path ? ` to ${path}` : ""}`;
|
||||
}
|
||||
|
||||
// Execute JS — description only, code shown separately
|
||||
if (action === "execute_js") return "executing javascript";
|
||||
|
||||
return action || "browser action";
|
||||
}
|
||||
|
||||
export default function BrowserRenderer({ args }: ToolRendererProps) {
|
||||
const action = (args.action as string) ?? "";
|
||||
const jsCode = action === "execute_js"
|
||||
? ((args.js_code as string) ?? (args.code as string) ?? "")
|
||||
: "";
|
||||
const description = describeAction(args);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-blue-400/80 font-semibold text-sm shrink-0">Browser</span>
|
||||
<span className="min-w-0 truncate text-[#888] text-[13px]">
|
||||
{typeof description === "string" ? description : description}
|
||||
</span>
|
||||
</div>
|
||||
{jsCode && <SyntaxBlock code={jsCode} language="javascript" collapsible />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { TruncatedText } from "./ToolCard";
|
||||
|
||||
interface ChatBubbleProps {
|
||||
role: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
const MAX_LINES = 30;
|
||||
|
||||
export default function ChatBubble({ role, content }: ChatBubbleProps) {
|
||||
const isUser = role === "user" || role === "human";
|
||||
|
||||
return (
|
||||
<div>
|
||||
<span className={`font-semibold text-sm ${isUser ? "text-blue-400/80" : "text-purple-400/80"}`}>
|
||||
{isUser ? "User" : "Thinking"}
|
||||
</span>
|
||||
<div className="mt-1.5 italic text-[#888]">
|
||||
<TruncatedText text={content} maxLines={MAX_LINES} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { ToolRendererProps } from "@/types/events";
|
||||
import { CodeBlock } from "./ToolCard";
|
||||
|
||||
/**
|
||||
* Generic renderer for tool names without a dedicated family renderer. Shows the
|
||||
* humanized tool name plus a pretty-printed dump of args/result. Tolerates the
|
||||
* server sending args/result as either a parsed object or an unparseable
|
||||
* Python-repr string (which arrives here wrapped as { __raw }); never crashes.
|
||||
*/
|
||||
function pretty(value: unknown): string | null {
|
||||
if (value == null) return null;
|
||||
if (typeof value === "string") return value.trim() ? value : null;
|
||||
if (typeof value === "object") {
|
||||
const rec = value as Record<string, unknown>;
|
||||
if (typeof rec.__raw === "string") return rec.__raw;
|
||||
if (Object.keys(rec).length === 0) return null;
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export default function FallbackRenderer({ toolName, args, result }: ToolRendererProps) {
|
||||
const argsText = pretty(args);
|
||||
const resultText = pretty(result);
|
||||
return (
|
||||
<div>
|
||||
<span className="text-[#888] font-semibold text-sm">{toolName.replace(/_/g, " ")}</span>
|
||||
{argsText && <CodeBlock className="text-[#777]">{argsText}</CodeBlock>}
|
||||
{resultText && <CodeBlock className="text-[#666]">{resultText}</CodeBlock>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import type { ToolRendererProps } from "@/types/events";
|
||||
import { shortPath } from "./utils";
|
||||
|
||||
const DIFF_PREVIEW_LINES = 30;
|
||||
|
||||
export default function FileEditRenderer({ toolName, args }: ToolRendererProps) {
|
||||
const filePath = (args.path as string) ?? (args.file_path as string) ?? "";
|
||||
const command = (args.command as string) ?? "";
|
||||
const oldStr = (args.old_str as string) ?? "";
|
||||
const newStr = (args.new_str as string) ?? "";
|
||||
const regex = (args.regex as string) ?? "";
|
||||
|
||||
let label: string;
|
||||
if (toolName === "list_files") label = "list";
|
||||
else if (toolName === "search_files") label = "search";
|
||||
else if (command === "view") label = "view";
|
||||
else if (command === "create") label = "create";
|
||||
else if (command === "str_replace") label = "edit";
|
||||
else if (command === "undo_edit") label = "undo";
|
||||
else if (command === "insert") label = "insert";
|
||||
else label = "file";
|
||||
|
||||
const pathDisplay = filePath ? shortPath(filePath) : "";
|
||||
const regexDisplay = regex ? ` /${regex}/` : "";
|
||||
|
||||
const oldLines = oldStr ? oldStr.split("\n") : [];
|
||||
const newLines = newStr ? newStr.split("\n") : [];
|
||||
const totalLines = oldLines.length + newLines.length;
|
||||
const truncated = totalLines > DIFF_PREVIEW_LINES;
|
||||
|
||||
// If truncated, split the budget proportionally
|
||||
const oldBudget = truncated ? Math.round(DIFF_PREVIEW_LINES * (oldLines.length / totalLines)) : oldLines.length;
|
||||
const newBudget = truncated ? DIFF_PREVIEW_LINES - oldBudget : newLines.length;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-sky-400/80 font-semibold text-sm shrink-0">{label}</span>
|
||||
{pathDisplay && <span className="text-[#888] font-mono text-[13px] break-all">{pathDisplay}</span>}
|
||||
</div>
|
||||
{regexDisplay && (
|
||||
<div className="text-purple-400/60 font-mono text-[13px] break-all mt-0.5">{regexDisplay}</div>
|
||||
)}
|
||||
{(oldStr || newStr) && (
|
||||
<div className="font-mono text-[13px] leading-relaxed mt-1.5">
|
||||
{oldLines.slice(0, oldBudget).map((line, i) => (
|
||||
<div key={`o${i}`} className="text-red-400/60">
|
||||
<span className="select-none text-red-400/30 mr-1">-</span>{line}
|
||||
</div>
|
||||
))}
|
||||
{newLines.slice(0, newBudget).map((line, i) => (
|
||||
<div key={`n${i}`} className="text-emerald-400/60">
|
||||
<span className="select-none text-emerald-400/30 mr-1">+</span>{line}
|
||||
</div>
|
||||
))}
|
||||
{truncated && (
|
||||
<div className="text-[#444] mt-0.5">... {totalLines - DIFF_PREVIEW_LINES} more lines</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import type { ToolRendererProps } from "@/types/events";
|
||||
import { TruncatedText } from "./ToolCard";
|
||||
|
||||
export default function FinishRenderer({ args }: ToolRendererProps) {
|
||||
const executiveSummary = (args.executive_summary as string) ?? "";
|
||||
const methodology = (args.methodology as string) ?? "";
|
||||
const technicalAnalysis = (args.technical_analysis as string) ?? "";
|
||||
const recommendations = (args.recommendations as string) ?? "";
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<span className="text-emerald-400/80 font-semibold text-sm">Penetration test completed</span>
|
||||
{executiveSummary && (
|
||||
<div><span className="text-emerald-400/60 text-sm font-semibold">Executive Summary</span><div className="mt-1"><TruncatedText text={executiveSummary} maxLines={25} /></div></div>
|
||||
)}
|
||||
{methodology && (
|
||||
<div><span className="text-emerald-400/60 text-sm font-semibold">Methodology</span><div className="mt-1"><TruncatedText text={methodology} maxLines={25} /></div></div>
|
||||
)}
|
||||
{technicalAnalysis && (
|
||||
<div><span className="text-emerald-400/60 text-sm font-semibold">Technical Analysis</span><div className="mt-1"><TruncatedText text={technicalAnalysis} maxLines={25} /></div></div>
|
||||
)}
|
||||
{recommendations && (
|
||||
<div><span className="text-emerald-400/60 text-sm font-semibold">Recommendations</span><div className="mt-1"><TruncatedText text={recommendations} maxLines={25} /></div></div>
|
||||
)}
|
||||
{!executiveSummary && !methodology && !technicalAnalysis && !recommendations && (
|
||||
<div className="text-[#555] text-xs">Generating final report...</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import type { ToolRendererProps } from "@/types/events";
|
||||
|
||||
export default function LoadSkillRenderer({ args }: ToolRendererProps) {
|
||||
const requestedRaw = (args.skills as string) ?? "";
|
||||
const requestedSkills = requestedRaw
|
||||
.split(",")
|
||||
.map((skill) => skill.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-emerald-400/80 font-semibold text-sm">Loading skill</span>
|
||||
{requestedSkills.length > 0 && (
|
||||
<span className="text-[#888] text-[13px]">{requestedSkills.join(", ")}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { rehypeCodeMeta, mdComponents } from "@/components/vulnerability/MdCodeBlock";
|
||||
|
||||
interface MarkdownProps {
|
||||
text: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function Markdown({ text, className = "" }: MarkdownProps) {
|
||||
return (
|
||||
<div className={`prose-markdown ${className}`}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeCodeMeta]}
|
||||
components={mdComponents}
|
||||
>
|
||||
{text}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import type { ToolRendererProps } from "@/types/events";
|
||||
import Markdown from "./Markdown";
|
||||
|
||||
export default function NotesRenderer({ toolName, args, result }: ToolRendererProps) {
|
||||
if (toolName === "create_note") {
|
||||
const title = (args.title as string) ?? "";
|
||||
const content = (args.content as string) ?? "";
|
||||
const category = (args.category as string) ?? "general";
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-amber-400/80 font-semibold text-sm">note</span>
|
||||
<span className="text-[#555] text-[13px]">({category})</span>
|
||||
</div>
|
||||
{title && <div className="mt-1.5 text-[#999] text-[13px]">{title}</div>}
|
||||
{content && <div className="mt-1"><Markdown text={content} /></div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (toolName === "delete_note") {
|
||||
return <span className="text-amber-400/80 font-semibold text-sm">note removed</span>;
|
||||
}
|
||||
|
||||
if (toolName === "update_note") {
|
||||
const title = (args.title as string) ?? "";
|
||||
const content = (args.content as string) ?? "";
|
||||
return (
|
||||
<div>
|
||||
<span className="text-amber-400/80 font-semibold text-sm">note updated</span>
|
||||
{title && <div className="mt-1.5 text-[#999] text-[13px]">{title}</div>}
|
||||
{content && <div className="mt-1"><Markdown text={content} /></div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (toolName === "get_note") {
|
||||
const res = result as Record<string, unknown> | null;
|
||||
const note = res && typeof res === "object" && res.success
|
||||
? (res.note as Record<string, string> | undefined)
|
||||
: undefined;
|
||||
return (
|
||||
<div>
|
||||
<span className="text-amber-400/80 font-semibold text-sm">note read</span>
|
||||
{note && (
|
||||
<>
|
||||
<div className="mt-1.5 text-[#999] text-[13px]">
|
||||
{note.title ?? "(untitled)"}
|
||||
<span className="text-[#555] ml-1">({note.category ?? "general"})</span>
|
||||
</div>
|
||||
{note.content && <div className="mt-1"><Markdown text={note.content} /></div>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (toolName === "list_notes") {
|
||||
const res = result as Record<string, unknown> | null;
|
||||
let notes: Array<Record<string, string>> = [];
|
||||
if (res && typeof res === "object" && res.success) {
|
||||
const rawNotes = res.notes;
|
||||
notes = Array.isArray(rawNotes) ? rawNotes as Array<Record<string, string>> : [];
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<span className="text-amber-400/80 font-semibold text-sm">notes</span>
|
||||
{notes.length > 0 ? (
|
||||
<div className="mt-1.5 space-y-0.5">
|
||||
{notes.map((n, i) => (
|
||||
<div key={i} className="text-[13px]">
|
||||
<span className="text-[#555] mr-1">-</span>
|
||||
<span className="text-[#999]">{n.title ?? "(untitled)"}</span>
|
||||
<span className="text-[#555] ml-1">({n.category ?? "general"})</span>
|
||||
{n.content && <div className="ml-3"><Markdown text={n.content} /></div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : <div className="mt-1 text-[#555] text-xs">No notes</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <span className="text-amber-400/80 font-semibold text-sm">note</span>;
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
"use client";
|
||||
|
||||
import type { ToolRendererProps } from "@/types/events";
|
||||
import { CodeBlock } from "./ToolCard";
|
||||
|
||||
const MAX_LINE_LENGTH = 200;
|
||||
|
||||
const METHOD_COLORS: Record<string, string> = {
|
||||
GET: "text-emerald-400/80", POST: "text-blue-400/80", PUT: "text-yellow-400/80",
|
||||
PATCH: "text-orange-400/80", DELETE: "text-red-400/80",
|
||||
};
|
||||
|
||||
function statusColor(code: number): string {
|
||||
if (code < 300) return "text-emerald-400/80";
|
||||
if (code < 400) return "text-yellow-400/80";
|
||||
if (code < 500) return "text-orange-400/80";
|
||||
return "text-red-400/80";
|
||||
}
|
||||
|
||||
/** Hard truncate with trailing "..." */
|
||||
function trunc(text: string, maxLen = 80): string {
|
||||
return text.length > maxLen ? text.slice(0, maxLen - 3) + "..." : text;
|
||||
}
|
||||
|
||||
/** Replace newlines/tabs, then truncate */
|
||||
function sanitize(text: string, maxLen = 150): string {
|
||||
return trunc(text.replace(/\n/g, " ").replace(/\r/g, "").replace(/\t/g, " "), maxLen);
|
||||
}
|
||||
|
||||
/** Limit body to maxLines, each truncated to MAX_LINE_LENGTH-5; returns display string */
|
||||
function limitBody(body: string, maxLines: number): string {
|
||||
const lines = body.split("\n");
|
||||
const display = lines.slice(0, maxLines).map(l => trunc(l, MAX_LINE_LENGTH - 5)).join("\n");
|
||||
return lines.length > maxLines ? display + "\n..." : display;
|
||||
}
|
||||
|
||||
function ListRequests({ args, result }: ToolRendererProps) {
|
||||
const filter = (args.httpql_filter as string) ?? "";
|
||||
const res = result as Record<string, unknown> | null;
|
||||
const rawReqs = res ? res.requests : null;
|
||||
const requests = Array.isArray(rawReqs) ? rawReqs as Array<Record<string, unknown>> : [];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-purple-400/80 font-semibold text-sm">listing requests</span>
|
||||
{filter && <span className="text-[#888] text-[13px]">{trunc(filter, 150)}</span>}
|
||||
</div>
|
||||
{requests.length > 0 && (
|
||||
<div className="mt-1.5 font-mono text-[13px] space-y-0.5">
|
||||
{requests.slice(0, 20).map((r, i) => {
|
||||
const m = ((r.method as string) ?? "GET").toUpperCase();
|
||||
const host = (r.host as string) ?? "";
|
||||
const path = (r.path as string) ?? "";
|
||||
const resp = r.response as Record<string, unknown> | undefined;
|
||||
const sc = (resp?.statusCode as number) ?? null;
|
||||
return (
|
||||
<div key={i} className="flex gap-2">
|
||||
<span className={`w-10 shrink-0 font-bold ${METHOD_COLORS[m] ?? "text-[#888]"}`}>{m}</span>
|
||||
<span className="text-[#777] truncate">{trunc(host + path, 180)}</span>
|
||||
{sc != null && <span className={`ml-auto shrink-0 ${statusColor(sc)}`}>{sc}</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{requests.length > 20 && <div className="text-[#555]">... +{requests.length - 20} more</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ViewRequest({ args, result }: ToolRendererProps) {
|
||||
const requestId = args.request_id as number | undefined;
|
||||
const part = (args.part as string) ?? "request";
|
||||
const searchPattern = (args.search_pattern as string) ?? "";
|
||||
const res = result as Record<string, unknown> | null;
|
||||
const rawMatches = res ? res.matches : null;
|
||||
const matches = Array.isArray(rawMatches) ? rawMatches as Array<Record<string, string>> : [];
|
||||
const content = res ? (res.content as string) ?? null : null;
|
||||
const hasMore = res ? !!(res.has_more) : false;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-purple-400/80 font-semibold text-sm">{searchPattern ? "searching" : "viewing"} {part}</span>
|
||||
{requestId != null && <span className="text-[#888] text-[13px]">#{requestId}</span>}
|
||||
{searchPattern && <span className="text-[#666] font-mono text-[13px]">/{trunc(searchPattern, 100)}/</span>}
|
||||
</div>
|
||||
{matches.length > 0 && (
|
||||
<div className="mt-1.5 font-mono text-[13px] space-y-1">
|
||||
{matches.slice(0, 5).map((m, i) => {
|
||||
// Sanitize context: replace newlines with space, trim to 100 chars
|
||||
const before = ((m.before ?? "").replace(/\n/g, " ").replace(/\r/g, "")).slice(-100);
|
||||
const after = ((m.after ?? "").replace(/\n/g, " ").replace(/\r/g, "")).slice(0, 100);
|
||||
return (
|
||||
<div key={i}>
|
||||
{before && <span className="text-[#555]">...{before}</span>}
|
||||
<span className="text-amber-400/80 font-bold">{m.match}</span>
|
||||
{after && <span className="text-[#555]">{after}...</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{matches.length > 5 && <div className="text-[#555]">... +{matches.length - 5} more matches</div>}
|
||||
</div>
|
||||
)}
|
||||
{content && !matches.length && (() => {
|
||||
const lines = content.split("\n");
|
||||
const display = lines.slice(0, 15).map(l => trunc(l, MAX_LINE_LENGTH)).join("\n");
|
||||
const showMore = hasMore || lines.length > 15;
|
||||
return (
|
||||
<CodeBlock className="text-[#666]">
|
||||
{display + (showMore ? "\n... more content available" : "")}
|
||||
</CodeBlock>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SendRequest({ args, result }: ToolRendererProps) {
|
||||
const method = ((args.method as string) ?? "GET").toUpperCase();
|
||||
const url = (args.url as string) ?? "";
|
||||
const headers = args.headers as Record<string, string> | undefined;
|
||||
const rawBody = args.body;
|
||||
const reqBody = typeof rawBody === "string" ? rawBody : "";
|
||||
const res = result as Record<string, unknown> | null;
|
||||
const error = res ? (res.error as string) ?? null : null;
|
||||
const statusCode = res ? (res.status_code as number) ?? null : null;
|
||||
const responseTime = res ? (res.response_time_ms as number) ?? null : null;
|
||||
const rawResBody = res ? res.body : null;
|
||||
const resBody = typeof rawResBody === "string" ? rawResBody : null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<span className="text-purple-400/80 font-semibold text-sm">request</span>
|
||||
<div className="mt-1.5 font-mono text-[13px] space-y-0.5">
|
||||
<div>
|
||||
<span className="text-[#555] select-none mr-1">>></span>
|
||||
<span className={`font-bold ${METHOD_COLORS[method] ?? "text-[#888]"}`}>{method}</span>
|
||||
<span className="text-[#888] ml-1 break-all">{trunc(url, 180)}</span>
|
||||
</div>
|
||||
{headers && typeof headers === "object" && Object.entries(headers).slice(0, 5).map(([k, v]) => (
|
||||
<div key={k} className="text-[#555] pl-5">{k}: {sanitize(String(v), 150)}</div>
|
||||
))}
|
||||
</div>
|
||||
{reqBody && (
|
||||
<CodeBlock className="text-[#888]">{limitBody(reqBody, 4)}</CodeBlock>
|
||||
)}
|
||||
{error && <div className="text-red-400/70 text-[13px] mt-1.5">{sanitize(error, 150)}</div>}
|
||||
{statusCode != null && (
|
||||
<div className="font-mono text-[13px] mt-1.5">
|
||||
<span className="text-[#555] select-none mr-1"><<</span>
|
||||
<span className={`font-bold ${statusColor(statusCode)}`}>{statusCode}</span>
|
||||
{responseTime != null && <span className="text-[#555] ml-2">{responseTime}ms</span>}
|
||||
</div>
|
||||
)}
|
||||
{resBody && (
|
||||
<CodeBlock className="text-[#666]">{limitBody(resBody, 6)}</CodeBlock>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RepeatRequest({ args, result }: ToolRendererProps) {
|
||||
const requestId = args.request_id as number | undefined;
|
||||
const modifications = args.modifications as Record<string, unknown> | undefined;
|
||||
const res = result as Record<string, unknown> | null;
|
||||
const statusCode = res ? (res.status_code as number) ?? null : null;
|
||||
const responseTime = res ? (res.response_time_ms as number) ?? null : null;
|
||||
const rawRepBody = res ? res.body : null;
|
||||
const resBody = typeof rawRepBody === "string" ? rawRepBody : null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-purple-400/80 font-semibold text-sm">repeating request</span>
|
||||
{requestId != null && <span className="text-[#888] text-[13px]">#{requestId}</span>}
|
||||
</div>
|
||||
{modifications && typeof modifications === "object" && Object.keys(modifications).length > 0 && (
|
||||
<div className="mt-1.5 font-mono text-[13px] space-y-0.5">
|
||||
{Object.entries(modifications).slice(0, 5).map(([k, v]) => (
|
||||
<div key={k}><span className="text-orange-400/60">{k}:</span> <span className="text-[#777]">{sanitize(typeof v === "string" ? v : JSON.stringify(v), 150)}</span></div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{statusCode != null && (
|
||||
<div className="font-mono text-[13px] mt-1.5">
|
||||
<span className="text-[#555] select-none mr-1"><<</span>
|
||||
<span className={`font-bold ${statusColor(statusCode)}`}>{statusCode}</span>
|
||||
{responseTime != null && <span className="text-[#555] ml-2">{responseTime}ms</span>}
|
||||
</div>
|
||||
)}
|
||||
{resBody && (
|
||||
<CodeBlock className="text-[#666]">{limitBody(resBody, 5)}</CodeBlock>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const SCOPE_ACTION: Record<string, string> = {
|
||||
get: "getting", list: "listing", create: "creating", update: "updating", delete: "deleting",
|
||||
};
|
||||
|
||||
function ScopeRules({ args }: ToolRendererProps) {
|
||||
const action = (args.action as string) ?? "";
|
||||
const scopeName = (args.scope_name as string) ?? "";
|
||||
const label = SCOPE_ACTION[action] ?? (action ? action : "managing");
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-purple-400/80 font-semibold text-sm">{label} proxy scope</span>
|
||||
{scopeName && <span className="text-[#888] text-[13px]">{trunc(scopeName, 50)}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ListSitemap({ args }: ToolRendererProps) {
|
||||
const parentId = args.parent_id as string | undefined;
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-purple-400/80 font-semibold text-sm">listing sitemap</span>
|
||||
{parentId && <span className="text-[#888] text-[13px]">under #{trunc(String(parentId), 20)}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ViewSitemapEntry({ args }: ToolRendererProps) {
|
||||
const entryId = args.entry_id as string | undefined;
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-purple-400/80 font-semibold text-sm">viewing sitemap entry</span>
|
||||
{entryId && <span className="text-[#888] text-[13px]">#{trunc(String(entryId), 20)}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ProxyRenderer(props: ToolRendererProps) {
|
||||
switch (props.toolName) {
|
||||
case "list_requests": return <ListRequests {...props} />;
|
||||
case "view_request": return <ViewRequest {...props} />;
|
||||
case "send_request": return <SendRequest {...props} />;
|
||||
case "repeat_request": return <RepeatRequest {...props} />;
|
||||
case "scope_rules": return <ScopeRules {...props} />;
|
||||
case "list_sitemap": return <ListSitemap {...props} />;
|
||||
case "view_sitemap_entry": return <ViewSitemapEntry {...props} />;
|
||||
default:
|
||||
return (
|
||||
<span className="text-purple-400/80 font-semibold text-sm">{props.toolName.replace(/_/g, " ")}</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import type { ToolRendererProps } from "@/types/events";
|
||||
import { CodeBlock, SyntaxBlock } from "./ToolCard";
|
||||
|
||||
const MAX_OUTPUT_LINES = 50;
|
||||
const MAX_LINE_LENGTH = 200;
|
||||
const HEAD = 25;
|
||||
const TAIL = 24;
|
||||
|
||||
// Full ANSI escape sequence pattern (matches Python's ANSI_PATTERN)
|
||||
const ANSI_PATTERN = /\x1b(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|\][^\x07]*\x07)/g;
|
||||
|
||||
// Strips truncation notices added by Python executor
|
||||
const STRIP_PATTERN = /\.\.\. \[(stdout|stderr|result|output|error) truncated at \d+k? chars\]/g;
|
||||
|
||||
function stripAnsi(text: string): string {
|
||||
return text.replace(ANSI_PATTERN, "");
|
||||
}
|
||||
|
||||
function truncateLine(line: string): string {
|
||||
const clean = stripAnsi(line);
|
||||
if (clean.length > MAX_LINE_LENGTH) return clean.slice(0, MAX_LINE_LENGTH - 3) + "...";
|
||||
return clean;
|
||||
}
|
||||
|
||||
function cleanOutput(output: string): string {
|
||||
return output.replace(STRIP_PATTERN, "").trim();
|
||||
}
|
||||
|
||||
function formatOutput(output: string): string {
|
||||
const lines = output.split("\n");
|
||||
if (lines.length <= MAX_OUTPUT_LINES) return lines.map(truncateLine).join("\n");
|
||||
const hiddenCount = lines.length - HEAD - TAIL;
|
||||
return [
|
||||
...lines.slice(0, HEAD).map(truncateLine),
|
||||
`... ${hiddenCount} lines truncated ...`,
|
||||
...lines.slice(-TAIL).map(truncateLine),
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export default function PythonRenderer({ args, result }: ToolRendererProps) {
|
||||
const action = (args.action as string) ?? "";
|
||||
const code = (args.code as string) ?? (args.script as string) ?? "";
|
||||
|
||||
const res = result as Record<string, unknown> | string | null;
|
||||
let stdout: string | null = null;
|
||||
if (res && typeof res === "object") stdout = typeof res.stdout === "string" ? res.stdout : null;
|
||||
else if (typeof res === "string") stdout = res;
|
||||
|
||||
const subtitle =
|
||||
action === "new_session" ? "new session" :
|
||||
action === "close" ? "close session" :
|
||||
action === "list_sessions" ? "list sessions" : null;
|
||||
|
||||
const output = stdout ? formatOutput(cleanOutput(stdout)) : null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-yellow-400/80 font-semibold text-sm">Python</span>
|
||||
{subtitle && <span className="text-[#888] text-[13px]">{subtitle}</span>}
|
||||
</div>
|
||||
{code && <SyntaxBlock code={code} language="python" collapsible />}
|
||||
{output && <CodeBlock className="text-[#666]">{output}</CodeBlock>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import type { ToolRendererProps } from "@/types/events";
|
||||
import { TruncatedText } from "./ToolCard";
|
||||
|
||||
function ScanStartInfo({ args }: ToolRendererProps) {
|
||||
const rawTargets = args.targets;
|
||||
const targets = Array.isArray(rawTargets) ? rawTargets : [];
|
||||
const targetNames = targets.map((t) => (typeof t === "object" && t ? (t.original as string) ?? null : null)).filter(Boolean) as string[];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-emerald-400/80 font-semibold text-sm">Starting penetration test</span>
|
||||
{targetNames.length === 1 && <span className="text-[#888] text-[13px]">on {targetNames[0]}</span>}
|
||||
</div>
|
||||
{targetNames.length > 1 && (
|
||||
<div className="mt-1.5 space-y-0.5">
|
||||
{targetNames.map((t, i) => (
|
||||
<div key={i} className="text-[13px] text-[#888]"><span className="text-[#555] mr-1">•</span>{t}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SubagentStartInfo({ args }: ToolRendererProps) {
|
||||
const name = (args.name as string) ?? "Unknown Agent";
|
||||
const task = (args.task as string) ?? "";
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[#888] text-[13px]">subagent</span>
|
||||
<span className="text-purple-400 font-semibold text-sm">{name}</span>
|
||||
</div>
|
||||
{task && <div className="mt-1.5"><TruncatedText text={task} maxLines={15} /></div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ScanInfoRenderer(props: ToolRendererProps) {
|
||||
if (props.toolName === "subagent_start_info") return <SubagentStartInfo {...props} />;
|
||||
return <ScanStartInfo {...props} />;
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
"use client";
|
||||
|
||||
import type { ToolRendererProps } from "@/types/events";
|
||||
import { CodeBlock, SyntaxBlock } from "./ToolCard";
|
||||
|
||||
const MAX_OUTPUT_LINES = 50;
|
||||
const MAX_LINE_LENGTH = 200;
|
||||
const HEAD = 25;
|
||||
const TAIL = 24;
|
||||
|
||||
const STRIP_PATTERNS: RegExp[] = [
|
||||
/\n?\[Command still running after [\d.]+s - showing output so far\.?\s*(?:Use C-c to interrupt if needed\.)?\]/g,
|
||||
/^\[Below is the output of the previous command\.\]\n?/gm,
|
||||
/^No command is currently running\. Cannot send input\.$/gm,
|
||||
/^A command is already running\. Use is_input=true to send input to it, or interrupt it first \(e\.g\., with C-c\)\.$/gm,
|
||||
];
|
||||
|
||||
// Terminal-tool chunk metadata (the OSS engine's shell tool prepends these; the
|
||||
// TUI strips them in strix/interface/tui/renderers/shell_renderer.py). Only a
|
||||
// contiguous block anchored on a "Chunk ID:" line is stripped, so identical
|
||||
// text inside real command output is left untouched.
|
||||
const CHUNK_PREAMBLE_START = /^Chunk ID: [0-9a-f]+\s*$/;
|
||||
const CHUNK_PREAMBLE_METADATA: RegExp[] = [
|
||||
/^Wall time: [\d.]+ seconds\s*$/,
|
||||
/^Process exited with code -?\d+\s*$/,
|
||||
/^Process running with session ID \d+\s*$/,
|
||||
/^Original token count: \d+\s*$/,
|
||||
];
|
||||
|
||||
function stripChunkPreambles(lines: string[]): string[] {
|
||||
const out: string[] = [];
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (CHUNK_PREAMBLE_START.test(lines[i])) {
|
||||
let j = i + 1;
|
||||
while (j < lines.length && CHUNK_PREAMBLE_METADATA.some((p) => p.test(lines[j]))) j++;
|
||||
if (j < lines.length && lines[j].trim() === "Output:") j++;
|
||||
i = j - 1;
|
||||
continue;
|
||||
}
|
||||
out.push(lines[i]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function escapeRegex(s: string): string {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function truncateLine(line: string): string {
|
||||
if (line.length > MAX_LINE_LENGTH) return line.slice(0, MAX_LINE_LENGTH - 3) + "...";
|
||||
return line;
|
||||
}
|
||||
|
||||
function cleanOutput(raw: string, command: string = ""): string {
|
||||
// Strip ANSI escape sequences and carriage returns
|
||||
let cleaned = raw.replace(/\x1b(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|\][^\x07]*\x07)/g, "").replace(/\r/g, "");
|
||||
|
||||
for (const pattern of STRIP_PATTERNS) {
|
||||
cleaned = cleaned.replace(pattern, "");
|
||||
}
|
||||
|
||||
if (cleaned.trim()) {
|
||||
const lines = stripChunkPreambles(cleaned.split("\n"));
|
||||
const filtered: string[] = [];
|
||||
for (const line of lines) {
|
||||
// Skip leading blank lines
|
||||
if (filtered.length === 0 && !line.trim()) continue;
|
||||
// Skip [STRIX_N]$ prompt lines
|
||||
if (/^\[STRIX_\d+\]\$\s*/.test(line)) continue;
|
||||
// Skip echoed command (plain)
|
||||
if (command && line.trim() === command.trim()) continue;
|
||||
// Skip echoed command with $/#/> prefix
|
||||
if (command && new RegExp(`^[\\$#>]\\s*${escapeRegex(command.trim())}\\s*$`).test(line)) continue;
|
||||
filtered.push(line);
|
||||
}
|
||||
// Strip trailing [STRIX_N]$ lines
|
||||
while (filtered.length > 0 && /^\[STRIX_\d+\]\$\s*/.test(filtered[filtered.length - 1])) {
|
||||
filtered.pop();
|
||||
}
|
||||
cleaned = filtered.join("\n");
|
||||
}
|
||||
|
||||
return cleaned.trim();
|
||||
}
|
||||
|
||||
function formatOutput(output: string): string {
|
||||
const lines = output.split("\n");
|
||||
if (lines.length <= MAX_OUTPUT_LINES) return lines.map(truncateLine).join("\n");
|
||||
const hiddenCount = lines.length - HEAD - TAIL;
|
||||
return [
|
||||
...lines.slice(0, HEAD).map(truncateLine),
|
||||
`... ${hiddenCount} lines truncated ...`,
|
||||
...lines.slice(-TAIL).map(truncateLine),
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export default function TerminalRenderer({ toolName, args, result }: ToolRendererProps) {
|
||||
const isStdin = toolName === "write_stdin";
|
||||
const command = isStdin
|
||||
? ((args.chars as string) ?? (args.input as string) ?? "")
|
||||
: ((args.command as string) ?? (args.cmd as string) ?? "");
|
||||
|
||||
const res = result as Record<string, unknown> | string | null;
|
||||
let content: string | null = null;
|
||||
let error: string | null = null;
|
||||
let exitCode: number | null = null;
|
||||
|
||||
if (res && typeof res === "object") {
|
||||
content = typeof res.content === "string" ? res.content : null;
|
||||
error = typeof res.error === "string" ? res.error : null;
|
||||
exitCode = typeof res.exit_code === "number" ? res.exit_code : null;
|
||||
const s = typeof res.status === "string" ? res.status : "";
|
||||
if (s === "running" || s === "command still running") content = null;
|
||||
} else if (typeof res === "string") {
|
||||
content = res;
|
||||
}
|
||||
|
||||
const output = content ? formatOutput(cleanOutput(content, command)) : null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<span className="text-emerald-400/80 font-semibold text-sm">{isStdin ? "Terminal input" : "Terminal"}</span>
|
||||
{command && <SyntaxBlock code={command} language="bash" collapsible />}
|
||||
{error && <CodeBlock className="text-red-400/70">{error}</CodeBlock>}
|
||||
{output && <CodeBlock className="text-[#666]">{output}</CodeBlock>}
|
||||
{exitCode != null && exitCode !== 0 && (
|
||||
<div className="font-mono text-[13px] text-red-400/70 mt-0.5">exit code {exitCode}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import type { ToolRendererProps } from "@/types/events";
|
||||
import { TruncatedText } from "./ToolCard";
|
||||
|
||||
export default function ThinkRenderer({ args }: ToolRendererProps) {
|
||||
const thought = (args.thought as string) ?? (args.content as string) ?? "";
|
||||
if (!thought) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<span className="text-purple-400/80 font-semibold text-sm">Agent is thinking</span>
|
||||
<div className="mt-1.5 italic text-[#888]">
|
||||
<TruncatedText text={thought} maxLines={20} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
"use client";
|
||||
|
||||
import type { ToolRendererProps } from "@/types/events";
|
||||
import { Circle, CircleDot, CircleCheckBig, Trash2, Plus, RefreshCw, CheckCheck, RotateCcw, Pencil } from "lucide-react";
|
||||
|
||||
interface TodoItem {
|
||||
id?: string;
|
||||
title?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
const ACTION_LABELS: Record<string, { label: string; Icon: typeof Circle }> = {
|
||||
create_todo: { label: "Task added", Icon: Plus },
|
||||
list_todos: { label: "Plan", Icon: CheckCheck },
|
||||
update_todo: { label: "Task updated", Icon: Pencil },
|
||||
mark_todo_done: { label: "Task completed", Icon: CircleCheckBig },
|
||||
mark_todo_pending: { label: "Task reopened", Icon: RotateCcw },
|
||||
delete_todo: { label: "Task removed", Icon: Trash2 },
|
||||
};
|
||||
|
||||
function StatusIcon({ status }: { status: string }) {
|
||||
if (status === "done") return <CircleCheckBig className="w-3.5 h-3.5 text-emerald-400/70 shrink-0" />;
|
||||
if (status === "in_progress") return <CircleDot className="w-3.5 h-3.5 text-purple-400/70 shrink-0 animate-pulse" />;
|
||||
return <Circle className="w-3.5 h-3.5 text-[#444] shrink-0" />;
|
||||
}
|
||||
|
||||
function TodoList({ todos, highlightId }: { todos: TodoItem[]; highlightId?: string }) {
|
||||
return (
|
||||
<div className="space-y-0">
|
||||
{todos.map((todo, i) => {
|
||||
const s = todo.status ?? "pending";
|
||||
const isHighlighted = highlightId && todo.id === highlightId;
|
||||
return (
|
||||
<div
|
||||
key={todo.id ?? i}
|
||||
className={`flex items-start gap-2.5 py-1.5 px-2 -mx-2 rounded-md transition-colors ${
|
||||
isHighlighted ? "bg-purple-500/[0.08]" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="mt-[1px]">
|
||||
<StatusIcon status={s} />
|
||||
</div>
|
||||
<span
|
||||
className={`text-[13px] leading-snug ${
|
||||
s === "done"
|
||||
? "text-[#555] line-through"
|
||||
: s === "in_progress"
|
||||
? "text-[#bbb]"
|
||||
: "text-[#999]"
|
||||
}`}
|
||||
>
|
||||
{todo.title ?? "(untitled)"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TodoRenderer({ toolName, args, result }: ToolRendererProps) {
|
||||
const action = ACTION_LABELS[toolName] ?? { label: "Plan", Icon: RefreshCw };
|
||||
const ActionIcon = action.Icon;
|
||||
const res = result as Record<string, unknown> | string | null;
|
||||
|
||||
// Simple string result
|
||||
if (typeof res === "string" && res.trim()) {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<ActionIcon className="w-3.5 h-3.5 text-purple-400/60" />
|
||||
<span className="text-purple-400/80 font-semibold text-sm">{action.label}</span>
|
||||
</div>
|
||||
<div className="mt-1.5 text-[#888] text-[13px]">{res.trim()}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Parse structured result
|
||||
let todos: TodoItem[] = [];
|
||||
let error: string | null = null;
|
||||
let todoId: string | undefined;
|
||||
|
||||
if (res && typeof res === "object") {
|
||||
error = (res.error as string) ?? null;
|
||||
if (res.success) {
|
||||
const rawTodos = res.todos;
|
||||
todos = Array.isArray(rawTodos) ? (rawTodos as TodoItem[]) : [];
|
||||
}
|
||||
todoId = (res.id as string) ?? (args.todo_id as string) ?? undefined;
|
||||
}
|
||||
|
||||
// For mutations, highlight the affected item
|
||||
const highlightId = toolName !== "list_todos" ? todoId : undefined;
|
||||
|
||||
// No todos and no error — brief label only
|
||||
if (todos.length === 0 && !error) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<ActionIcon className="w-3.5 h-3.5 text-purple-400/60" />
|
||||
<span className="text-purple-400/80 font-semibold text-sm">{action.label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<ActionIcon className="w-3.5 h-3.5 text-purple-400/60" />
|
||||
<span className="text-purple-400/80 font-semibold text-sm">{action.label}</span>
|
||||
</div>
|
||||
{error && <div className="text-red-400/70 text-[13px] mb-2">{error}</div>}
|
||||
{todos.length > 0 && (
|
||||
<div className="rounded-lg border border-white/[0.06] bg-white/[0.015] px-3 py-2">
|
||||
<TodoList todos={todos} highlightId={highlightId} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Markdown from "./Markdown";
|
||||
import hljs from "@/lib/hljs";
|
||||
import "highlight.js/styles/github-dark.css";
|
||||
|
||||
const OUTPUT_PREVIEW_LINES = 6;
|
||||
const CODE_PREVIEW_LINES = 20;
|
||||
|
||||
/** Truncatable markdown text with "Show more" */
|
||||
export function TruncatedText({ text, maxLines = 20 }: { text: string; maxLines?: number }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const lines = text.trimEnd().split("\n");
|
||||
const needsTruncation = lines.length > maxLines;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className={expanded && needsTruncation ? "max-h-[1200px] overflow-auto" : ""}
|
||||
style={!expanded && needsTruncation ? { display: "-webkit-box", WebkitLineClamp: maxLines, WebkitBoxOrient: "vertical", overflow: "hidden" } : undefined}
|
||||
>
|
||||
<Markdown text={text} />
|
||||
</div>
|
||||
{needsTruncation && (
|
||||
<button onClick={() => setExpanded(!expanded)} className="text-xs text-[#555] hover:text-[#888] mt-1">
|
||||
{expanded ? "Show less" : "Show more"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Code/output block — truncates to 12 lines with "Show more", expanded view scrolls */
|
||||
export function CodeBlock({ children, className = "" }: { children: React.ReactNode; className?: string }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const isString = typeof children === "string";
|
||||
const lines = isString ? (children as string).trimEnd().split("\n") : null;
|
||||
const needsTruncation = lines !== null && lines.length > OUTPUT_PREVIEW_LINES;
|
||||
const displayContent = needsTruncation && !expanded
|
||||
? lines!.slice(0, OUTPUT_PREVIEW_LINES).join("\n")
|
||||
: children;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<pre className={`font-mono text-[13px] leading-relaxed whitespace-pre-wrap break-words mt-1 ${
|
||||
expanded ? "overflow-auto max-h-[1200px]" : "overflow-hidden"
|
||||
} ${className}`}>
|
||||
{displayContent}
|
||||
</pre>
|
||||
{needsTruncation && (
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="text-xs text-[#555] hover:text-[#888] mt-0.5"
|
||||
>
|
||||
{expanded ? "Show less" : "Show more"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Syntax-highlighted code block — no border, no line numbers, just highlighting.
|
||||
* Pass `collapsible` to get a "Show more" toggle instead of a scroll cap. */
|
||||
export function SyntaxBlock({ code, language, className = "", collapsible = false }: { code: string; language?: string; className?: string; collapsible?: boolean }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const lines = code.trimEnd().split("\n");
|
||||
const needsTruncation = collapsible && lines.length > CODE_PREVIEW_LINES;
|
||||
const displayCode = needsTruncation && !expanded
|
||||
? lines.slice(0, CODE_PREVIEW_LINES).join("\n")
|
||||
: code;
|
||||
|
||||
let highlighted: string;
|
||||
try {
|
||||
highlighted = language
|
||||
? hljs.highlight(displayCode, { language, ignoreIllegals: true }).value
|
||||
: hljs.highlightAuto(displayCode).value;
|
||||
} catch {
|
||||
highlighted = hljs.highlightAuto(displayCode).value;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<pre className={`font-mono text-[12px] leading-relaxed px-0 py-1 mt-1 whitespace-pre-wrap break-all ${
|
||||
collapsible
|
||||
? expanded ? "overflow-auto max-h-[1200px]" : "overflow-hidden"
|
||||
: "overflow-auto max-h-[400px]"
|
||||
} ${className}`}>
|
||||
<code dangerouslySetInnerHTML={{ __html: highlighted }} />
|
||||
</pre>
|
||||
{needsTruncation && (
|
||||
<button onClick={() => setExpanded(!expanded)} className="text-xs text-[#555] hover:text-[#888] mt-0.5">
|
||||
{expanded ? "Show less" : "Show more"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import type { ToolRendererProps } from "@/types/events";
|
||||
import { shortPath } from "./utils";
|
||||
|
||||
/** Mirrors the OSS TUI `ViewImageRenderer`: surfaces load errors, otherwise a
|
||||
* compact "view image <path>" line. */
|
||||
export default function ViewImageRenderer({ args, result }: ToolRendererProps) {
|
||||
const path = ((args.path as string) ?? "").trim();
|
||||
|
||||
const res = result as Record<string, unknown> | string | null;
|
||||
let error: string | null = null;
|
||||
if (typeof res === "string") {
|
||||
const trimmed = res.trim();
|
||||
// A string result that isn't an image payload or structured data is an error message
|
||||
if (trimmed && !trimmed.toLowerCase().startsWith("data:image/") && !trimmed.startsWith("{")) {
|
||||
error = trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-sky-400/80 font-semibold text-sm shrink-0">view image</span>
|
||||
{path && <span className="text-[#888] font-mono text-[13px] break-all">{shortPath(path)}</span>}
|
||||
</div>
|
||||
{error && <div className="text-red-400/70 text-[13px] mt-1">{error}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import type { ToolRendererProps } from "@/types/events";
|
||||
import { TruncatedText } from "./ToolCard";
|
||||
import { MdCodeBlock } from "@/components/vulnerability/MdCodeBlock";
|
||||
import Markdown from "./Markdown";
|
||||
|
||||
const SEVERITY_COLORS: Record<string, string> = {
|
||||
critical: "text-red-400", high: "text-orange-400", medium: "text-yellow-400",
|
||||
low: "text-blue-400", info: "text-cyan-400",
|
||||
};
|
||||
|
||||
export default function VulnReportRenderer({ args, result }: ToolRendererProps) {
|
||||
const title = (args.title as string) ?? "";
|
||||
const description = (args.description as string) ?? "";
|
||||
const impact = (args.impact as string) ?? "";
|
||||
const target = (args.target as string) ?? "";
|
||||
const endpoint = (args.endpoint as string) ?? "";
|
||||
const method = (args.method as string) ?? "";
|
||||
const technicalAnalysis = (args.technical_analysis as string) ?? "";
|
||||
const pocDescription = (args.poc_description as string) ?? "";
|
||||
const pocCode = (args.poc_script_code as string) ?? "";
|
||||
const remediation = (args.remediation_steps as string) ?? "";
|
||||
const cve = (args.cve as string) ?? "";
|
||||
const cwe = (args.cwe as string) ?? "";
|
||||
|
||||
const res = result as Record<string, unknown> | null;
|
||||
const rawSev = (res && typeof res === "object" ? res.severity : null) ?? args.severity ?? "medium";
|
||||
const severity = String(rawSev).toLowerCase();
|
||||
const cvss = (res && typeof res === "object" ? (res.cvss_score as number) : null) ?? (args.cvss as number) ?? null;
|
||||
const sevColor = SEVERITY_COLORS[severity] ?? "text-yellow-400";
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className={`font-semibold text-sm ${sevColor}`}>{severity.toUpperCase()}</span>
|
||||
{cvss != null && <span className="text-[#888] text-[13px]">CVSS {cvss}</span>}
|
||||
{cve && <span className="text-[#888] font-mono text-[13px]">{cve}</span>}
|
||||
{cwe && <span className="text-[#888] font-mono text-[13px]">{cwe}</span>}
|
||||
</div>
|
||||
{title && <div className="text-[15px] text-white/80 font-semibold">{title}</div>}
|
||||
{(target || endpoint) && (
|
||||
<div className="text-[13px] text-[#888] font-mono">{target}{endpoint ? ` ${method} ${endpoint}` : ""}</div>
|
||||
)}
|
||||
{description && <TruncatedText text={description} maxLines={20} />}
|
||||
{impact && (
|
||||
<div>
|
||||
<span className="text-emerald-400/60 text-sm font-semibold">Impact</span>
|
||||
<div className="mt-1"><TruncatedText text={impact} maxLines={15} /></div>
|
||||
</div>
|
||||
)}
|
||||
{technicalAnalysis && (
|
||||
<div>
|
||||
<span className="text-emerald-400/60 text-sm font-semibold">Technical Analysis</span>
|
||||
<div className="mt-1"><TruncatedText text={technicalAnalysis} maxLines={20} /></div>
|
||||
</div>
|
||||
)}
|
||||
{(pocDescription || pocCode) && (
|
||||
<div>
|
||||
<span className="text-emerald-400/60 text-sm font-semibold">Proof of Concept</span>
|
||||
{pocDescription && <div className="mt-1"><Markdown text={pocDescription} /></div>}
|
||||
{pocCode && <MdCodeBlock>{pocCode}</MdCodeBlock>}
|
||||
</div>
|
||||
)}
|
||||
{remediation && (
|
||||
<div>
|
||||
<span className="text-emerald-400/60 text-sm font-semibold">Remediation</span>
|
||||
<div className="mt-1"><TruncatedText text={remediation} maxLines={15} /></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import type { ToolRendererProps } from "@/types/events";
|
||||
import { TruncatedText } from "./ToolCard";
|
||||
|
||||
export default function WebSearchRenderer({ args, result }: ToolRendererProps) {
|
||||
const query = (args.query as string) ?? (args.search_query as string) ?? "";
|
||||
const res = result as Record<string, unknown> | null;
|
||||
const content = res ? (res.content as string) ?? null : null;
|
||||
const error = res && !res.success ? (res.message as string) ?? null : null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<span className="text-amber-400/80 font-semibold text-sm">Searching the web</span>
|
||||
{query && <div className="text-[#888] text-[13px] mt-0.5">{query}</div>}
|
||||
{error && <div className="text-red-400/70 text-[13px] mt-1.5">{error}</div>}
|
||||
{content && (
|
||||
<div className="mt-2">
|
||||
<TruncatedText text={content} maxLines={15} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import type { ComponentType } from "react";
|
||||
import type { ToolRendererProps } from "@/types/events";
|
||||
import {
|
||||
Terminal, Globe, FileText, ShieldAlert, ArrowUpRight, Brain,
|
||||
Bot, MessageCircle, Flag, Eye, Search, Code, StickyNote,
|
||||
ListTodo, Crosshair, Wrench, Ban, Image,
|
||||
} from "lucide-react";
|
||||
|
||||
import TerminalRenderer from "./TerminalRenderer";
|
||||
import BrowserRenderer from "./BrowserRenderer";
|
||||
import FileEditRenderer from "./FileEditRenderer";
|
||||
import ApplyPatchRenderer from "./ApplyPatchRenderer";
|
||||
import ViewImageRenderer from "./ViewImageRenderer";
|
||||
import VulnReportRenderer from "./VulnReportRenderer";
|
||||
import ProxyRenderer from "./ProxyRenderer";
|
||||
import ThinkRenderer from "./ThinkRenderer";
|
||||
import AgentCommsRenderer from "./AgentCommsRenderer";
|
||||
import WebSearchRenderer from "./WebSearchRenderer";
|
||||
import PythonRenderer from "./PythonRenderer";
|
||||
import ScanInfoRenderer from "./ScanInfoRenderer";
|
||||
import FinishRenderer from "./FinishRenderer";
|
||||
import NotesRenderer from "./NotesRenderer";
|
||||
import TodoRenderer from "./TodoRenderer";
|
||||
import FallbackRenderer from "./FallbackRenderer";
|
||||
import LoadSkillRenderer from "./LoadSkillRenderer";
|
||||
|
||||
/**
|
||||
* Tool-renderer mapping — data-driven, keyed by the engine's tool *family*.
|
||||
*
|
||||
* The OSS strix engine (usestrix/strix) is the source of truth for tool names:
|
||||
* see `strix/tools/**` for definitions and `strix/interface/tui/renderers/` for
|
||||
* the TUI equivalents of these components. Tools come in families that share a
|
||||
* React renderer + icon (terminal, proxy, notes, todos, …), so we describe each
|
||||
* family ONCE instead of repeating a row per tool name. A new tool that joins an
|
||||
* existing family (e.g. another `*_request` proxy tool) is picked up by the
|
||||
* family prefix matcher with no code change; only genuinely-new families need an
|
||||
* entry here.
|
||||
*/
|
||||
|
||||
export type ToolCategory =
|
||||
| "terminal"
|
||||
| "python"
|
||||
| "browser"
|
||||
| "filesystem"
|
||||
| "proxy"
|
||||
| "reporting"
|
||||
| "thinking"
|
||||
| "agents"
|
||||
| "search"
|
||||
| "lifecycle"
|
||||
| "notes"
|
||||
| "skills"
|
||||
| "todos"
|
||||
| "telemetry";
|
||||
|
||||
export interface ToolIconMeta {
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface CategoryMeta {
|
||||
renderer: ComponentType<ToolRendererProps>;
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
color: string;
|
||||
/** Family matcher for graceful fallback of unknown tools in this family. */
|
||||
match?: RegExp;
|
||||
}
|
||||
|
||||
/** Per-family defaults: renderer + base icon/color + a family-name matcher. */
|
||||
const CATEGORY_META: Record<ToolCategory, CategoryMeta> = {
|
||||
terminal: { renderer: TerminalRenderer, icon: Terminal, color: "text-emerald-400" },
|
||||
python: { renderer: PythonRenderer, icon: Code, color: "text-yellow-400" },
|
||||
browser: { renderer: BrowserRenderer, icon: Globe, color: "text-blue-400" },
|
||||
filesystem: { renderer: FileEditRenderer, icon: FileText, color: "text-sky-400" },
|
||||
proxy: { renderer: ProxyRenderer, icon: ArrowUpRight, color: "text-purple-400", match: /request|sitemap|scope/ },
|
||||
reporting: { renderer: VulnReportRenderer, icon: ShieldAlert, color: "text-red-400" },
|
||||
thinking: { renderer: ThinkRenderer, icon: Brain, color: "text-purple-400" },
|
||||
agents: { renderer: AgentCommsRenderer, icon: Bot, color: "text-cyan-400", match: /agent/ },
|
||||
search: { renderer: WebSearchRenderer, icon: Search, color: "text-amber-400" },
|
||||
lifecycle: { renderer: ScanInfoRenderer, icon: Flag, color: "text-emerald-400" },
|
||||
notes: { renderer: NotesRenderer, icon: StickyNote, color: "text-amber-400", match: /note/ },
|
||||
skills: { renderer: LoadSkillRenderer, icon: Wrench, color: "text-emerald-400" },
|
||||
todos: { renderer: TodoRenderer, icon: ListTodo, color: "text-purple-400", match: /todo/ },
|
||||
telemetry: { renderer: FallbackRenderer, icon: Wrench, color: "text-[#555]" },
|
||||
};
|
||||
|
||||
/**
|
||||
* Tool name → family. Grouped by family; legacy names the engine used before the
|
||||
* OSS SDK migration (terminal_execute, python_action, browser_action,
|
||||
* str_replace_editor, send_request, …) are kept as aliases so historical scan
|
||||
* data keeps rendering.
|
||||
*/
|
||||
const CATEGORY_TOOLS: Record<ToolCategory, readonly string[]> = {
|
||||
// Shell — SDK `exec_command` / `write_stdin` (legacy: terminal_execute)
|
||||
terminal: ["exec_command", "write_stdin", "terminal_execute"],
|
||||
// Legacy Python session tool (now runs through the shell)
|
||||
python: ["python_action"],
|
||||
// Legacy browser tool (now driven via agent-browser CLI over the shell)
|
||||
browser: ["browser_action"],
|
||||
// SDK filesystem — `apply_patch` / `view_image` (legacy: str_replace_editor, list/search)
|
||||
filesystem: ["apply_patch", "view_image", "str_replace_editor", "list_files", "search_files"],
|
||||
// Caido proxy tools (legacy: send_request)
|
||||
proxy: ["list_requests", "view_request", "repeat_request", "list_sitemap", "view_sitemap_entry", "scope_rules", "send_request"],
|
||||
reporting: ["create_vulnerability_report"],
|
||||
thinking: ["think"],
|
||||
agents: ["create_agent", "agent_finish", "send_message_to_agent", "wait_for_message", "view_agent_graph", "stop_agent"],
|
||||
search: ["web_search"],
|
||||
// scan_start_info / subagent_start_info are strix-app synthetic events; finish_scan is the engine's
|
||||
lifecycle: ["scan_start_info", "subagent_start_info", "finish_scan"],
|
||||
notes: ["create_note", "delete_note", "update_note", "list_notes", "get_note"],
|
||||
skills: ["load_skill"],
|
||||
todos: ["create_todo", "list_todos", "update_todo", "mark_todo_done", "mark_todo_pending", "delete_todo"],
|
||||
telemetry: ["sandbox_error_details", "llm_error_details"],
|
||||
};
|
||||
|
||||
/** Reverse index (tool name → family), built once from CATEGORY_TOOLS. */
|
||||
const TOOL_CATEGORY: Record<string, ToolCategory> = Object.fromEntries(
|
||||
(Object.entries(CATEGORY_TOOLS) as [ToolCategory, readonly string[]][]).flatMap(
|
||||
([category, names]) => names.map((name) => [name, category] as const),
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* Per-tool renderer overrides — for the rare tool whose renderer differs from its
|
||||
* family default (finish_scan renders the final report, not the scan-start card).
|
||||
*/
|
||||
const RENDERER_OVERRIDES: Partial<Record<string, ComponentType<ToolRendererProps>>> = {
|
||||
finish_scan: FinishRenderer,
|
||||
apply_patch: ApplyPatchRenderer,
|
||||
view_image: ViewImageRenderer,
|
||||
};
|
||||
|
||||
/**
|
||||
* Per-tool icon overrides — for tools whose icon/color differs from their family
|
||||
* default (the agents family and lifecycle family each vary per tool).
|
||||
*/
|
||||
const ICON_OVERRIDES: Partial<Record<string, ToolIconMeta>> = {
|
||||
agent_finish: { icon: Flag, color: "text-cyan-400" },
|
||||
send_message_to_agent: { icon: MessageCircle, color: "text-cyan-400" },
|
||||
wait_for_message: { icon: MessageCircle, color: "text-cyan-400" },
|
||||
view_agent_graph: { icon: Eye, color: "text-cyan-400" },
|
||||
stop_agent: { icon: Ban, color: "text-red-400" },
|
||||
scan_start_info: { icon: Crosshair, color: "text-emerald-400" },
|
||||
subagent_start_info: { icon: Bot, color: "text-purple-400" },
|
||||
view_image: { icon: Image, color: "text-sky-400" },
|
||||
};
|
||||
|
||||
const FALLBACK_META: CategoryMeta = CATEGORY_META.telemetry;
|
||||
|
||||
/** Resolve a tool name to its family, falling back to family-name matchers. */
|
||||
function resolveCategory(toolName: string): ToolCategory | null {
|
||||
const direct = TOOL_CATEGORY[toolName];
|
||||
if (direct) return direct;
|
||||
for (const [category, meta] of Object.entries(CATEGORY_META) as [ToolCategory, CategoryMeta][]) {
|
||||
if (meta.match?.test(toolName)) return category;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getToolRenderer(toolName: string): ComponentType<ToolRendererProps> {
|
||||
const override = RENDERER_OVERRIDES[toolName];
|
||||
if (override) return override;
|
||||
const category = resolveCategory(toolName);
|
||||
return category ? CATEGORY_META[category].renderer : FallbackRenderer;
|
||||
}
|
||||
|
||||
export function getToolIcon(toolName: string): ToolIconMeta {
|
||||
const override = ICON_OVERRIDES[toolName];
|
||||
if (override) return override;
|
||||
const category = resolveCategory(toolName);
|
||||
const meta = category ? CATEGORY_META[category] : FALLBACK_META;
|
||||
return { icon: meta.icon, color: meta.color };
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function shortPath(p: string): string {
|
||||
return p.length > 60 ? "..." + p.slice(-57) : p;
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import hljs from "@/lib/hljs";
|
||||
import "highlight.js/styles/github-dark.css";
|
||||
import { diffLines } from "diff";
|
||||
import { Copy, Check } from "lucide-react";
|
||||
import { getLanguageFromFile, copyToClipboard } from "@/lib/vulnerability-utils";
|
||||
|
||||
function safeHighlight(code: string, lang: string): string {
|
||||
try {
|
||||
return hljs.highlight(code, { language: lang, ignoreIllegals: true }).value;
|
||||
} catch {
|
||||
return hljs.highlightAuto(code).value;
|
||||
}
|
||||
}
|
||||
|
||||
interface CodeDiffBlockProps {
|
||||
file: string;
|
||||
startLine: number;
|
||||
endLine?: number;
|
||||
before: string;
|
||||
after: string;
|
||||
onCopy?: () => void;
|
||||
}
|
||||
|
||||
export function CodeDiffBlock({ file, startLine, endLine, before, after, onCopy }: CodeDiffBlockProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const lineRef =
|
||||
endLine && endLine !== startLine ? `${startLine}-${endLine}` : `${startLine}`;
|
||||
const lang = getLanguageFromFile(file) || "text";
|
||||
const changes = diffLines(before, after);
|
||||
|
||||
let oldLineNo = startLine;
|
||||
let newLineNo = startLine;
|
||||
const rows = changes.flatMap((change) =>
|
||||
change.value
|
||||
.replace(/\n$/, "")
|
||||
.split("\n")
|
||||
.map((line) => {
|
||||
const highlighted =
|
||||
line === ""
|
||||
? "\n"
|
||||
: lang !== "text"
|
||||
? safeHighlight(line, lang)
|
||||
: line.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
let leftNo = "";
|
||||
let rightNo = "";
|
||||
if (change.removed) {
|
||||
leftNo = String(oldLineNo++);
|
||||
} else if (change.added) {
|
||||
rightNo = String(newLineNo++);
|
||||
} else {
|
||||
leftNo = String(oldLineNo++);
|
||||
rightNo = String(newLineNo++);
|
||||
}
|
||||
return { highlighted, added: !!change.added, removed: !!change.removed, leftNo, rightNo };
|
||||
})
|
||||
);
|
||||
|
||||
const copy = () => {
|
||||
copyToClipboard(after);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
onCopy?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-[#2a2a2a] overflow-hidden">
|
||||
<div className="flex items-stretch">
|
||||
<span className="relative flex items-center text-[13px] text-[#999] font-mono px-4 py-2 border-r border-[#2a2a2a] break-all">
|
||||
{file}:{lineRef}
|
||||
<span className="absolute top-0 inset-x-0 h-0.5 bg-white/60 rounded-full" />
|
||||
</span>
|
||||
<div className="flex-1 border-b border-[#2a2a2a]" />
|
||||
<button
|
||||
onClick={copy}
|
||||
className="px-3 py-2 text-[#555] hover:text-white transition-colors flex-shrink-0 border-b border-[#2a2a2a]"
|
||||
aria-label="Copy fixed code"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="w-3.5 h-3.5 text-emerald-400" />
|
||||
) : (
|
||||
<Copy className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="overflow-auto max-h-[400px]">
|
||||
<table className="w-full border-collapse font-mono text-[12px] leading-[22px] [font-variant-ligatures:none]">
|
||||
<tbody>
|
||||
{rows.map((row, i) => (
|
||||
<tr
|
||||
key={i}
|
||||
className={row.added ? "bg-blue-500/[0.12]" : row.removed ? "bg-red-500/[0.12]" : ""}
|
||||
>
|
||||
<td className="select-none w-[1px] whitespace-nowrap pl-4 pr-1.5 text-right text-[#555] align-top text-[12px] leading-[22px]">
|
||||
{row.leftNo}
|
||||
</td>
|
||||
<td className="select-none w-[1px] whitespace-nowrap pl-1.5 pr-4 text-right text-[#555] align-top text-[12px] leading-[22px] border-r border-[#2a2a2a]">
|
||||
{row.rightNo}
|
||||
</td>
|
||||
<td
|
||||
className="pl-4 pr-4 whitespace-pre"
|
||||
dangerouslySetInnerHTML={{ __html: row.highlighted }}
|
||||
/>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { rehypeCodeMeta, mdComponents } from "./MdCodeBlock";
|
||||
|
||||
export function ContentSection({ title, content, action }: { title?: string; content: string; action?: React.ReactNode }) {
|
||||
return (
|
||||
<section>
|
||||
{(title || action) && (
|
||||
<div className="flex items-center justify-between gap-3 mb-3">
|
||||
{title ? <h2 className="text-xl font-semibold text-white">{title}</h2> : <span />}
|
||||
{action}
|
||||
</div>
|
||||
)}
|
||||
<div className="prose-markdown">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeCodeMeta]}
|
||||
components={mdComponents}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Clock, Globe, ChevronDown } from "lucide-react";
|
||||
import { parseTarget } from "@/lib/target-utils";
|
||||
import { ProviderIcon } from "@/components/AddRepositoryDialog";
|
||||
import { getSeverityDot } from "@/lib/vulnerability-utils";
|
||||
import { formatTimeAgo } from "@/lib/utils";
|
||||
import { FIX_EFFORT_META, type FixEffort, type Vulnerability } from "@/types/issues";
|
||||
|
||||
/* ─── Human-friendly CVSS labels ─── */
|
||||
|
||||
const HUMAN_LABELS: Record<string, Record<string, string>> = {
|
||||
attack_vector: { N: "Remotely exploitable", A: "Adjacent network", L: "Local access required", P: "Physical access required" },
|
||||
attack_complexity: { L: "Easy to exploit", H: "Requires specific conditions" },
|
||||
privileges_required: { N: "No authentication needed", L: "Low privileges needed", H: "High privileges needed" },
|
||||
user_interaction: { N: "No user action required", R: "Requires user action", P: "Passive user role", A: "Active user role" },
|
||||
scope: { U: "Impact stays contained", C: "Can spread to other systems" },
|
||||
confidentiality: { N: "No data exposure", L: "Partial data exposure", H: "Full data exposure" },
|
||||
integrity: { N: "No data modification", L: "Limited modification", H: "Full data modification" },
|
||||
availability: { N: "No service disruption", L: "Limited disruption", H: "Full service disruption" },
|
||||
};
|
||||
|
||||
const RISK_LEVEL: Record<string, Record<string, "low" | "medium" | "high">> = {
|
||||
attack_vector: { N: "high", A: "medium", L: "low", P: "low" },
|
||||
attack_complexity: { L: "high", H: "low" },
|
||||
privileges_required: { N: "high", L: "medium", H: "low" },
|
||||
user_interaction: { N: "high", R: "low", P: "medium", A: "low" },
|
||||
scope: { C: "high", U: "low" },
|
||||
confidentiality: { H: "high", L: "medium", N: "low" },
|
||||
integrity: { H: "high", L: "medium", N: "low" },
|
||||
availability: { H: "high", L: "medium", N: "low" },
|
||||
};
|
||||
|
||||
const RISK_BADGE: Record<string, string> = {
|
||||
high: "bg-red-500/15 text-red-400 border-red-500/25",
|
||||
medium: "bg-yellow-500/15 text-yellow-400 border-yellow-500/25",
|
||||
low: "bg-[#222] text-[#666] border-[#333]",
|
||||
};
|
||||
|
||||
const FACTOR_GROUPS: { label: string; keys: string[] }[] = [
|
||||
{ label: "Exploitability", keys: ["attack_vector", "attack_complexity", "privileges_required", "user_interaction"] },
|
||||
{ label: "Impact", keys: ["scope", "confidentiality", "integrity", "availability"] },
|
||||
];
|
||||
|
||||
/* ─── Location link builder ─── */
|
||||
|
||||
export function buildLocationHref(
|
||||
repoUrl: string,
|
||||
provider: string,
|
||||
branch: string,
|
||||
file: string,
|
||||
startLine: number,
|
||||
): string | null {
|
||||
const base = repoUrl.replace(/\.git$/, "").replace(/\/+$/, "");
|
||||
const encodedFile = file.split("/").map(encodeURIComponent).join("/");
|
||||
const encodedBranch = branch.split("/").map(encodeURIComponent).join("/");
|
||||
|
||||
if (provider === "github") {
|
||||
return `${base}/blob/${encodedBranch}/${encodedFile}#L${startLine}`;
|
||||
}
|
||||
if (provider === "gitlab") {
|
||||
return `${base}/-/blob/${encodedBranch}/${encodedFile}#L${startLine}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/* ─── Props ─── */
|
||||
|
||||
interface IssueSidebarProps {
|
||||
vulnerability: Vulnerability;
|
||||
statusSlot: React.ReactNode;
|
||||
slackThreadUrl?: string | null;
|
||||
}
|
||||
|
||||
/* ─── Component ─── */
|
||||
|
||||
export function IssueSidebar({ vulnerability, statusSlot, slackThreadUrl }: IssueSidebarProps) {
|
||||
const { severity, cvss, cve, cwe, fix_effort, created_at, target, endpoint, method, code_locations, cvss_breakdown, location_meta } = vulnerability;
|
||||
|
||||
const [riskOpen, setRiskOpen] = useState(true);
|
||||
|
||||
const fixLocations = code_locations?.filter((loc) => loc.fix_before && loc.fix_after);
|
||||
const hasLocations = fixLocations && fixLocations.length > 0;
|
||||
const parsed = target ? parseTarget(target) : null;
|
||||
const hasAsset = !!(target || endpoint || method || hasLocations);
|
||||
|
||||
const hasBreakdown = cvss_breakdown && Object.values(cvss_breakdown).some((v) => v != null);
|
||||
|
||||
return (
|
||||
<aside className="lg:sticky lg:top-6 lg:max-h-[calc(100vh-3rem)] lg:overflow-y-auto">
|
||||
{/* ─── Metadata ─── */}
|
||||
<div className="pb-4">
|
||||
<div className="space-y-3">
|
||||
{/* Severity */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-[#aaa]">Severity</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className={`w-2 h-2 rounded-full ${getSeverityDot(severity)}`} aria-hidden="true" />
|
||||
<span className="text-sm font-medium capitalize text-white">{severity}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CVSS */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-[#aaa]">CVSS Score</span>
|
||||
<span className="text-sm font-semibold tabular-nums text-white">{cvss !== null ? cvss : "N/A"}</span>
|
||||
</div>
|
||||
|
||||
{/* CVE */}
|
||||
{cve && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-[#aaa]">CVE</span>
|
||||
<span className="text-sm text-white font-mono">{cve}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CWE */}
|
||||
{cwe && cwe.length > 0 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-[#aaa]">CWE</span>
|
||||
<span className="text-xs text-white font-mono truncate max-w-[80%] text-right" title={cwe.join(" · ")}>
|
||||
{cwe.join(" · ")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fix Effort */}
|
||||
{fix_effort && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-[#aaa]">Fix Effort</span>
|
||||
<span className={`inline-flex items-center px-2 py-0.5 text-[11px] font-medium rounded-full border ${FIX_EFFORT_META[fix_effort as FixEffort]?.color ?? "text-[#666]"}`}>
|
||||
{fix_effort.charAt(0).toUpperCase() + fix_effort.slice(1)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Discovered */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-[#aaa]">Discovered</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Clock className="w-3 h-3 text-[#444]" aria-hidden="true" />
|
||||
<span className="text-sm text-white">{formatTimeAgo(created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-[#aaa]">Status</span>
|
||||
{statusSlot}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ─── Asset ─── */}
|
||||
{hasAsset && (
|
||||
<div className="border-t border-[#191919] pt-4 pb-4">
|
||||
<p className="text-xs font-medium text-[#aaa] mb-2.5">Asset</p>
|
||||
<div className="space-y-2.5">
|
||||
{target && parsed && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{parsed.provider ? (
|
||||
<span className="flex-shrink-0 [&_svg]:w-3.5 [&_svg]:h-3.5" aria-hidden="true">
|
||||
<ProviderIcon provider={parsed.provider} />
|
||||
</span>
|
||||
) : (
|
||||
<Globe className="w-3.5 h-3.5 text-[#555] flex-shrink-0" aria-hidden="true" />
|
||||
)}
|
||||
{parsed.href ? (
|
||||
<a
|
||||
href={parsed.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-white hover:text-[#ccc] break-words min-w-0 transition-colors"
|
||||
>
|
||||
{parsed.display}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-sm text-white break-words min-w-0">{parsed.display}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{endpoint && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-[#aaa]">Endpoint</span>
|
||||
<span className="text-xs text-white font-mono truncate max-w-[75%] text-right">{endpoint}</span>
|
||||
</div>
|
||||
)}
|
||||
{method && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-[#aaa]">Method</span>
|
||||
<span className="text-xs text-white font-mono">{method}</span>
|
||||
</div>
|
||||
)}
|
||||
{hasLocations && (
|
||||
<div>
|
||||
<span className="text-xs text-[#aaa] mb-1.5 block">Locations</span>
|
||||
<div className="space-y-0.5">
|
||||
{fixLocations!.map((loc, i) => {
|
||||
const label = `${loc.file}:${loc.start_line}`;
|
||||
const href = location_meta
|
||||
? buildLocationHref(location_meta.repo_url, location_meta.provider, location_meta.branch, loc.file, loc.start_line)
|
||||
: null;
|
||||
return href ? (
|
||||
<a
|
||||
key={`loc-${i}`}
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[13px] text-[#888] hover:text-white font-mono break-all transition-colors block"
|
||||
>
|
||||
{label}
|
||||
</a>
|
||||
) : (
|
||||
<span key={`loc-${i}`} className="text-[13px] text-[#888] font-mono break-all block">
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ─── Risk Assessment (human-friendly CVSS) ─── */}
|
||||
{hasBreakdown && (
|
||||
<div className="border-t border-[#191919] pt-4">
|
||||
<button
|
||||
onClick={() => setRiskOpen(!riskOpen)}
|
||||
className="flex items-center justify-between w-full mb-2.5 group"
|
||||
aria-expanded={riskOpen}
|
||||
>
|
||||
<span className="text-xs font-medium text-[#aaa]">Risk Assessment</span>
|
||||
<ChevronDown className={`w-3.5 h-3.5 text-[#555] group-hover:text-white transition-transform ${riskOpen ? "" : "-rotate-90"}`} aria-hidden="true" />
|
||||
</button>
|
||||
<div className={`space-y-3 ${riskOpen ? "" : "hidden"}`}>
|
||||
{FACTOR_GROUPS.map((group) => {
|
||||
const factors = group.keys.filter(
|
||||
(k) => (cvss_breakdown as unknown as Record<string, string | null>)[k] != null
|
||||
);
|
||||
if (factors.length === 0) return null;
|
||||
return (
|
||||
<div key={group.label}>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<p className="text-[10px] uppercase tracking-wider text-[#444] font-medium">
|
||||
{group.label}
|
||||
</p>
|
||||
<p className="text-[10px] uppercase tracking-wider text-[#444] font-medium mr-2">
|
||||
Risk
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{factors.map((key) => {
|
||||
const raw = (cvss_breakdown as unknown as Record<string, string | null>)[key];
|
||||
const level = raw ? (RISK_LEVEL[key]?.[raw] ?? "low") : "low";
|
||||
const label = raw ? (HUMAN_LABELS[key]?.[raw] ?? raw) : "N/A";
|
||||
return (
|
||||
<div key={key} className="flex items-center justify-between py-0.5">
|
||||
<span className="text-[12px] text-[#aaa]">{label}</span>
|
||||
<span className={`text-[10px] font-medium px-1.5 py-0.5 rounded border ${RISK_BADGE[level]}`}>
|
||||
{level}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import hljs from "@/lib/hljs";
|
||||
import "highlight.js/styles/github-dark.css";
|
||||
import { Copy, Check } from "lucide-react";
|
||||
import { copyToClipboard } from "@/lib/vulnerability-utils";
|
||||
|
||||
export function MdCodeBlock({
|
||||
className,
|
||||
children,
|
||||
node,
|
||||
}: {
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
node?: { data?: { meta?: string }; properties?: { metastring?: string } };
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const raw = String(children).replace(/\n$/, "");
|
||||
const match = /language-(\w+)/.exec(className || "");
|
||||
const isBlock = raw.includes("\n") || match;
|
||||
|
||||
if (!isBlock) {
|
||||
return <code className={`${className || ""} bg-white/8 px-1.5 py-0.5 rounded text-[13px]`}>{children}</code>;
|
||||
}
|
||||
|
||||
const meta = node?.data?.meta || node?.properties?.metastring || "";
|
||||
const titleMatch = /title=["']?([^"'\s}]+)["']?/.exec(meta);
|
||||
const startMatch = /startLineNumber=(\d+)/.exec(meta);
|
||||
const fileName = titleMatch?.[1] || null;
|
||||
const startLine = startMatch ? parseInt(startMatch[1], 10) : 1;
|
||||
const headerLabel = fileName
|
||||
? startMatch
|
||||
? `${fileName}:${startLine}`
|
||||
: fileName
|
||||
: null;
|
||||
|
||||
let highlighted: string;
|
||||
if (match) {
|
||||
try {
|
||||
highlighted = hljs.highlight(raw, { language: match[1], ignoreIllegals: true }).value;
|
||||
} catch {
|
||||
highlighted = hljs.highlightAuto(raw).value;
|
||||
}
|
||||
} else {
|
||||
highlighted = hljs.highlightAuto(raw).value;
|
||||
}
|
||||
|
||||
const lines = highlighted.split("\n");
|
||||
|
||||
const copy = () => {
|
||||
copyToClipboard(raw);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="group/code relative rounded-md border border-[#2a2a2a] my-4 text-[#ddd] overflow-hidden">
|
||||
{headerLabel ? (
|
||||
<div className="flex items-stretch">
|
||||
<span className="relative flex items-center text-[13px] text-[#999] font-mono px-4 py-2 border-r border-[#2a2a2a]">{headerLabel}<span className="absolute top-0 inset-x-0 h-0.5 bg-white/60 rounded-full" /></span>
|
||||
<div className="flex-1 border-b border-[#2a2a2a]" />
|
||||
<button
|
||||
onClick={copy}
|
||||
className="px-3 py-2 text-[#555] hover:text-white transition-colors border-b border-[#2a2a2a]"
|
||||
aria-label="Copy code"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="w-3.5 h-3.5 text-emerald-400" />
|
||||
) : (
|
||||
<Copy className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={copy}
|
||||
className="absolute top-2 right-2 z-10 p-1 rounded text-[#444] hover:text-white opacity-0 group-hover/code:opacity-100 transition-opacity"
|
||||
aria-label="Copy code"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="w-3.5 h-3.5 text-emerald-400" />
|
||||
) : (
|
||||
<Copy className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<div className="overflow-auto max-h-[400px]">
|
||||
<table className="w-full border-collapse font-mono text-[12px] leading-[22px] [font-variant-ligatures:none]">
|
||||
<tbody>
|
||||
{lines.map((line, i) => (
|
||||
<tr key={i}>
|
||||
<td className="select-none w-[1px] whitespace-nowrap px-4 text-right text-[#555] align-top text-[12px] leading-[22px] border-r border-[#2a2a2a]">
|
||||
{startLine + i}
|
||||
</td>
|
||||
<td
|
||||
className="pl-4 pr-4 whitespace-pre"
|
||||
dangerouslySetInnerHTML={{ __html: line || "\n" }}
|
||||
/>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// rehype plugin: pass code fence meta string through to code element properties
|
||||
type HastNode = {
|
||||
type: string;
|
||||
tagName?: string;
|
||||
children?: HastNode[];
|
||||
properties?: Record<string, unknown>;
|
||||
data?: { meta?: string };
|
||||
};
|
||||
|
||||
export function rehypeCodeMeta() {
|
||||
return (tree: HastNode) => {
|
||||
const visit = (node: HastNode) => {
|
||||
if (node.type === "element" && node.tagName === "pre" && node.children) {
|
||||
const codeEl = node.children.find(
|
||||
(c) => c.type === "element" && c.tagName === "code"
|
||||
);
|
||||
if (codeEl?.data?.meta) {
|
||||
codeEl.properties = codeEl.properties || {};
|
||||
codeEl.properties.metastring = codeEl.data.meta;
|
||||
}
|
||||
}
|
||||
if (node.children) {
|
||||
node.children.forEach((child) => visit(child));
|
||||
}
|
||||
};
|
||||
visit(tree);
|
||||
};
|
||||
}
|
||||
|
||||
export const mdComponents = {
|
||||
code: MdCodeBlock as React.ComponentType<React.HTMLAttributes<HTMLElement>>,
|
||||
pre: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import hljs from "@/lib/hljs";
|
||||
import "highlight.js/styles/github-dark.css";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { Copy, Check } from "lucide-react";
|
||||
import { copyToClipboard } from "@/lib/vulnerability-utils";
|
||||
import { rehypeCodeMeta, mdComponents } from "./MdCodeBlock";
|
||||
|
||||
interface PocBlockProps {
|
||||
description?: string | null;
|
||||
scriptCode?: string | null;
|
||||
onCopy?: () => void;
|
||||
}
|
||||
|
||||
export function PocBlock({ description, scriptCode, onCopy }: PocBlockProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
if (!description && !scriptCode) return null;
|
||||
|
||||
const copy = () => {
|
||||
if (!scriptCode) return;
|
||||
copyToClipboard(scriptCode);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
onCopy?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-white mb-3">Proof of Concept</h2>
|
||||
<div className="space-y-4">
|
||||
{description && (
|
||||
<div className="prose-markdown">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeCodeMeta]}
|
||||
components={mdComponents}
|
||||
>
|
||||
{description}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
{scriptCode && (
|
||||
<div className="group/poc relative rounded-md border border-[#2a2a2a] overflow-hidden">
|
||||
<div className="flex items-stretch">
|
||||
<span className="relative flex items-center text-[13px] text-[#999] font-mono px-4 py-2 border-r border-[#2a2a2a]">PoC Script<span className="absolute top-0 inset-x-0 h-0.5 bg-white/60 rounded-full" /></span>
|
||||
<div className="flex-1 border-b border-[#2a2a2a]" />
|
||||
<button
|
||||
onClick={copy}
|
||||
className="px-3 py-2 text-[#555] hover:text-white transition-colors flex-shrink-0 border-b border-[#2a2a2a]"
|
||||
aria-label="Copy PoC code"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="w-3.5 h-3.5 text-emerald-400" />
|
||||
) : (
|
||||
<Copy className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="overflow-auto max-h-[400px] px-4 py-3">
|
||||
<pre className="font-mono text-[12px] leading-[22px] whitespace-pre [font-variant-ligatures:none]">
|
||||
<code
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: hljs.highlight(scriptCode, { language: "python" }).value,
|
||||
}}
|
||||
/>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { Clock, CheckCircle2, Ban, History, BellOff, Wrench, GitMerge, GitPullRequest } from "lucide-react";
|
||||
import { ProInlineCta } from "@/components/ProCta";
|
||||
import { Vulnerability, VulnerabilityStatus, SEVERITY_COLORS, STATUS_META, isSeverityOverridden } from "@/types/issues";
|
||||
import { formatTimeAgo } from "@/lib/utils";
|
||||
import { getSeverityDot } from "@/lib/vulnerability-utils";
|
||||
import { formatStrixId } from "@/lib/display-number";
|
||||
import { ContentSection } from "@/components/vulnerability/ContentSection";
|
||||
import { CodeDiffBlock } from "@/components/vulnerability/CodeDiffBlock";
|
||||
import { PocBlock } from "@/components/vulnerability/PocBlock";
|
||||
import { IssueSidebar } from "@/components/vulnerability/IssueSidebar";
|
||||
|
||||
function bannerTime(dateString: string | null): string {
|
||||
if (!dateString) return "";
|
||||
const diffInSeconds = Math.floor((Date.now() - new Date(dateString).getTime()) / 1000);
|
||||
if (diffInSeconds < 604800) return ` ${formatTimeAgo(dateString)}`;
|
||||
return ` on ${formatTimeAgo(dateString)}`;
|
||||
}
|
||||
|
||||
const STATUS_BANNER: Record<VulnerabilityStatus, { icon: React.ElementType; label: string; iconColor: string } | null> = {
|
||||
open: null,
|
||||
in_progress: { icon: Clock, label: "Marked as In Progress", iconColor: "text-blue-400" },
|
||||
snoozed: { icon: BellOff, label: "Snoozed", iconColor: "text-purple-400" },
|
||||
fixed: { icon: CheckCircle2, label: "Marked as Fixed", iconColor: "text-emerald-400" },
|
||||
ignored: { icon: Ban, label: "Marked as Ignored", iconColor: "text-[#888]" },
|
||||
};
|
||||
|
||||
type BottomTab = "fix" | "reproduction";
|
||||
|
||||
interface VulnerabilityDetailProps {
|
||||
vulnerability: Vulnerability;
|
||||
}
|
||||
|
||||
/**
|
||||
* Self-contained finding detail (header + status banners + content grid),
|
||||
* without page chrome. Shared by the public /share/issues page and the local
|
||||
* /results view so both render findings identically.
|
||||
*/
|
||||
export default function VulnerabilityDetail({ vulnerability }: VulnerabilityDetailProps) {
|
||||
const currentMeta = STATUS_META[vulnerability.status];
|
||||
const hasCodeLocations = vulnerability.code_locations && vulnerability.code_locations.length > 0;
|
||||
const hasFix = hasCodeLocations || vulnerability.remediation_steps;
|
||||
const hasReproduction = !!(vulnerability.evidence || vulnerability.assumptions || vulnerability.poc_description || vulnerability.poc_script_code);
|
||||
|
||||
const [activeTab, setActiveTab] = useState<BottomTab>("fix");
|
||||
|
||||
const bottomTabs: { id: BottomTab; label: string; show: boolean }[] = [
|
||||
{ id: "fix", label: "Fix", show: !!hasFix },
|
||||
{ id: "reproduction", label: "Reproduction", show: hasReproduction },
|
||||
];
|
||||
const visibleTabs = bottomTabs.filter((t) => t.show);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<div className="mb-2">
|
||||
{vulnerability.display_number && (
|
||||
<span className="text-xs font-mono text-[#555] block mb-1">
|
||||
{formatStrixId(vulnerability.display_number)}
|
||||
</span>
|
||||
)}
|
||||
<h1 className="text-2xl font-semibold text-white">{vulnerability.title}</h1>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span className={`inline-flex items-center gap-1.5 px-3 py-1 text-sm font-medium rounded-full border ${currentMeta.color}`}>
|
||||
{currentMeta.label}
|
||||
</span>
|
||||
<div
|
||||
className={`inline-flex items-center gap-1.5 px-3 py-1 text-sm font-semibold rounded-full border ${SEVERITY_COLORS[vulnerability.severity]}`}
|
||||
title={isSeverityOverridden(vulnerability) ? `Adjusted from ${vulnerability.original_severity}` : undefined}
|
||||
>
|
||||
<div className={`w-2 h-2 rounded-full ${getSeverityDot(vulnerability.severity)}`} />
|
||||
<span className="capitalize">
|
||||
{vulnerability.severity}
|
||||
{!isSeverityOverridden(vulnerability) && vulnerability.cvss ? ` ${vulnerability.cvss}` : ""}
|
||||
</span>
|
||||
{isSeverityOverridden(vulnerability) && (
|
||||
<History className="w-3 h-3 opacity-70" aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
{vulnerability.cve && (
|
||||
<>
|
||||
<span className="text-[#333]">·</span>
|
||||
<span className="text-sm text-[#666] font-mono">{vulnerability.cve}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status banner */}
|
||||
{vulnerability.status !== "open" && (() => {
|
||||
const banner = STATUS_BANNER[vulnerability.status];
|
||||
if (!banner) return null;
|
||||
const BannerIcon = banner.icon;
|
||||
return (
|
||||
<div className="rounded-lg px-4 py-3.5 flex gap-3" style={{ border: "1px solid rgba(255,255,255,0.08)" }}>
|
||||
<BannerIcon className={`w-5 h-5 flex-shrink-0 mt-0.5 ${banner.iconColor}`} aria-hidden="true" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-white">
|
||||
{banner.label}{bannerTime(vulnerability.status_changed_at)}
|
||||
</p>
|
||||
{vulnerability.status_note && (
|
||||
<p className="text-sm text-[#666] italic mt-1">
|
||||
“{vulnerability.status_note}”
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Severity override banner */}
|
||||
{isSeverityOverridden(vulnerability) && (
|
||||
<div className="rounded-lg px-4 py-3.5 flex gap-3" style={{ border: "1px solid rgba(255,255,255,0.08)" }}>
|
||||
<History className="w-5 h-5 flex-shrink-0 mt-0.5 text-orange-400" aria-hidden="true" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-white">
|
||||
Severity changed manually from{" "}
|
||||
<span className="capitalize">{vulnerability.original_severity}</span>
|
||||
{vulnerability.cvss != null ? ` (${vulnerability.cvss})` : ""} to{" "}
|
||||
<span className="capitalize">{vulnerability.severity}</span>
|
||||
{bannerTime(vulnerability.severity_changed_at)}
|
||||
</p>
|
||||
{vulnerability.severity_override_reason && (
|
||||
<p className="text-sm text-[#666] italic mt-1">
|
||||
“{vulnerability.severity_override_reason}”
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content grid */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[1fr_340px] gap-8">
|
||||
{/* Main content */}
|
||||
<div className="min-w-0">
|
||||
<div className="space-y-8">
|
||||
<ContentSection title="TL;DR" content={vulnerability.description} />
|
||||
|
||||
{vulnerability.impact && <ContentSection title="Impact" content={vulnerability.impact} />}
|
||||
|
||||
{vulnerability.technical_analysis && (
|
||||
<ContentSection title="Technical Details" content={vulnerability.technical_analysis} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bottom tabs */}
|
||||
{visibleTabs.length > 0 && (
|
||||
<div className="mt-10">
|
||||
<div className="border-b border-[#2a2a2a]">
|
||||
<nav className="flex gap-6" aria-label="Tabs">
|
||||
{visibleTabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`relative min-w-[80px] text-center pb-3 text-[16px] font-semibold transition-colors ${
|
||||
activeTab === tab.id
|
||||
? "text-white"
|
||||
: "text-[#666] hover:text-white"
|
||||
}`}
|
||||
aria-current={activeTab === tab.id ? "page" : undefined}
|
||||
>
|
||||
{tab.label}
|
||||
{activeTab === tab.id && (
|
||||
<span className="absolute bottom-0 inset-x-0 h-0.5 bg-white rounded-full" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Fix tab */}
|
||||
{hasFix && (
|
||||
<div className={`pt-6 space-y-6 ${activeTab === "fix" ? "animate-tab-in" : "hidden"}`}>
|
||||
{vulnerability.remediation_steps && (
|
||||
<ContentSection title="How do I fix it?" content={vulnerability.remediation_steps} />
|
||||
)}
|
||||
|
||||
{hasCodeLocations &&
|
||||
vulnerability.code_locations!
|
||||
.filter((loc) => loc.fix_before && loc.fix_after)
|
||||
.map((loc, i) => (
|
||||
<CodeDiffBlock
|
||||
key={`fix-${i}`}
|
||||
file={loc.file}
|
||||
startLine={loc.start_line}
|
||||
endLine={loc.end_line}
|
||||
before={loc.fix_before!}
|
||||
after={loc.fix_after!}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reproduction tab */}
|
||||
{hasReproduction && (
|
||||
<div className={`pt-6 space-y-8 ${activeTab === "reproduction" ? "animate-tab-in" : "hidden"}`}>
|
||||
{vulnerability.assumptions && (
|
||||
<ContentSection title="Assumptions" content={vulnerability.assumptions} />
|
||||
)}
|
||||
|
||||
{vulnerability.evidence && (
|
||||
<ContentSection title="Evidence" content={vulnerability.evidence} />
|
||||
)}
|
||||
|
||||
<PocBlock
|
||||
description={vulnerability.poc_description}
|
||||
scriptCode={vulnerability.poc_script_code}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="lg:border-l lg:border-[#2a2a2a] lg:pl-6">
|
||||
<IssueSidebar
|
||||
vulnerability={vulnerability}
|
||||
statusSlot={
|
||||
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-full border ${currentMeta.color}`}>
|
||||
<div className={`w-1.5 h-1.5 rounded-full ${currentMeta.dotColor}`} />
|
||||
{currentMeta.label}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Team-workflow CTAs (Pro). Highest-intent surface: act on this finding. */}
|
||||
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5">
|
||||
<p className="text-sm font-semibold text-white">Ship the fix with your team</p>
|
||||
<p className="mt-0.5 text-xs text-[#666]">
|
||||
Take this finding into your team's workflow.
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2.5">
|
||||
<ProInlineCta
|
||||
label="Auto-fix & open a PR"
|
||||
desc="Fix it for you and open a PR, retested."
|
||||
slug="autofix"
|
||||
icon={Wrench}
|
||||
surface="finding_detail"
|
||||
/>
|
||||
<ProInlineCta
|
||||
label="Sync to Jira / Linear"
|
||||
desc="Two-way sync findings to Jira, Linear, and Slack."
|
||||
slug="integrations"
|
||||
icon={GitMerge}
|
||||
surface="finding_detail"
|
||||
/>
|
||||
<ProInlineCta
|
||||
label="Catch this in PR reviews"
|
||||
desc="Pentest every pull request your team opens."
|
||||
slug="pr_reviews"
|
||||
icon={GitPullRequest}
|
||||
surface="finding_detail"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import type { Vulnerability } from "@/types/issues";
|
||||
import {
|
||||
parseRunJson,
|
||||
parseVulnerabilitiesJson,
|
||||
type ParsedRunSummary,
|
||||
} from "@/lib/local-run-parser";
|
||||
|
||||
/**
|
||||
* Data seam for the local viewer. Replaces strix-app's browser file-picker
|
||||
* (`loadFromTexts`) with fetches against the local Python server's JSON
|
||||
* endpoints (same origin, relative URLs). Produces the same in-memory
|
||||
* `LoadedRun` shape the UI renders, plus a `finished` flag driving live polling.
|
||||
*
|
||||
* The server serves a live in-progress run and a finished one identically; the
|
||||
* only signal is `run.finished`.
|
||||
*/
|
||||
|
||||
/** A transcript agent as emitted by GET /api/transcript (already parsed). */
|
||||
export interface TranscriptAgent {
|
||||
id: string;
|
||||
name: string;
|
||||
parent_id: string | null;
|
||||
status: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/** Chat/tool event data as emitted by GET /api/transcript. */
|
||||
export interface TranscriptEvent {
|
||||
id: string;
|
||||
type: "chat" | "tool";
|
||||
agent_id: string;
|
||||
timestamp: string;
|
||||
version: number;
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface Transcript {
|
||||
agents: TranscriptAgent[];
|
||||
events: TranscriptEvent[];
|
||||
}
|
||||
|
||||
export interface LoadedRun {
|
||||
summary: ParsedRunSummary;
|
||||
/** Whole raw run record (for llm_usage, targets_info details, etc.). */
|
||||
raw: Record<string, unknown>;
|
||||
finished: boolean;
|
||||
vulnerabilities: Vulnerability[];
|
||||
reportMarkdown: string | null;
|
||||
transcript: Transcript;
|
||||
}
|
||||
|
||||
async function getJson(path: string): Promise<unknown> {
|
||||
const res = await fetch(path, { cache: "no-store" });
|
||||
if (!res.ok) throw new Error(`${path} responded ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** Build a ``?run=<name>`` suffix for run-scoped data endpoints. */
|
||||
function runQuery(runName?: string | null): string {
|
||||
return runName ? `?run=${encodeURIComponent(runName)}` : "";
|
||||
}
|
||||
|
||||
export async function fetchRunSummary(runName?: string | null): Promise<{
|
||||
summary: ParsedRunSummary;
|
||||
raw: Record<string, unknown>;
|
||||
finished: boolean;
|
||||
}> {
|
||||
const raw = (await getJson("/api/run" + runQuery(runName))) as Record<string, unknown>;
|
||||
// parseRunJson tolerates extra keys and takes raw TEXT.
|
||||
const summary = parseRunJson(JSON.stringify(raw));
|
||||
const finished = raw.finished === true;
|
||||
return { summary, raw, finished };
|
||||
}
|
||||
|
||||
export async function fetchVulnerabilities(
|
||||
runId: string | null,
|
||||
runName?: string | null
|
||||
): Promise<Vulnerability[]> {
|
||||
const arr = await getJson("/api/vulnerabilities" + runQuery(runName));
|
||||
return parseVulnerabilitiesJson(JSON.stringify(arr), runId);
|
||||
}
|
||||
|
||||
export async function fetchReportMarkdown(runName?: string | null): Promise<string | null> {
|
||||
const obj = (await getJson("/api/report" + runQuery(runName))) as { markdown?: string };
|
||||
return obj?.markdown ?? null;
|
||||
}
|
||||
|
||||
export async function fetchTranscript(runName?: string | null): Promise<Transcript> {
|
||||
const obj = (await getJson("/api/transcript" + runQuery(runName))) as Partial<Transcript>;
|
||||
return {
|
||||
agents: Array.isArray(obj?.agents) ? obj.agents : [],
|
||||
events: Array.isArray(obj?.events) ? obj.events : [],
|
||||
};
|
||||
}
|
||||
|
||||
/** One-shot fetch of every endpoint (used on mount and on final settle). */
|
||||
export async function fetchAll(runName?: string | null): Promise<LoadedRun> {
|
||||
const { summary, raw, finished } = await fetchRunSummary(runName);
|
||||
const [vulnerabilities, reportMarkdown, transcript] = await Promise.all([
|
||||
fetchVulnerabilities(summary.runId, runName).catch(() => [] as Vulnerability[]),
|
||||
fetchReportMarkdown(runName).catch(() => null),
|
||||
fetchTranscript(runName).catch(() => ({ agents: [], events: [] }) as Transcript),
|
||||
]);
|
||||
return { summary, raw, finished, vulnerabilities, reportMarkdown, transcript };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Run history + email auth + report send
|
||||
//
|
||||
// These endpoints back the "Your runs" sidebar section. Auth and report-send
|
||||
// responses carry a meaningful JSON body on non-2xx statuses (an ``error``
|
||||
// code), so they read the body regardless of status rather than throwing.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface RunSeverityCounts {
|
||||
critical: number;
|
||||
high: number;
|
||||
medium: number;
|
||||
low: number;
|
||||
}
|
||||
|
||||
export interface RunListEntry {
|
||||
name: string;
|
||||
target: string | null;
|
||||
scan_mode: string | null;
|
||||
status: string | null;
|
||||
start_time: string | null;
|
||||
end_time: string | null;
|
||||
finished: boolean;
|
||||
severity_counts: RunSeverityCounts;
|
||||
}
|
||||
|
||||
export interface RunsPayload {
|
||||
locked: boolean;
|
||||
count: number;
|
||||
runs: RunListEntry[];
|
||||
}
|
||||
|
||||
export interface AuthStatus {
|
||||
verified: boolean;
|
||||
email: string | null;
|
||||
}
|
||||
|
||||
export type OtpStartResult = { ok: true } | { ok: false; error: string };
|
||||
export type OtpVerifyResult =
|
||||
| { verified: true; email: string }
|
||||
| { verified: false; error: string };
|
||||
export type SendReportResult =
|
||||
| { ok: true; password: string; filename: string }
|
||||
| { ok: false; error: string };
|
||||
|
||||
async function postJson(
|
||||
path: string,
|
||||
body: Record<string, unknown>
|
||||
): Promise<{ ok: boolean; status: number; data: Record<string, unknown> }> {
|
||||
const res = await fetch(path, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
cache: "no-store",
|
||||
});
|
||||
let data: Record<string, unknown> = {};
|
||||
try {
|
||||
const parsed = await res.json();
|
||||
if (parsed && typeof parsed === "object") data = parsed as Record<string, unknown>;
|
||||
} catch {
|
||||
/* empty or non-JSON body */
|
||||
}
|
||||
return { ok: res.ok, status: res.status, data };
|
||||
}
|
||||
|
||||
export async function fetchRuns(): Promise<RunsPayload> {
|
||||
const obj = (await getJson("/api/runs")) as Partial<RunsPayload>;
|
||||
return {
|
||||
locked: obj?.locked ?? true,
|
||||
count: typeof obj?.count === "number" ? obj.count : 0,
|
||||
runs: Array.isArray(obj?.runs) ? (obj.runs as RunListEntry[]) : [],
|
||||
};
|
||||
}
|
||||
|
||||
export interface Capabilities {
|
||||
can_steer: boolean;
|
||||
}
|
||||
|
||||
export type SteerResult = { ok: true } | { ok: false; error: string };
|
||||
|
||||
/** GET /api/capabilities. can_steer is true only inside a live in-TUI scan. */
|
||||
export async function fetchCapabilities(): Promise<Capabilities> {
|
||||
const obj = (await getJson("/api/capabilities")) as Partial<Capabilities>;
|
||||
return { can_steer: obj?.can_steer === true };
|
||||
}
|
||||
|
||||
/** POST /api/agents/steer. Sends a steering instruction to a running agent. */
|
||||
export async function steerAgent(agentId: string, message: string): Promise<SteerResult> {
|
||||
const { ok, data } = await postJson("/api/agents/steer", {
|
||||
agent_id: agentId,
|
||||
message,
|
||||
});
|
||||
if (ok && data.ok === true) return { ok: true };
|
||||
return { ok: false, error: String(data.error ?? "unavailable") };
|
||||
}
|
||||
|
||||
export async function fetchAuthStatus(): Promise<AuthStatus> {
|
||||
const obj = (await getJson("/api/auth/status")) as Partial<AuthStatus>;
|
||||
return { verified: obj?.verified === true, email: obj?.email ?? null };
|
||||
}
|
||||
|
||||
export async function otpStart(email: string): Promise<OtpStartResult> {
|
||||
const { ok, data } = await postJson("/api/auth/otp/start", { email });
|
||||
if (ok && data.ok === true) return { ok: true };
|
||||
return { ok: false, error: String(data.error ?? "unavailable") };
|
||||
}
|
||||
|
||||
export async function otpVerify(email: string, code: string): Promise<OtpVerifyResult> {
|
||||
const { ok, data } = await postJson("/api/auth/otp/verify", { email, code });
|
||||
if (ok && data.verified === true) {
|
||||
return { verified: true, email: String(data.email ?? email) };
|
||||
}
|
||||
return { verified: false, error: String(data.error ?? "invalid_code") };
|
||||
}
|
||||
|
||||
export async function forgetAuth(): Promise<void> {
|
||||
await postJson("/api/auth/forget", {});
|
||||
}
|
||||
|
||||
export async function sendReport(runName?: string | null): Promise<SendReportResult> {
|
||||
const { ok, data } = await postJson("/api/report/send", runName ? { run: runName } : {});
|
||||
if (ok && data.ok === true) {
|
||||
return {
|
||||
ok: true,
|
||||
password: String(data.password ?? ""),
|
||||
filename: String(data.filename ?? "strix-report.pdf"),
|
||||
};
|
||||
}
|
||||
return { ok: false, error: String(data.error ?? "unavailable") };
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--font-geist-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
|
||||
Helvetica, Arial, sans-serif;
|
||||
--font-geist-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
|
||||
"Liberation Mono", "Courier New", monospace;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
background: #000000;
|
||||
color: #ffffff;
|
||||
font-family: var(--font-geist-sans);
|
||||
}
|
||||
|
||||
/* Tab content transition (lifted from strix-app globals.css) */
|
||||
@keyframes tab-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
filter: blur(4px);
|
||||
transform: translateY(6px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
filter: blur(0px);
|
||||
transform: translateY(0px);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-tab-in {
|
||||
animation: tab-in 200ms ease-out;
|
||||
}
|
||||
|
||||
/* Markdown prose styling (lifted from strix-app globals.css) */
|
||||
.prose-markdown {
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
color: #999;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.prose-markdown p {
|
||||
margin-bottom: 0.75em;
|
||||
}
|
||||
|
||||
.prose-markdown p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.prose-markdown strong {
|
||||
color: #ccc;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.prose-markdown em {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.prose-markdown code {
|
||||
background: #0a0a0a;
|
||||
border: 1px solid #111;
|
||||
border-radius: 4px;
|
||||
padding: 0.15em 0.4em;
|
||||
font-size: 0.9em;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
color: #ccc;
|
||||
font-variant-ligatures: none;
|
||||
}
|
||||
|
||||
.prose-markdown pre {
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font-variant-ligatures: none;
|
||||
}
|
||||
|
||||
.prose-markdown pre code {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-size: 13px;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.prose-markdown ul,
|
||||
.prose-markdown ol {
|
||||
padding-left: 1.5em;
|
||||
margin-bottom: 0.75em;
|
||||
}
|
||||
|
||||
.prose-markdown ul {
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
.prose-markdown ol {
|
||||
list-style-type: decimal;
|
||||
}
|
||||
|
||||
.prose-markdown li {
|
||||
margin-bottom: 0.25em;
|
||||
}
|
||||
|
||||
.prose-markdown li > ul,
|
||||
.prose-markdown li > ol {
|
||||
padding-left: 1.5em;
|
||||
margin-top: 0.25em;
|
||||
margin-bottom: 0.25em;
|
||||
}
|
||||
|
||||
.prose-markdown ol + ul {
|
||||
padding-left: 3em;
|
||||
margin-top: -0.5em;
|
||||
}
|
||||
|
||||
.prose-markdown h1,
|
||||
.prose-markdown h2,
|
||||
.prose-markdown h3,
|
||||
.prose-markdown h4,
|
||||
.prose-markdown h5,
|
||||
.prose-markdown h6 {
|
||||
color: #ddd;
|
||||
font-weight: 600;
|
||||
margin-top: 1em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
.prose-markdown a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.prose-markdown blockquote {
|
||||
border-left: 3px solid #333;
|
||||
padding-left: 1em;
|
||||
color: #777;
|
||||
margin: 0.75em 0;
|
||||
}
|
||||
|
||||
.prose-markdown hr {
|
||||
border: none;
|
||||
border-top: 1px solid #222;
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
||||
.prose-markdown > table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 0.75em 0;
|
||||
}
|
||||
|
||||
.prose-markdown > table th,
|
||||
.prose-markdown > table td {
|
||||
border: 1px solid #333;
|
||||
padding: 0.4em 0.75em;
|
||||
text-align: left;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.prose-markdown > table th {
|
||||
background: #1a1a1a;
|
||||
color: #ccc;
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// All upsell / sign-up CTAs route anonymous local-viewer users to the public
|
||||
// cloud sign-up. Open in a new tab so the local results stay put.
|
||||
export const SIGNUP_URL = "https://app.strix.ai/api/auth/signup";
|
||||
export const DEMO_URL = "https://strix.ai/demo";
|
||||
export const PRICING_URL = "https://strix.ai/pricing";
|
||||
|
||||
// Attribution params appended to every outbound CTA link so the destination
|
||||
// analytics can see the local viewer drove the click, with utm_content carrying
|
||||
// the CTA slug so we know which one.
|
||||
const CTA_PARAMS =
|
||||
"ref=oss_viewer&utm_source=oss_viewer&utm_medium=local_viewer&utm_campaign=oss_viewer";
|
||||
|
||||
export function ctaUrl(base: string, slug: string): string {
|
||||
const sep = base.includes("?") ? "&" : "?";
|
||||
return `${base}${sep}${CTA_PARAMS}&utm_content=${encodeURIComponent(slug)}`;
|
||||
}
|
||||
|
||||
// Best-effort, anonymous beacon. The local server forwards this to PostHog only
|
||||
// if the user has telemetry enabled; it never blocks navigation. Undefined
|
||||
// props are dropped so we only send what is set. NEVER pass PII here (no email,
|
||||
// code, or report content) - the props are limited to anonymous metadata.
|
||||
export function track(event: string, props: Record<string, string | undefined> = {}): void {
|
||||
try {
|
||||
const body: Record<string, string> = { event };
|
||||
for (const [key, value] of Object.entries(props)) {
|
||||
if (value !== undefined) body[key] = value;
|
||||
}
|
||||
const payload = JSON.stringify(body);
|
||||
if (typeof navigator !== "undefined" && navigator.sendBeacon) {
|
||||
navigator.sendBeacon("/api/event", payload);
|
||||
} else {
|
||||
void fetch("/api/event", { method: "POST", body: payload, keepalive: true });
|
||||
}
|
||||
} catch {
|
||||
/* analytics is best-effort */
|
||||
}
|
||||
}
|
||||
|
||||
// Anonymous conversion tracking for a sign-up/upsell click. `surface` records
|
||||
// where the click happened so one CTA slug can be reused across placements.
|
||||
export function trackCta(cta: string, surface?: string): void {
|
||||
track("cta_clicked", { cta, surface });
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
// Slim, dependency-free extract of strix-app's display-number helper. The full
|
||||
// version queries Supabase to compute org-wide finding numbers; the local viewer
|
||||
// only ever needs the pure formatter, so the supabase-backed functions are
|
||||
// intentionally omitted (a local run has no org context).
|
||||
export function formatStrixId(num: number): string {
|
||||
return `STRIX-${num}`;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import hljs from "highlight.js/lib/common";
|
||||
import http from "highlight.js/lib/languages/http";
|
||||
import nginx from "highlight.js/lib/languages/nginx";
|
||||
import apache from "highlight.js/lib/languages/apache";
|
||||
import dockerfile from "highlight.js/lib/languages/dockerfile";
|
||||
import properties from "highlight.js/lib/languages/properties";
|
||||
|
||||
hljs.registerLanguage("http", http);
|
||||
hljs.registerLanguage("nginx", nginx);
|
||||
hljs.registerLanguage("apache", apache);
|
||||
hljs.registerLanguage("dockerfile", dockerfile);
|
||||
hljs.registerLanguage("properties", properties);
|
||||
|
||||
export default hljs;
|
||||
@@ -0,0 +1,325 @@
|
||||
import type {
|
||||
Vulnerability,
|
||||
VulnerabilitySeverity,
|
||||
VulnerabilityStatus,
|
||||
} from "@/types/issues";
|
||||
|
||||
/**
|
||||
* Pure, dependency-free parsers that turn a Strix CLI local run
|
||||
* (`strix_runs/<run>/{run.json,vulnerabilities.json}`) into the app's own
|
||||
* types, so the /results view can reuse the dashboard's finding components.
|
||||
*
|
||||
* These run entirely client-side against files the user picked from disk —
|
||||
* nothing here uploads or persists anything.
|
||||
*/
|
||||
|
||||
export class RunParseError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "RunParseError";
|
||||
}
|
||||
}
|
||||
|
||||
export interface ParsedRunSummary {
|
||||
runId: string | null;
|
||||
runName: string | null;
|
||||
targets: string[];
|
||||
scanMode: string | null;
|
||||
status: string | null;
|
||||
startTime: string | null;
|
||||
endTime: string | null;
|
||||
durationSeconds: number | null;
|
||||
executiveSummary: string | null;
|
||||
technicalAnalysis: string | null;
|
||||
methodology: string | null;
|
||||
recommendations: string | null;
|
||||
}
|
||||
|
||||
const KNOWN_SEVERITIES: VulnerabilitySeverity[] = ["critical", "high", "medium", "low"];
|
||||
|
||||
function coerceSeverity(raw: unknown): VulnerabilitySeverity {
|
||||
const s = String(raw ?? "").toLowerCase().trim();
|
||||
if ((KNOWN_SEVERITIES as string[]).includes(s)) return s as VulnerabilitySeverity;
|
||||
// The app's severity type has no "info"/"informational" bucket; fold those
|
||||
// (and anything unrecognized) into "low" so the shared UI renders cleanly.
|
||||
return "low";
|
||||
}
|
||||
|
||||
function toIsoTimestamp(raw: unknown): string {
|
||||
if (typeof raw === "string" && raw.trim()) {
|
||||
// CLI writes e.g. "2025-01-02 03:04:05 UTC".
|
||||
const normalized = raw.trim().replace(" UTC", "Z").replace(" ", "T");
|
||||
const d = new Date(normalized);
|
||||
if (!Number.isNaN(d.getTime())) return d.toISOString();
|
||||
const direct = new Date(raw);
|
||||
if (!Number.isNaN(direct.getTime())) return direct.toISOString();
|
||||
}
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function asStringOrNull(v: unknown): string | null {
|
||||
return typeof v === "string" && v.length > 0 ? v : null;
|
||||
}
|
||||
|
||||
function asNumberOrNull(v: unknown): number | null {
|
||||
return typeof v === "number" && Number.isFinite(v) ? v : null;
|
||||
}
|
||||
|
||||
function parseJson(text: string, label: string): unknown {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
throw new RunParseError(
|
||||
`${label} isn't valid JSON. Make sure you selected a Strix run directory.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function parseRunJson(text: string): ParsedRunSummary {
|
||||
const data = parseJson(text, "run.json");
|
||||
if (!data || typeof data !== "object" || Array.isArray(data)) {
|
||||
throw new RunParseError("run.json is not an object.");
|
||||
}
|
||||
const record = data as Record<string, unknown>;
|
||||
|
||||
const targets: string[] = [];
|
||||
const targetsInfo = record.targets_info;
|
||||
if (Array.isArray(targetsInfo)) {
|
||||
for (const t of targetsInfo) {
|
||||
if (t && typeof t === "object") {
|
||||
const original = (t as Record<string, unknown>).original;
|
||||
if (typeof original === "string" && original) targets.push(original);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const startTime = asStringOrNull(record.start_time);
|
||||
const endTime = asStringOrNull(record.end_time);
|
||||
let durationSeconds: number | null = null;
|
||||
if (startTime && endTime) {
|
||||
const s = new Date(startTime).getTime();
|
||||
const e = new Date(endTime).getTime();
|
||||
if (!Number.isNaN(s) && !Number.isNaN(e) && e >= s) {
|
||||
durationSeconds = Math.round((e - s) / 1000);
|
||||
}
|
||||
}
|
||||
|
||||
let executiveSummary: string | null = null;
|
||||
let technicalAnalysis: string | null = null;
|
||||
let methodology: string | null = null;
|
||||
let recommendations: string | null = null;
|
||||
const scanResults = record.scan_results;
|
||||
if (scanResults && typeof scanResults === "object") {
|
||||
const sr = scanResults as Record<string, unknown>;
|
||||
executiveSummary = asStringOrNull(sr.executive_summary);
|
||||
technicalAnalysis = asStringOrNull(sr.technical_analysis);
|
||||
methodology = asStringOrNull(sr.methodology);
|
||||
recommendations = asStringOrNull(sr.recommendations);
|
||||
}
|
||||
|
||||
return {
|
||||
runId: asStringOrNull(record.run_id),
|
||||
runName: asStringOrNull(record.run_name),
|
||||
targets,
|
||||
scanMode: asStringOrNull(record.scan_mode),
|
||||
status: asStringOrNull(record.status),
|
||||
startTime,
|
||||
endTime,
|
||||
durationSeconds,
|
||||
executiveSummary,
|
||||
technicalAnalysis,
|
||||
methodology,
|
||||
recommendations,
|
||||
};
|
||||
}
|
||||
|
||||
/** Fields on the app's Vulnerability type that a local run never provides. */
|
||||
function emptyVulnerabilityDefaults(): Omit<
|
||||
Vulnerability,
|
||||
| "id"
|
||||
| "title"
|
||||
| "description"
|
||||
| "severity"
|
||||
| "created_at"
|
||||
| "scan_id"
|
||||
| "status"
|
||||
> {
|
||||
return {
|
||||
pr_review_id: null,
|
||||
cve: null,
|
||||
cvss: null,
|
||||
potential_risk_saving: null,
|
||||
risk_saving_description: null,
|
||||
impact: null,
|
||||
endpoint: null,
|
||||
method: null,
|
||||
target: null,
|
||||
technical_analysis: null,
|
||||
poc_description: null,
|
||||
poc_script_code: null,
|
||||
code_diff: null,
|
||||
code_file: null,
|
||||
code_before: null,
|
||||
code_after: null,
|
||||
cwe: null,
|
||||
code_locations: null,
|
||||
remediation_steps: null,
|
||||
fix_pr_body: null,
|
||||
evidence: null,
|
||||
assumptions: null,
|
||||
fix_effort: null,
|
||||
cvss_breakdown: null,
|
||||
status_changed_at: null,
|
||||
status_changed_by: null,
|
||||
status_note: null,
|
||||
snoozed_until: null,
|
||||
reopened_at: null,
|
||||
reopened_by: null,
|
||||
original_severity: null,
|
||||
severity_changed_at: null,
|
||||
severity_changed_by: null,
|
||||
severity_override_reason: null,
|
||||
retest_of_vulnerability_id: null,
|
||||
};
|
||||
}
|
||||
|
||||
function parseOneVulnerability(
|
||||
raw: Record<string, unknown>,
|
||||
index: number,
|
||||
runId: string | null
|
||||
): Vulnerability {
|
||||
const cweRaw = raw.cwe;
|
||||
const cwe =
|
||||
typeof cweRaw === "string" && cweRaw.trim()
|
||||
? [cweRaw.trim()]
|
||||
: Array.isArray(cweRaw)
|
||||
? (cweRaw.filter((c) => typeof c === "string" && c) as string[])
|
||||
: null;
|
||||
|
||||
const status: VulnerabilityStatus = "open";
|
||||
|
||||
return {
|
||||
...emptyVulnerabilityDefaults(),
|
||||
id: asStringOrNull(raw.id) ?? `vuln-${index + 1}`,
|
||||
scan_id: runId,
|
||||
title: asStringOrNull(raw.title) ?? "Untitled finding",
|
||||
description: asStringOrNull(raw.description) ?? "",
|
||||
severity: coerceSeverity(raw.severity),
|
||||
status,
|
||||
created_at: toIsoTimestamp(raw.timestamp),
|
||||
cve: asStringOrNull(raw.cve),
|
||||
cvss: asNumberOrNull(raw.cvss),
|
||||
impact: asStringOrNull(raw.impact),
|
||||
endpoint: asStringOrNull(raw.endpoint),
|
||||
method: asStringOrNull(raw.method),
|
||||
target: asStringOrNull(raw.target),
|
||||
technical_analysis: asStringOrNull(raw.technical_analysis),
|
||||
poc_description: asStringOrNull(raw.poc_description),
|
||||
poc_script_code: asStringOrNull(raw.poc_script_code),
|
||||
cwe,
|
||||
code_locations: Array.isArray(raw.code_locations)
|
||||
? (raw.code_locations as Vulnerability["code_locations"])
|
||||
: null,
|
||||
remediation_steps: asStringOrNull(raw.remediation_steps),
|
||||
fix_pr_body: asStringOrNull(raw.fix_pr_body),
|
||||
evidence: asStringOrNull(raw.evidence),
|
||||
assumptions: asStringOrNull(raw.assumptions),
|
||||
fix_effort: (asStringOrNull(raw.fix_effort) as Vulnerability["fix_effort"]) ?? null,
|
||||
cvss_breakdown: (raw.cvss_breakdown as Vulnerability["cvss_breakdown"]) ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseVulnerabilitiesJson(
|
||||
text: string,
|
||||
runId: string | null = null
|
||||
): Vulnerability[] {
|
||||
const data = parseJson(text, "vulnerabilities.json");
|
||||
if (!Array.isArray(data)) {
|
||||
throw new RunParseError("vulnerabilities.json is not a JSON array.");
|
||||
}
|
||||
return data.map((item, i) => {
|
||||
if (!item || typeof item !== "object") {
|
||||
throw new RunParseError(`vulnerabilities.json entry #${i + 1} is not an object.`);
|
||||
}
|
||||
return parseOneVulnerability(item as Record<string, unknown>, i, runId);
|
||||
});
|
||||
}
|
||||
|
||||
export interface ParsedAgent {
|
||||
id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
parentId: string | null;
|
||||
task: string | null;
|
||||
skills: string[];
|
||||
depth: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the agent execution trace from `.state/agents.json` into a pre-ordered
|
||||
* tree (children follow their parent; `depth` drives indentation). Rendered
|
||||
* 100% client-side and never uploaded — traces contain target details, so they
|
||||
* must stay on the user's machine. The heavier `.state/agents.db` (SQLite) is
|
||||
* intentionally ignored; `agents.json` has everything the panel needs.
|
||||
*/
|
||||
export function parseAgentsJson(text: string): ParsedAgent[] {
|
||||
const data = parseJson(text, "agents.json");
|
||||
if (!data || typeof data !== "object" || Array.isArray(data)) return [];
|
||||
const record = data as Record<string, unknown>;
|
||||
const statuses = (record.statuses ?? {}) as Record<string, unknown>;
|
||||
const parentOf = (record.parent_of ?? {}) as Record<string, unknown>;
|
||||
const names = (record.names ?? {}) as Record<string, unknown>;
|
||||
const metadata = (record.metadata ?? {}) as Record<string, unknown>;
|
||||
|
||||
const agents = new Map<string, ParsedAgent>();
|
||||
for (const id of Object.keys(statuses)) {
|
||||
const meta = (metadata[id] ?? {}) as Record<string, unknown>;
|
||||
const skillsRaw = meta.skills;
|
||||
agents.set(id, {
|
||||
id,
|
||||
name: asStringOrNull(names[id]) ?? id,
|
||||
status: asStringOrNull(statuses[id]) ?? "unknown",
|
||||
parentId: asStringOrNull(parentOf[id]),
|
||||
task: asStringOrNull(meta.task),
|
||||
skills: Array.isArray(skillsRaw)
|
||||
? skillsRaw.filter((s): s is string => typeof s === "string")
|
||||
: [],
|
||||
depth: 0,
|
||||
});
|
||||
}
|
||||
if (agents.size === 0) return [];
|
||||
|
||||
const childrenOf = new Map<string | null, string[]>();
|
||||
for (const a of agents.values()) {
|
||||
const key = a.parentId && agents.has(a.parentId) ? a.parentId : null;
|
||||
(childrenOf.get(key) ?? childrenOf.set(key, []).get(key)!).push(a.id);
|
||||
}
|
||||
|
||||
const ordered: ParsedAgent[] = [];
|
||||
const seen = new Set<string>();
|
||||
const visit = (id: string, depth: number): void => {
|
||||
const a = agents.get(id);
|
||||
if (!a || seen.has(id)) return;
|
||||
seen.add(id);
|
||||
a.depth = depth;
|
||||
ordered.push(a);
|
||||
for (const childId of childrenOf.get(id) ?? []) visit(childId, depth + 1);
|
||||
};
|
||||
for (const rootId of childrenOf.get(null) ?? []) visit(rootId, 0);
|
||||
// Defensive: include any agents not reachable from a root.
|
||||
for (const a of agents.values()) if (!seen.has(a.id)) ordered.push(a);
|
||||
return ordered;
|
||||
}
|
||||
|
||||
export function severityCounts(
|
||||
vulns: Vulnerability[]
|
||||
): Record<VulnerabilitySeverity, number> {
|
||||
const counts: Record<VulnerabilitySeverity, number> = {
|
||||
critical: 0,
|
||||
high: 0,
|
||||
medium: 0,
|
||||
low: 0,
|
||||
};
|
||||
for (const v of vulns) counts[v.severity] += 1;
|
||||
return counts;
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import type React from "react";
|
||||
import {
|
||||
GitPullRequest,
|
||||
Layers,
|
||||
Globe,
|
||||
Puzzle,
|
||||
Users,
|
||||
Search,
|
||||
LayoutDashboard,
|
||||
AlertTriangle,
|
||||
MessageSquare,
|
||||
Network,
|
||||
Database,
|
||||
} from "lucide-react";
|
||||
|
||||
/**
|
||||
* Platform (Pro / Enterprise) feature catalog. Powers both the unified sidebar
|
||||
* nav rows and the in-app FeatureDetail upsell view. Everything is "Pro" except
|
||||
* Networks, which is "Enterprise". No lock icons anywhere.
|
||||
*/
|
||||
|
||||
export type FeatureTier = "Pro" | "Enterprise";
|
||||
|
||||
export interface ProFeature {
|
||||
slug: string;
|
||||
title: string;
|
||||
icon: React.ElementType;
|
||||
tier: FeatureTier;
|
||||
/** Short one-liner for the sidebar nav row (two-line layout). */
|
||||
navDesc: string;
|
||||
/** Headline shown on the FeatureDetail upsell page. */
|
||||
headline: string;
|
||||
/** Longer sentence shown on the FeatureDetail upsell page. */
|
||||
description: string;
|
||||
}
|
||||
|
||||
// Flat catalog of every platform feature, keyed by slug for routing. The
|
||||
// sidebar groups these into capability themes (see PLATFORM_THEMES); nothing
|
||||
// here implies a tier ordering.
|
||||
export const PLATFORM_FEATURES: ProFeature[] = [
|
||||
{
|
||||
slug: "pr_reviews",
|
||||
title: "PR Reviews",
|
||||
icon: GitPullRequest,
|
||||
tier: "Pro",
|
||||
navDesc: "Pentest every pull request",
|
||||
headline: "Pentest every pull request",
|
||||
description:
|
||||
"Strix reviews every pull request your team opens and catches exploitable changes before they merge.",
|
||||
},
|
||||
{
|
||||
slug: "repositories",
|
||||
title: "Repositories",
|
||||
icon: Layers,
|
||||
tier: "Pro",
|
||||
navDesc: "Connect your team's repos",
|
||||
headline: "Connect your team's repositories",
|
||||
description:
|
||||
"Link your org's repositories so Strix can scan them continuously and track findings over time.",
|
||||
},
|
||||
{
|
||||
slug: "domains",
|
||||
title: "Domains",
|
||||
icon: Globe,
|
||||
tier: "Pro",
|
||||
navDesc: "Cover the domains you own",
|
||||
headline: "Cover every domain you own",
|
||||
description:
|
||||
"Add the domains your team owns and let Strix watch them for newly exposed paths and drift.",
|
||||
},
|
||||
{
|
||||
slug: "integrations",
|
||||
title: "Integrations",
|
||||
icon: Puzzle,
|
||||
tier: "Pro",
|
||||
navDesc: "Sync to Jira, Linear, Slack",
|
||||
headline: "Sync findings to your tools",
|
||||
description:
|
||||
"Two-way sync findings to Jira, Linear, and Slack so fixes happen where your team already works.",
|
||||
},
|
||||
{
|
||||
slug: "members",
|
||||
title: "Members",
|
||||
icon: Users,
|
||||
tier: "Pro",
|
||||
navDesc: "Invite your team, set roles",
|
||||
headline: "Bring your whole team",
|
||||
description:
|
||||
"Invite your team, set roles, and share findings and run history across your org.",
|
||||
},
|
||||
{
|
||||
slug: "pentests",
|
||||
title: "Pentests",
|
||||
icon: Search,
|
||||
tier: "Pro",
|
||||
navDesc: "Deeper scans on managed infra",
|
||||
headline: "Launch deeper pentests",
|
||||
description:
|
||||
"Run deeper, longer pentests on managed infrastructure whenever you need them.",
|
||||
},
|
||||
{
|
||||
slug: "dashboard",
|
||||
title: "Dashboard",
|
||||
icon: LayoutDashboard,
|
||||
tier: "Pro",
|
||||
navDesc: "Everything in one place",
|
||||
headline: "See everything in one place",
|
||||
description:
|
||||
"Track every project, run, and finding across your org from a single dashboard.",
|
||||
},
|
||||
{
|
||||
slug: "platform_issues",
|
||||
title: "Issues",
|
||||
icon: AlertTriangle,
|
||||
tier: "Pro",
|
||||
navDesc: "Triage across your org",
|
||||
headline: "Triage findings across your org",
|
||||
description:
|
||||
"Manage and triage findings across every project and repository in one queue.",
|
||||
},
|
||||
{
|
||||
slug: "chat",
|
||||
title: "Chat",
|
||||
icon: MessageSquare,
|
||||
tier: "Pro",
|
||||
navDesc: "Ask about any finding",
|
||||
headline: "Ask Strix anything",
|
||||
description:
|
||||
"Ask the agents about any finding, run, or part of your app in natural language.",
|
||||
},
|
||||
{
|
||||
slug: "networks",
|
||||
title: "Networks",
|
||||
icon: Network,
|
||||
tier: "Enterprise",
|
||||
navDesc: "Reach internal, VPN-only targets",
|
||||
headline: "Scan internal networks",
|
||||
description:
|
||||
"Connect private networks to scan internal applications, VPN-only services, and RFC1918 targets.",
|
||||
},
|
||||
{
|
||||
slug: "knowledge",
|
||||
title: "Knowledge",
|
||||
icon: Database,
|
||||
tier: "Pro",
|
||||
navDesc: "Give agents context",
|
||||
headline: "Give agents context",
|
||||
description:
|
||||
"Teach Strix about your systems and business logic so every run gets smarter.",
|
||||
},
|
||||
];
|
||||
|
||||
export const FEATURES: Record<string, ProFeature> = Object.fromEntries(
|
||||
PLATFORM_FEATURES.map((f) => [f.slug, f])
|
||||
);
|
||||
|
||||
/**
|
||||
* Order the platform rows appear in the sidebar's single, ungrouped nav list
|
||||
* (after the run/local rows). No section headers; tier is shown only by each
|
||||
* row's inline tag.
|
||||
*/
|
||||
export const PLATFORM_ORDER: string[] = [
|
||||
"pr_reviews",
|
||||
"repositories",
|
||||
"domains",
|
||||
"integrations",
|
||||
"members",
|
||||
"pentests",
|
||||
"networks",
|
||||
"chat",
|
||||
"dashboard",
|
||||
"platform_issues",
|
||||
"knowledge",
|
||||
];
|
||||
@@ -0,0 +1,61 @@
|
||||
export interface ParsedTarget {
|
||||
display: string;
|
||||
href: string | null;
|
||||
provider: "github" | "gitlab" | "bitbucket" | null;
|
||||
}
|
||||
|
||||
export function parseTarget(target: string): ParsedTarget {
|
||||
// GitHub URL
|
||||
const ghMatch = target.match(
|
||||
/(?:https?:\/\/)?(?:www\.)?github\.com\/([^\s/]+\/[^\s/]+)/
|
||||
);
|
||||
if (ghMatch) {
|
||||
const slug = ghMatch[1].replace(/\.git$/, "");
|
||||
return { display: slug, href: `https://github.com/${slug}`, provider: "github" };
|
||||
}
|
||||
|
||||
// GitLab URL
|
||||
const glMatch = target.match(
|
||||
/(?:https?:\/\/)?(?:www\.)?gitlab\.com\/([^\s/]+\/[^\s/]+)/
|
||||
);
|
||||
if (glMatch) {
|
||||
const slug = glMatch[1].replace(/\.git$/, "");
|
||||
return { display: slug, href: `https://gitlab.com/${slug}`, provider: "gitlab" };
|
||||
}
|
||||
|
||||
// Bitbucket URL
|
||||
const bbMatch = target.match(
|
||||
/(?:https?:\/\/)?(?:www\.)?bitbucket\.org\/([^\s/]+\/[^\s/]+)/
|
||||
);
|
||||
if (bbMatch) {
|
||||
const slug = bbMatch[1].replace(/\.git$/, "");
|
||||
return { display: slug, href: `https://bitbucket.org/${slug}`, provider: "bitbucket" };
|
||||
}
|
||||
|
||||
// URL with protocol
|
||||
if (/^https?:\/\//i.test(target)) {
|
||||
return {
|
||||
display: target.replace(/^https?:\/\/(www\.)?/, ""),
|
||||
href: target,
|
||||
provider: null,
|
||||
};
|
||||
}
|
||||
|
||||
// Bare domain (e.g. "example.com" or "example.com/path")
|
||||
if (/^[a-zA-Z0-9][\w.-]*\.[a-zA-Z]{2,}/.test(target)) {
|
||||
return { display: target, href: `https://${target}`, provider: null };
|
||||
}
|
||||
|
||||
// Not a URL
|
||||
return { display: target, href: null, provider: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* A clean, human-readable run title derived from the scan target (e.g.
|
||||
* "arch.co") rather than the raw run dir name ("arch-co_ca3f"). Falls back to
|
||||
* the raw name, then a generic label.
|
||||
*/
|
||||
export function runTitle(target: string | null, fallback: string): string {
|
||||
if (target) return parseTarget(target).display.replace(/\/$/, "");
|
||||
return fallback || "Untitled scan";
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
export function formatDate(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
export function isValidUrl(url: string): boolean {
|
||||
if (!url || !url.trim()) return false;
|
||||
|
||||
try {
|
||||
// Add protocol if missing
|
||||
let urlWithProtocol = url.trim();
|
||||
if (!urlWithProtocol.startsWith("http://") && !urlWithProtocol.startsWith("https://")) {
|
||||
urlWithProtocol = `https://${urlWithProtocol}`;
|
||||
}
|
||||
const parsed = new URL(urlWithProtocol);
|
||||
// Check if it has a valid hostname with at least one dot (domain)
|
||||
return Boolean(parsed.hostname) && parsed.hostname.includes(".");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function isValidDomain(domain: string | null): boolean {
|
||||
if (!domain) return false;
|
||||
|
||||
// Basic domain validation regex
|
||||
const domainRegex = /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
|
||||
|
||||
// Check basic format
|
||||
if (!domainRegex.test(domain)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check length constraints
|
||||
if (domain.length > 253) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Must have at least one dot (TLD required)
|
||||
if (!domain.includes(".")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check each label length (max 63 chars per label)
|
||||
const labels = domain.split(".");
|
||||
for (const label of labels) {
|
||||
if (label.length === 0 || label.length > 63) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// TLD should be at least 2 characters
|
||||
const tld = labels[labels.length - 1];
|
||||
if (tld.length < 2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function formatCurrency(amount: number): string {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
export function formatTimeAgo(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000);
|
||||
|
||||
if (diffInSeconds < 60) {
|
||||
return "just now";
|
||||
}
|
||||
if (diffInSeconds < 3600) {
|
||||
const minutes = Math.floor(diffInSeconds / 60);
|
||||
return `${minutes}m ago`;
|
||||
}
|
||||
if (diffInSeconds < 86400) {
|
||||
const hours = Math.floor(diffInSeconds / 3600);
|
||||
return `${hours}h ago`;
|
||||
}
|
||||
if (diffInSeconds < 604800) {
|
||||
const days = Math.floor(diffInSeconds / 86400);
|
||||
return `${days}d ago`;
|
||||
}
|
||||
return formatDate(dateString);
|
||||
}
|
||||
|
||||
export function formatTimeUntil(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffInSeconds = Math.floor((date.getTime() - now.getTime()) / 1000);
|
||||
|
||||
if (diffInSeconds < 0) return "now";
|
||||
if (diffInSeconds < 60) return "in <1m";
|
||||
if (diffInSeconds < 3600) {
|
||||
const minutes = Math.floor(diffInSeconds / 60);
|
||||
return `in ${minutes}m`;
|
||||
}
|
||||
if (diffInSeconds < 86400) {
|
||||
const hours = Math.floor(diffInSeconds / 3600);
|
||||
return `in ${hours}h`;
|
||||
}
|
||||
if (diffInSeconds < 604800) {
|
||||
const days = Math.round(diffInSeconds / 86400);
|
||||
return `in ${days || 1}d`;
|
||||
}
|
||||
return formatDate(dateString);
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
const LANGUAGE_MAP: Record<string, string> = {
|
||||
js: "javascript", ts: "typescript", tsx: "typescript", jsx: "javascript",
|
||||
py: "python", rb: "ruby", go: "go", rs: "rust", java: "java", php: "php",
|
||||
cs: "csharp", cpp: "cpp", c: "c", sh: "bash", bash: "bash", sql: "sql",
|
||||
html: "html", css: "css", json: "json", yaml: "yaml", yml: "yaml", xml: "xml",
|
||||
};
|
||||
|
||||
export function getLanguageFromFile(filename: string | null | undefined): string | null {
|
||||
if (!filename) return null;
|
||||
const ext = filename.split(".").pop()?.toLowerCase();
|
||||
return ext ? LANGUAGE_MAP[ext] || null : null;
|
||||
}
|
||||
|
||||
export function getSeverityDot(severity: string): string {
|
||||
switch (severity) {
|
||||
case "critical": return "bg-red-500";
|
||||
case "high": return "bg-orange-500";
|
||||
case "medium": return "bg-yellow-500";
|
||||
default: return "bg-blue-500";
|
||||
}
|
||||
}
|
||||
|
||||
import type { Vulnerability } from "@/types/issues";
|
||||
|
||||
export function buildMarkdown(v: Vulnerability): string {
|
||||
const parts: string[] = [];
|
||||
parts.push(`# ${v.title}`);
|
||||
parts.push("");
|
||||
const cwePart = v.cwe && v.cwe.length > 0 ? ` · **CWE:** ${v.cwe.join(", ")}` : "";
|
||||
parts.push(`**Severity:** ${v.severity.toUpperCase()}${v.cvss ? ` · **CVSS:** ${v.cvss}` : ""}${v.cve ? ` · **CVE:** ${v.cve}` : ""}${cwePart}${v.fix_effort ? ` · **Fix Effort:** ${v.fix_effort}` : ""}`);
|
||||
parts.push(`**Status:** ${v.status}`);
|
||||
if (v.target) parts.push(`**Target:** ${v.target}`);
|
||||
if (v.endpoint) parts.push(`**Endpoint:** ${v.method ? `${v.method} ` : ""}${v.endpoint}`);
|
||||
parts.push("");
|
||||
if (v.description) {
|
||||
parts.push("## Description");
|
||||
parts.push("");
|
||||
parts.push(v.description);
|
||||
parts.push("");
|
||||
}
|
||||
if (v.impact) {
|
||||
parts.push("## Impact");
|
||||
parts.push("");
|
||||
parts.push(v.impact);
|
||||
parts.push("");
|
||||
}
|
||||
if (v.evidence) {
|
||||
parts.push("## Evidence");
|
||||
parts.push("");
|
||||
parts.push(v.evidence);
|
||||
parts.push("");
|
||||
}
|
||||
if (v.assumptions) {
|
||||
parts.push("## Assumptions");
|
||||
parts.push("");
|
||||
parts.push(v.assumptions);
|
||||
parts.push("");
|
||||
}
|
||||
if (v.technical_analysis) {
|
||||
parts.push("## Technical Details");
|
||||
parts.push("");
|
||||
parts.push(v.technical_analysis);
|
||||
parts.push("");
|
||||
}
|
||||
if (v.remediation_steps) {
|
||||
parts.push("## How to Fix");
|
||||
parts.push("");
|
||||
parts.push(v.remediation_steps);
|
||||
parts.push("");
|
||||
}
|
||||
if (v.poc_description) {
|
||||
parts.push("## Proof of Concept");
|
||||
parts.push("");
|
||||
parts.push(v.poc_description);
|
||||
if (v.poc_script_code) {
|
||||
parts.push("");
|
||||
parts.push("```");
|
||||
parts.push(v.poc_script_code);
|
||||
parts.push("```");
|
||||
}
|
||||
parts.push("");
|
||||
}
|
||||
if (v.code_locations?.length) {
|
||||
parts.push("## Code Locations");
|
||||
parts.push("");
|
||||
if (v.location_meta) {
|
||||
parts.push(`**Repository:** ${v.location_meta.repo_url} (${v.location_meta.branch})`);
|
||||
parts.push("");
|
||||
}
|
||||
for (const loc of v.code_locations) {
|
||||
if (!loc.file) continue;
|
||||
const lineRef = loc.end_line && loc.end_line !== loc.start_line
|
||||
? `${loc.file}:${loc.start_line}-${loc.end_line}`
|
||||
: `${loc.file}:${loc.start_line}`;
|
||||
parts.push(`### \`${lineRef}\``);
|
||||
if (loc.label) parts.push(loc.label);
|
||||
if (loc.snippet) {
|
||||
parts.push("");
|
||||
parts.push("```");
|
||||
parts.push(loc.snippet);
|
||||
parts.push("```");
|
||||
}
|
||||
if (loc.fix_before && loc.fix_after) {
|
||||
parts.push("");
|
||||
parts.push("```diff");
|
||||
for (const l of loc.fix_before.split("\n")) parts.push(`- ${l}`);
|
||||
for (const l of loc.fix_after.split("\n")) parts.push(`+ ${l}`);
|
||||
parts.push("```");
|
||||
}
|
||||
parts.push("");
|
||||
}
|
||||
} else if (v.code_file && (v.code_before || v.code_after)) {
|
||||
parts.push("## Code");
|
||||
parts.push("");
|
||||
parts.push(`**File:** \`${v.code_file}\``);
|
||||
if (v.code_before && v.code_after) {
|
||||
parts.push("");
|
||||
parts.push("```diff");
|
||||
for (const l of v.code_before.split("\n")) parts.push(`- ${l}`);
|
||||
for (const l of v.code_after.split("\n")) parts.push(`+ ${l}`);
|
||||
parts.push("```");
|
||||
} else if (v.code_diff) {
|
||||
parts.push("");
|
||||
parts.push("```diff");
|
||||
parts.push(v.code_diff);
|
||||
parts.push("```");
|
||||
}
|
||||
parts.push("");
|
||||
}
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
export function buildAIFixPrompt(v: Vulnerability): string {
|
||||
const parts: string[] = [];
|
||||
parts.push("This is a security vulnerability found during a code review.");
|
||||
parts.push("");
|
||||
parts.push(`Vulnerability: ${v.title}`);
|
||||
parts.push(`Severity: ${v.severity.toUpperCase()}`);
|
||||
if (v.cwe && v.cwe.length > 0) {
|
||||
parts.push(`CWE: ${v.cwe.join(", ")}`);
|
||||
}
|
||||
parts.push("");
|
||||
parts.push(v.description);
|
||||
|
||||
if (v.evidence) {
|
||||
parts.push("");
|
||||
parts.push("Evidence:");
|
||||
parts.push(v.evidence);
|
||||
}
|
||||
|
||||
const allLocations = v.code_locations || [];
|
||||
for (const fixLoc of allLocations) {
|
||||
if (!fixLoc.file) continue;
|
||||
const startLine = fixLoc.start_line || 0;
|
||||
const endLine = fixLoc.end_line || startLine;
|
||||
parts.push("");
|
||||
parts.push(`Location: ${fixLoc.file}:${startLine}-${endLine}`);
|
||||
if (fixLoc.label) {
|
||||
parts.push(`Context: ${fixLoc.label}`);
|
||||
}
|
||||
if (fixLoc.fix_before && fixLoc.fix_after) {
|
||||
parts.push("```");
|
||||
parts.push(`// Before:\n${fixLoc.fix_before}`);
|
||||
parts.push(`// After:\n${fixLoc.fix_after}`);
|
||||
parts.push("```");
|
||||
} else if (fixLoc.snippet) {
|
||||
parts.push("```");
|
||||
parts.push(fixLoc.snippet);
|
||||
parts.push("```");
|
||||
}
|
||||
}
|
||||
|
||||
if (v.remediation_steps) {
|
||||
parts.push("");
|
||||
parts.push("How to fix:");
|
||||
parts.push(v.remediation_steps);
|
||||
}
|
||||
parts.push("");
|
||||
parts.push("Please fix this vulnerability. If you propose a fix, make it concise and minimal.");
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
export async function copyToClipboard(text: string): Promise<void> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} catch {
|
||||
const ta = document.createElement("textarea");
|
||||
ta.value = text;
|
||||
ta.style.position = "absolute";
|
||||
ta.style.left = "-9999px";
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "./index.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,16 @@
|
||||
// Local stub of the strix-app auth store. The local viewer has no accounts:
|
||||
// there is never a signed-in user and no feature is entitled, so every upsell
|
||||
// CTA routes to the external cloud sign-up link.
|
||||
interface AuthState {
|
||||
user: null;
|
||||
hasFeature: (feature: string) => boolean;
|
||||
}
|
||||
|
||||
const STATE: AuthState = {
|
||||
user: null,
|
||||
hasFeature: () => false,
|
||||
};
|
||||
|
||||
export function useAuthStore<T>(selector: (s: AuthState) => T): T {
|
||||
return selector(STATE);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
// Types matching the Python tracer's event shapes stored in Convex
|
||||
|
||||
export type EventType =
|
||||
| "run.started"
|
||||
| "run.configured"
|
||||
| "run.completed"
|
||||
| "agent.created"
|
||||
| "agent.status.updated"
|
||||
| "tool.execution.started"
|
||||
| "tool.execution.updated"
|
||||
| "chat.message"
|
||||
| "finding.created"
|
||||
| "finding.reviewed"
|
||||
| "traffic.batch";
|
||||
|
||||
export interface EventActor {
|
||||
agent_id?: string;
|
||||
agent_name?: string;
|
||||
tool_name?: string;
|
||||
execution_id?: number;
|
||||
role?: string;
|
||||
}
|
||||
|
||||
export interface ConvexEvent {
|
||||
_id: string;
|
||||
_creationTime: number;
|
||||
timestamp: string;
|
||||
event_type: EventType;
|
||||
run_id: string;
|
||||
trace_id?: string | null;
|
||||
span_id?: string | null;
|
||||
parent_span_id?: string | null;
|
||||
actor: EventActor | null;
|
||||
payload: Record<string, unknown> | null;
|
||||
status: string | null;
|
||||
error: unknown | null;
|
||||
source?: string;
|
||||
run_metadata?: RunMetadata;
|
||||
}
|
||||
|
||||
export interface RunMetadata {
|
||||
run_id: string;
|
||||
run_name: string | null;
|
||||
start_time: string;
|
||||
end_time: string | null;
|
||||
targets: string[];
|
||||
status: string;
|
||||
user_instructions?: string;
|
||||
max_iterations?: number;
|
||||
}
|
||||
|
||||
export interface AgentNode {
|
||||
id: string;
|
||||
name: string;
|
||||
task: string;
|
||||
status: "running" | "completed" | "failed" | "error";
|
||||
parentId: string | null;
|
||||
children: string[];
|
||||
createdAt: string;
|
||||
toolCount: number;
|
||||
messageCount: number;
|
||||
}
|
||||
|
||||
export interface ToolExecution {
|
||||
executionId: number;
|
||||
agentId: string;
|
||||
toolName: string;
|
||||
args: Record<string, unknown>;
|
||||
result: unknown;
|
||||
status: "running" | "completed" | "failed" | "error";
|
||||
startedAt: string;
|
||||
completedAt: string | null;
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
messageId: number;
|
||||
agentId: string | null;
|
||||
role: string;
|
||||
content: string;
|
||||
timestamp: string;
|
||||
metadata: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface Finding {
|
||||
id: string;
|
||||
title: string;
|
||||
severity: "critical" | "high" | "medium" | "low";
|
||||
description?: string;
|
||||
target?: string;
|
||||
endpoint?: string;
|
||||
method?: string;
|
||||
cvss?: number;
|
||||
cve?: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface ToolRendererProps {
|
||||
toolName: string;
|
||||
args: Record<string, unknown>;
|
||||
result: unknown;
|
||||
status: "running" | "completed" | "failed" | "error";
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
export type VulnerabilitySeverity = "critical" | "high" | "medium" | "low";
|
||||
export type VulnerabilityStatus = "open" | "in_progress" | "snoozed" | "fixed" | "ignored";
|
||||
export type FixEffort = "trivial" | "low" | "medium" | "high";
|
||||
|
||||
export const ACTIVE_STATUSES: VulnerabilityStatus[] = ["open", "in_progress", "snoozed"];
|
||||
export const RESOLVED_STATUSES: VulnerabilityStatus[] = ["fixed", "ignored"];
|
||||
|
||||
// Statuses worth retesting in a "retest all" — everything except ignored
|
||||
// (fixed issues are still re-verified; ignored issues are intentionally skipped).
|
||||
export const RETESTABLE_STATUSES: VulnerabilityStatus[] = ["open", "in_progress", "snoozed", "fixed"];
|
||||
|
||||
export const ALL_STATUSES: VulnerabilityStatus[] = ["open", "in_progress", "snoozed", "fixed", "ignored"];
|
||||
|
||||
export interface StatusCounts {
|
||||
all: number;
|
||||
open: number;
|
||||
in_progress: number;
|
||||
snoozed: number;
|
||||
fixed: number;
|
||||
ignored: number;
|
||||
}
|
||||
|
||||
export interface StatusMeta {
|
||||
label: string;
|
||||
color: string;
|
||||
dotColor: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export const STATUS_META: Record<VulnerabilityStatus, StatusMeta> = {
|
||||
open: {
|
||||
label: "Open",
|
||||
color: "bg-red-500/10 text-red-400 border-red-500/20",
|
||||
dotColor: "bg-red-500",
|
||||
description: "Newly discovered, awaiting triage",
|
||||
},
|
||||
in_progress: {
|
||||
label: "In Progress",
|
||||
color: "bg-blue-500/10 text-blue-400 border-blue-500/20",
|
||||
dotColor: "bg-blue-500",
|
||||
description: "Someone is working on this",
|
||||
},
|
||||
snoozed: {
|
||||
label: "Snoozed",
|
||||
color: "bg-purple-500/10 text-purple-400 border-purple-500/20",
|
||||
dotColor: "bg-purple-500",
|
||||
description: "Temporarily hidden until a follow-up date",
|
||||
},
|
||||
fixed: {
|
||||
label: "Fixed",
|
||||
color: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20",
|
||||
dotColor: "bg-emerald-500",
|
||||
description: "This vulnerability has been fixed",
|
||||
},
|
||||
ignored: {
|
||||
label: "Ignored",
|
||||
color: "bg-gray-500/10 text-gray-400 border-gray-500/20",
|
||||
dotColor: "bg-gray-500",
|
||||
description: "Acknowledged but accepted",
|
||||
},
|
||||
};
|
||||
|
||||
export const FIX_EFFORT_META: Record<FixEffort, { label: string; color: string }> = {
|
||||
trivial: { label: "Trivial", color: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20" },
|
||||
low: { label: "Low", color: "bg-blue-500/10 text-blue-400 border-blue-500/20" },
|
||||
medium: { label: "Medium", color: "bg-yellow-500/10 text-yellow-400 border-yellow-500/20" },
|
||||
high: { label: "High", color: "bg-orange-500/10 text-orange-400 border-orange-500/20" },
|
||||
};
|
||||
|
||||
export interface CodeLocation {
|
||||
file: string;
|
||||
start_line: number;
|
||||
end_line?: number;
|
||||
snippet?: string;
|
||||
label?: string;
|
||||
fix_before?: string;
|
||||
fix_after?: string;
|
||||
}
|
||||
|
||||
export interface CVSSBreakdown {
|
||||
attack_vector: string | null;
|
||||
attack_complexity: string | null;
|
||||
privileges_required: string | null;
|
||||
user_interaction: string | null;
|
||||
scope: string | null;
|
||||
confidentiality: string | null;
|
||||
integrity: string | null;
|
||||
availability: string | null;
|
||||
}
|
||||
|
||||
export interface Vulnerability {
|
||||
id: string;
|
||||
scan_id: string | null;
|
||||
pr_review_id: string | null;
|
||||
title: string;
|
||||
description: string;
|
||||
cve: string | null;
|
||||
cvss: number | null;
|
||||
created_at: string;
|
||||
potential_risk_saving: number | null;
|
||||
risk_saving_description: string | null;
|
||||
status: VulnerabilityStatus;
|
||||
severity: VulnerabilitySeverity;
|
||||
impact: string | null;
|
||||
endpoint: string | null;
|
||||
method: string | null;
|
||||
target: string | null;
|
||||
technical_analysis: string | null;
|
||||
poc_description: string | null;
|
||||
poc_script_code: string | null;
|
||||
code_diff: string | null;
|
||||
code_file: string | null;
|
||||
code_before: string | null;
|
||||
code_after: string | null;
|
||||
cwe: string[] | null;
|
||||
code_locations: CodeLocation[] | null;
|
||||
remediation_steps: string | null;
|
||||
fix_pr_body: string | null;
|
||||
evidence: string | null;
|
||||
assumptions: string | null;
|
||||
fix_effort: FixEffort | null;
|
||||
cvss_breakdown: CVSSBreakdown | null;
|
||||
status_changed_at: string | null;
|
||||
status_changed_by: string | null;
|
||||
status_note: string | null;
|
||||
snoozed_until: string | null;
|
||||
reopened_at: string | null;
|
||||
reopened_by: string | null;
|
||||
original_severity: VulnerabilitySeverity | null;
|
||||
severity_changed_at: string | null;
|
||||
severity_changed_by: string | null;
|
||||
severity_override_reason: string | null;
|
||||
retest_of_vulnerability_id: string | null;
|
||||
slack_thread_url?: string;
|
||||
display_number?: number | null;
|
||||
location_meta?: {
|
||||
branch: string;
|
||||
provider: string;
|
||||
repo_url: string;
|
||||
} | null;
|
||||
fix_pr_eligible?: boolean;
|
||||
fix_pr_reason?: string | null;
|
||||
fix_pr_url?: string | null;
|
||||
}
|
||||
|
||||
export interface VulnerabilityFilters {
|
||||
scan_id?: string;
|
||||
severity?: VulnerabilitySeverity;
|
||||
status?: VulnerabilityStatus;
|
||||
search?: string;
|
||||
sortBy?: "cvss" | "created_at";
|
||||
sortOrder?: "asc" | "desc";
|
||||
domain_id?: string;
|
||||
repository_id?: string;
|
||||
}
|
||||
|
||||
export interface VulnerabilityAction {
|
||||
type: "status_change" | "generate_report" | "create_ticket";
|
||||
notes?: string;
|
||||
verification?: string;
|
||||
reason?: string;
|
||||
explanation?: string;
|
||||
report_type?: string;
|
||||
system?: string;
|
||||
priority?: string;
|
||||
assignee?: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export const SEVERITY_COLORS: Record<VulnerabilitySeverity, string> = {
|
||||
critical: "bg-red-500/20 text-red-500 border-red-500/30",
|
||||
high: "bg-orange-500/20 text-orange-500 border-orange-500/30",
|
||||
medium: "bg-yellow-500/20 text-yellow-500 border-yellow-500/30",
|
||||
low: "bg-blue-500/20 text-blue-500 border-blue-500/30",
|
||||
};
|
||||
|
||||
export const STATUS_COLORS: Record<VulnerabilityStatus, string> = {
|
||||
open: STATUS_META.open.color,
|
||||
in_progress: STATUS_META.in_progress.color,
|
||||
snoozed: STATUS_META.snoozed.color,
|
||||
fixed: STATUS_META.fixed.color,
|
||||
ignored: STATUS_META.ignored.color,
|
||||
};
|
||||
|
||||
export function isSeverityOverridden(
|
||||
v: Pick<Vulnerability, "original_severity" | "severity">
|
||||
): boolean {
|
||||
return v.original_severity != null && v.original_severity !== v.severity;
|
||||
}
|
||||
|
||||
export function formatCvssLabel(cvss: number | null): string {
|
||||
if (cvss === null) return "N/A";
|
||||
if (cvss >= 9.0) return "Critical";
|
||||
if (cvss >= 7.0) return "High";
|
||||
if (cvss >= 4.0) return "Medium";
|
||||
return "Low";
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import { fileURLToPath, URL } from "node:url";
|
||||
|
||||
// The viewer is served as static files by a stdlib Python server on an
|
||||
// arbitrary ephemeral port, so all asset URLs must be relative (base: "./").
|
||||
// The build output is committed at strix/viewer/viewer_dist and shipped.
|
||||
export default defineConfig({
|
||||
base: "./",
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": fileURLToPath(new URL("./src", import.meta.url)),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: "../viewer/viewer_dist",
|
||||
emptyOutDir: true,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Tests for building and encrypting the viewer PDF report."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from io import BytesIO
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
from pypdf import PdfReader
|
||||
from pypdf.errors import WrongPasswordError
|
||||
|
||||
from strix.viewer.report_pdf import (
|
||||
build_encrypted_report,
|
||||
encrypt_pdf,
|
||||
generate_password,
|
||||
generate_report_pdf,
|
||||
)
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _make_run(base: Path, name: str = "sample") -> Path:
|
||||
run_dir = base / "strix_runs" / name
|
||||
run_dir.mkdir(parents=True)
|
||||
record = {
|
||||
"run_name": name,
|
||||
"targets_info": [{"original": "https://example.com"}],
|
||||
"scan_mode": "deep",
|
||||
"status": "completed",
|
||||
"start_time": "2026-01-01T00:00:00Z",
|
||||
"end_time": "2026-01-01T01:02:03Z",
|
||||
"scan_results": {
|
||||
"executive_summary": "Summary with an ampersand & an <angle> bracket.",
|
||||
"recommendations": "Patch things.",
|
||||
},
|
||||
}
|
||||
(run_dir / "run.json").write_text(json.dumps(record), encoding="utf-8")
|
||||
vulns = [
|
||||
{
|
||||
"title": "SQL Injection",
|
||||
"severity": "CRITICAL",
|
||||
"cvss": 9.8,
|
||||
"description": "User input reaches the query.",
|
||||
"impact": "Full database read.",
|
||||
"technical_analysis": "Details here.",
|
||||
"poc_description": "Send a crafted parameter.",
|
||||
"poc_script_code": "print('exploit')",
|
||||
"evidence": "HTTP 500 with SQL error.",
|
||||
"remediation_steps": ["Use parameterized queries", "Validate input"],
|
||||
"target": "https://example.com",
|
||||
"endpoint": "/login",
|
||||
"method": "POST",
|
||||
},
|
||||
{"title": "Informational note", "severity": "info"},
|
||||
]
|
||||
(run_dir / "vulnerabilities.json").write_text(json.dumps(vulns), encoding="utf-8")
|
||||
return run_dir
|
||||
|
||||
|
||||
def test_generate_report_pdf_has_pdf_header(tmp_path: Path) -> None:
|
||||
run_dir = _make_run(tmp_path)
|
||||
pdf = generate_report_pdf(run_dir)
|
||||
assert pdf.startswith(b"%PDF-")
|
||||
assert len(pdf) > 1000
|
||||
|
||||
|
||||
def test_generate_password_is_long_and_random() -> None:
|
||||
first = generate_password()
|
||||
second = generate_password()
|
||||
assert len(first) >= 20
|
||||
assert first != second
|
||||
|
||||
|
||||
def test_encrypt_pdf_roundtrip(tmp_path: Path) -> None:
|
||||
run_dir = _make_run(tmp_path)
|
||||
pdf = generate_report_pdf(run_dir)
|
||||
password = generate_password()
|
||||
encrypted = encrypt_pdf(pdf, password)
|
||||
|
||||
reader = PdfReader(BytesIO(encrypted))
|
||||
assert reader.is_encrypted
|
||||
assert reader.decrypt(password)
|
||||
# A correct password unlocks the pages.
|
||||
assert len(reader.pages) >= 1
|
||||
|
||||
|
||||
def test_wrong_password_is_rejected(tmp_path: Path) -> None:
|
||||
run_dir = _make_run(tmp_path)
|
||||
encrypted = encrypt_pdf(generate_report_pdf(run_dir), "correct-horse-battery")
|
||||
with pytest.raises(WrongPasswordError):
|
||||
PdfReader(BytesIO(encrypted), password="not-the-password")
|
||||
|
||||
|
||||
def test_build_encrypted_report(tmp_path: Path) -> None:
|
||||
run_dir = _make_run(tmp_path, name="run-42")
|
||||
pdf_bytes, password, filename = build_encrypted_report(run_dir)
|
||||
|
||||
assert filename == "strix-report-run-42.pdf"
|
||||
assert len(password) >= 20
|
||||
reader = PdfReader(BytesIO(pdf_bytes))
|
||||
assert reader.is_encrypted
|
||||
assert reader.decrypt(password)
|
||||
@@ -0,0 +1,416 @@
|
||||
"""Tests for the local run viewer (strix.viewer) and its path helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from strix.core.paths import latest_run_dir, runs_base_dir
|
||||
from strix.viewer.server import serve
|
||||
from strix.viewer.transcript import (
|
||||
build_run_state,
|
||||
read_report_markdown,
|
||||
read_run_summary,
|
||||
read_vulnerabilities,
|
||||
)
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Mapping
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _make_run(base: Path, name: str, *, status: str, end_time: str | None) -> Path:
|
||||
run_dir = base / "strix_runs" / name
|
||||
state_dir = run_dir / ".state"
|
||||
state_dir.mkdir(parents=True)
|
||||
record = {"run_name": name, "status": status, "end_time": end_time}
|
||||
(run_dir / "run.json").write_text(json.dumps(record), encoding="utf-8")
|
||||
agents = {
|
||||
"statuses": {"root": "completed", "child": "running"},
|
||||
"names": {"root": "strix", "child": "recon"},
|
||||
"parent_of": {"root": None, "child": "root"},
|
||||
}
|
||||
(state_dir / "agents.json").write_text(json.dumps(agents), encoding="utf-8")
|
||||
return run_dir
|
||||
|
||||
|
||||
def test_latest_run_dir_none_when_no_runs(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
assert latest_run_dir() is None
|
||||
assert runs_base_dir() == tmp_path / "strix_runs"
|
||||
|
||||
|
||||
def test_latest_run_dir_picks_newest_by_record_mtime(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
older = _make_run(tmp_path, "old", status="completed", end_time="2026-01-01T00:00:00Z")
|
||||
newer = _make_run(tmp_path, "new", status="running", end_time=None)
|
||||
# Force a newer mtime on the second run's record.
|
||||
os.utime(newer / "run.json", (2_000_000_000, 2_000_000_000))
|
||||
os.utime(older / "run.json", (1_000_000_000, 1_000_000_000))
|
||||
assert latest_run_dir() == newer
|
||||
|
||||
|
||||
def test_read_run_summary_finished_flag(tmp_path: Path) -> None:
|
||||
finished = _make_run(tmp_path, "done", status="completed", end_time="2026-01-01T00:00:00Z")
|
||||
live = _make_run(tmp_path, "live", status="running", end_time=None)
|
||||
assert read_run_summary(finished)["finished"] is True
|
||||
assert read_run_summary(live)["finished"] is False
|
||||
# A terminal status without an end_time is not "finished".
|
||||
partial = _make_run(tmp_path, "partial", status="failed", end_time=None)
|
||||
assert read_run_summary(partial)["finished"] is False
|
||||
|
||||
|
||||
def test_read_missing_artifacts_return_defaults(tmp_path: Path) -> None:
|
||||
run_dir = _make_run(tmp_path, "empty", status="running", end_time=None)
|
||||
assert read_vulnerabilities(run_dir) == []
|
||||
assert read_report_markdown(run_dir) == ""
|
||||
|
||||
|
||||
def test_build_run_state_from_agents_json(tmp_path: Path) -> None:
|
||||
run_dir = _make_run(tmp_path, "graph", status="running", end_time=None)
|
||||
state = build_run_state(run_dir)
|
||||
ids = {a["id"] for a in state["agents"]}
|
||||
assert ids == {"root", "child"}
|
||||
child = next(a for a in state["agents"] if a["id"] == "child")
|
||||
assert child["parent_id"] == "root"
|
||||
assert child["name"] == "recon"
|
||||
# No agents.db, so no message/tool events.
|
||||
assert state["events"] == []
|
||||
|
||||
|
||||
def _get(url: str) -> tuple[int, str, bytes]:
|
||||
with urllib.request.urlopen(url) as resp: # noqa: S310 - localhost test server
|
||||
return resp.status, resp.headers.get("Content-Type", ""), resp.read()
|
||||
|
||||
|
||||
def test_server_serves_api_and_static(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
run_dir = _make_run(tmp_path, "served", status="completed", end_time="2026-01-01T00:00:00Z")
|
||||
|
||||
assets = tmp_path / "bundle"
|
||||
(assets / "assets").mkdir(parents=True)
|
||||
(assets / "index.html").write_text("<!doctype html><div id=root></div>", encoding="utf-8")
|
||||
(assets / "assets" / "app.js").write_text("console.log(1)", encoding="utf-8")
|
||||
monkeypatch.setattr("strix.viewer.server.bundle_dir", lambda: assets)
|
||||
|
||||
httpd, url, _ = serve(run_dir, open_browser=False)
|
||||
try:
|
||||
status, ctype, body = _get(f"{url}/api/run")
|
||||
assert status == 200
|
||||
assert "application/json" in ctype
|
||||
assert json.loads(body)["finished"] is True
|
||||
|
||||
status, _, body = _get(f"{url}/api/transcript")
|
||||
assert {a["id"] for a in json.loads(body)["agents"]} == {"root", "child"}
|
||||
|
||||
# Real asset is served.
|
||||
status, ctype, _ = _get(f"{url}/assets/app.js")
|
||||
assert status == 200
|
||||
|
||||
# Unknown non-API route falls back to index.html (SPA routing).
|
||||
status, ctype, body = _get(f"{url}/agents/root")
|
||||
assert status == 200
|
||||
assert b"<div id=root>" in body
|
||||
finally:
|
||||
httpd.shutdown()
|
||||
httpd.server_close()
|
||||
|
||||
|
||||
def test_server_event_endpoint_forwards_cta(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
run_dir = _make_run(tmp_path, "evt", status="running", end_time=None)
|
||||
assets = tmp_path / "bundle"
|
||||
assets.mkdir()
|
||||
(assets / "index.html").write_text("x", encoding="utf-8")
|
||||
monkeypatch.setattr("strix.viewer.server.bundle_dir", lambda: assets)
|
||||
|
||||
seen: list[tuple[str, str | None]] = []
|
||||
monkeypatch.setattr(
|
||||
"strix.telemetry.posthog.viewer_cta_clicked",
|
||||
lambda cta, surface=None: seen.append((cta, surface)),
|
||||
)
|
||||
|
||||
httpd, url, _ = serve(run_dir, open_browser=False)
|
||||
try:
|
||||
body = json.dumps(
|
||||
{"event": "cta_clicked", "cta": "PR reviews", "surface": "sidebar_nav"}
|
||||
).encode()
|
||||
req = urllib.request.Request( # noqa: S310 - localhost test server
|
||||
f"{url}/api/event", data=body, headers={"Content-Type": "application/json"}
|
||||
)
|
||||
with urllib.request.urlopen(req) as resp: # noqa: S310
|
||||
assert resp.status == 204
|
||||
assert seen == [("PR reviews", "sidebar_nav")]
|
||||
finally:
|
||||
httpd.shutdown()
|
||||
httpd.server_close()
|
||||
|
||||
|
||||
def test_server_event_endpoint_forwards_email_funnel(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
run_dir = _make_run(tmp_path, "evt2", status="running", end_time=None)
|
||||
assets = tmp_path / "bundle"
|
||||
assets.mkdir()
|
||||
(assets / "index.html").write_text("x", encoding="utf-8")
|
||||
monkeypatch.setattr("strix.viewer.server.bundle_dir", lambda: assets)
|
||||
|
||||
seen: list[tuple[str, str | None]] = []
|
||||
monkeypatch.setattr(
|
||||
"strix.telemetry.posthog.viewer_email_event",
|
||||
lambda step, purpose=None: seen.append((step, purpose)),
|
||||
)
|
||||
|
||||
httpd, url, _ = serve(run_dir, open_browser=False)
|
||||
try:
|
||||
# A whitelisted funnel event is forwarded; an unknown event is ignored.
|
||||
for payload, expected in (
|
||||
({"event": "email_verified", "purpose": "report"}, [("email_verified", "report")]),
|
||||
({"event": "not_a_real_event"}, [("email_verified", "report")]),
|
||||
):
|
||||
req = urllib.request.Request( # noqa: S310 - localhost test server
|
||||
f"{url}/api/event",
|
||||
data=json.dumps(payload).encode(),
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
with urllib.request.urlopen(req) as resp: # noqa: S310
|
||||
assert resp.status == 204
|
||||
assert seen == expected
|
||||
finally:
|
||||
httpd.shutdown()
|
||||
httpd.server_close()
|
||||
|
||||
|
||||
def _post(
|
||||
url: str, path: str, payload: Mapping[str, object], *, cookie: str | None = None
|
||||
) -> tuple[int, bytes]:
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if cookie:
|
||||
headers["Cookie"] = cookie
|
||||
req = urllib.request.Request( # noqa: S310 - localhost test server
|
||||
url + path, data=json.dumps(payload).encode(), headers=headers, method="POST"
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req) as resp: # noqa: S310
|
||||
return resp.status, resp.read()
|
||||
except urllib.error.HTTPError as exc:
|
||||
return exc.code, exc.read()
|
||||
|
||||
|
||||
def _session_cookie(url: str, token: str) -> str:
|
||||
"""Bootstrap a session via the tokened URL and return its ``name=value`` cookie."""
|
||||
bootstrap = f"{url}/?token={token}"
|
||||
with urllib.request.urlopen(bootstrap) as resp: # noqa: S310 - localhost test server
|
||||
raw = str(resp.headers.get("Set-Cookie", ""))
|
||||
return raw.split(";", 1)[0]
|
||||
|
||||
|
||||
def _get_status(url: str) -> int:
|
||||
try:
|
||||
with urllib.request.urlopen(url) as resp: # noqa: S310 - localhost test server
|
||||
return int(resp.status)
|
||||
except urllib.error.HTTPError as exc:
|
||||
return int(exc.code)
|
||||
|
||||
|
||||
def _bundle(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
assets = tmp_path / "bundle"
|
||||
assets.mkdir()
|
||||
(assets / "index.html").write_text("<!doctype html><div id=root></div>", encoding="utf-8")
|
||||
monkeypatch.setattr("strix.viewer.server.bundle_dir", lambda: assets)
|
||||
|
||||
|
||||
def test_capability_issued_only_for_tokened_bootstrap(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
run_dir = _make_run(tmp_path, "cookie", status="running", end_time=None)
|
||||
assets = tmp_path / "bundle"
|
||||
(assets / "assets").mkdir(parents=True)
|
||||
(assets / "index.html").write_text("<!doctype html>index", encoding="utf-8")
|
||||
(assets / "assets" / "app.js").write_text("1", encoding="utf-8")
|
||||
monkeypatch.setattr("strix.viewer.server.bundle_dir", lambda: assets)
|
||||
|
||||
httpd, url, token = serve(run_dir, open_browser=False)
|
||||
try:
|
||||
# A bare index load -- all a reachable client can do -- hands out nothing.
|
||||
with urllib.request.urlopen(url + "/") as resp: # noqa: S310
|
||||
assert resp.headers.get("Set-Cookie") is None
|
||||
|
||||
# A wrong token is likewise refused the capability.
|
||||
with urllib.request.urlopen(f"{url}/?token=wrong") as resp: # noqa: S310
|
||||
assert resp.headers.get("Set-Cookie") is None
|
||||
|
||||
# Only the correct bootstrap token mints the session cookie.
|
||||
with urllib.request.urlopen(f"{url}/?token={token}") as resp: # noqa: S310
|
||||
cookie = str(resp.headers.get("Set-Cookie", ""))
|
||||
assert "strix_viewer_session=" in cookie
|
||||
assert "HttpOnly" in cookie and "SameSite=Strict" in cookie
|
||||
|
||||
# Static assets never carry it.
|
||||
with urllib.request.urlopen(url + "/assets/app.js") as resp: # noqa: S310
|
||||
assert resp.headers.get("Set-Cookie") is None
|
||||
finally:
|
||||
httpd.shutdown()
|
||||
httpd.server_close()
|
||||
|
||||
|
||||
def test_unauthorized_client_cannot_acquire_capability(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
run_dir = _make_run(tmp_path, "exposed", status="running", end_time=None)
|
||||
_bundle(tmp_path, monkeypatch)
|
||||
|
||||
delivered: list[tuple[str, str]] = []
|
||||
|
||||
def handler(agent_id: str, message: str) -> bool:
|
||||
delivered.append((agent_id, message))
|
||||
return True
|
||||
|
||||
httpd, url, _ = serve(run_dir, open_browser=False, steer_handler=handler)
|
||||
try:
|
||||
# A direct network client can reach the page but is handed no capability,
|
||||
# so replaying an empty/guessed cookie cannot steer a live scan.
|
||||
with urllib.request.urlopen(url + "/") as resp: # noqa: S310
|
||||
assert resp.headers.get("Set-Cookie") is None
|
||||
status, _ = _post(
|
||||
url,
|
||||
"/api/agents/steer",
|
||||
{"agent_id": "root", "message": "pwn"},
|
||||
cookie="strix_viewer_session=",
|
||||
)
|
||||
assert status == 403
|
||||
assert delivered == []
|
||||
finally:
|
||||
httpd.shutdown()
|
||||
httpd.server_close()
|
||||
|
||||
|
||||
def test_auth_status_reflects_expiry(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
run_dir = _make_run(tmp_path, "status", status="running", end_time=None)
|
||||
_bundle(tmp_path, monkeypatch)
|
||||
monkeypatch.setattr("strix.viewer.auth.read_auth", lambda: {"email": "[email protected]", "token": "t"})
|
||||
verified = {"value": True}
|
||||
monkeypatch.setattr("strix.viewer.auth.is_verified", lambda: verified["value"])
|
||||
|
||||
httpd, url, _ = serve(run_dir, open_browser=False)
|
||||
try:
|
||||
_, _, body = _get(f"{url}/api/auth/status")
|
||||
assert json.loads(body) == {"verified": True, "email": "[email protected]"}
|
||||
|
||||
# Once expired, status must advertise unverified so the SPA re-prompts.
|
||||
verified["value"] = False
|
||||
_, _, body = _get(f"{url}/api/auth/status")
|
||||
assert json.loads(body)["verified"] is False
|
||||
finally:
|
||||
httpd.shutdown()
|
||||
httpd.server_close()
|
||||
|
||||
|
||||
def test_steer_requires_session_cookie(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
run_dir = _make_run(tmp_path, "steer", status="running", end_time=None)
|
||||
_bundle(tmp_path, monkeypatch)
|
||||
|
||||
delivered: list[tuple[str, str]] = []
|
||||
|
||||
def handler(agent_id: str, message: str) -> bool:
|
||||
delivered.append((agent_id, message))
|
||||
return True
|
||||
|
||||
httpd, url, token = serve(run_dir, open_browser=False, steer_handler=handler)
|
||||
try:
|
||||
body = {"agent_id": "root", "message": "focus on auth"}
|
||||
# No cookie: rejected before reaching the live coordinator.
|
||||
status, _ = _post(url, "/api/agents/steer", body)
|
||||
assert status == 403
|
||||
assert delivered == []
|
||||
|
||||
# With the session cookie the message is delivered.
|
||||
status, raw = _post(url, "/api/agents/steer", body, cookie=_session_cookie(url, token))
|
||||
assert status == 200
|
||||
assert json.loads(raw)["ok"] is True
|
||||
assert delivered == [("root", "focus on auth")]
|
||||
finally:
|
||||
httpd.shutdown()
|
||||
httpd.server_close()
|
||||
|
||||
|
||||
def test_report_send_requires_session_cookie(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
run_dir = _make_run(tmp_path, "report", status="completed", end_time="2026-01-01T00:00:00Z")
|
||||
_bundle(tmp_path, monkeypatch)
|
||||
|
||||
# A verified machine token exists, but that alone must not authorize a caller.
|
||||
monkeypatch.setattr("strix.viewer.auth.read_auth", lambda: {"email": "[email protected]", "token": "t"})
|
||||
|
||||
httpd, url, token = serve(run_dir, open_browser=False)
|
||||
try:
|
||||
# No cookie: forbidden before the machine token is ever consulted.
|
||||
status, _ = _post(url, "/api/report/send", {})
|
||||
assert status == 403
|
||||
|
||||
# With the cookie the request clears the session gate; it then reaches
|
||||
# the run resolver, so an unknown run is a 404 rather than a 403.
|
||||
status, _ = _post(
|
||||
url, "/api/report/send", {"run": "does-not-exist"}, cookie=_session_cookie(url, token)
|
||||
)
|
||||
assert status == 404
|
||||
finally:
|
||||
httpd.shutdown()
|
||||
httpd.server_close()
|
||||
|
||||
|
||||
def test_historical_run_data_requires_verification(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
launched = _make_run(tmp_path, "launched", status="completed", end_time="2026-01-01T00:00:00Z")
|
||||
_make_run(tmp_path, "other", status="completed", end_time="2026-01-01T00:00:00Z")
|
||||
_bundle(tmp_path, monkeypatch)
|
||||
|
||||
verified = {"value": False}
|
||||
monkeypatch.setattr("strix.viewer.auth.is_verified", lambda: verified["value"])
|
||||
|
||||
httpd, url, _ = serve(launched, open_browser=False)
|
||||
try:
|
||||
# The launched run is always viewable, no verification required.
|
||||
status, _, _ = _get(f"{url}/api/run")
|
||||
assert status == 200
|
||||
|
||||
# A different run's data is gated behind verification.
|
||||
assert _get_status(f"{url}/api/run?run=other") == 401
|
||||
|
||||
# Once verified, the historical run resolves.
|
||||
verified["value"] = True
|
||||
status, _, _ = _get(f"{url}/api/run?run=other")
|
||||
assert status == 200
|
||||
finally:
|
||||
httpd.shutdown()
|
||||
httpd.server_close()
|
||||
|
||||
|
||||
def test_server_rejects_path_traversal(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
run_dir = _make_run(tmp_path, "guard", status="completed", end_time="2026-01-01T00:00:00Z")
|
||||
secret = tmp_path / "secret.txt"
|
||||
secret.write_text("top secret", encoding="utf-8")
|
||||
|
||||
assets = tmp_path / "bundle"
|
||||
assets.mkdir()
|
||||
(assets / "index.html").write_text("<!doctype html>index", encoding="utf-8")
|
||||
monkeypatch.setattr("strix.viewer.server.bundle_dir", lambda: assets)
|
||||
|
||||
httpd, url, _ = serve(run_dir, open_browser=False)
|
||||
try:
|
||||
# A traversal target must never leak the file; it falls back to index.html.
|
||||
_, _, body = _get(f"{url}/..%2f..%2fsecret.txt")
|
||||
assert b"top secret" not in body
|
||||
finally:
|
||||
httpd.shutdown()
|
||||
httpd.server_close()
|
||||
@@ -0,0 +1,136 @@
|
||||
"""Tests for viewer auth state and the relay client mapping."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import stat
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import pytest
|
||||
|
||||
from strix.viewer import auth
|
||||
|
||||
|
||||
def _iso(delta: timedelta) -> str:
|
||||
return (datetime.now(UTC) + delta).isoformat()
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _tmp_auth(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
home = tmp_path / "home"
|
||||
monkeypatch.setattr(auth, "AUTH_PATH", home / ".strix" / "viewer-auth.json")
|
||||
return auth.AUTH_PATH
|
||||
|
||||
|
||||
def test_write_read_forget_roundtrip() -> None:
|
||||
assert auth.read_auth() is None
|
||||
assert auth.is_verified() is False
|
||||
|
||||
auth.write_auth(email="[email protected]", token="tok-123", verified_at=_iso(timedelta(days=30)))
|
||||
|
||||
record = auth.read_auth()
|
||||
assert record is not None
|
||||
assert record["email"] == "[email protected]"
|
||||
assert record["token"] == "tok-123"
|
||||
assert auth.is_verified() is True
|
||||
|
||||
auth.forget()
|
||||
assert auth.read_auth() is None
|
||||
assert auth.is_verified() is False
|
||||
# Forget is a no-op when the file is already gone.
|
||||
auth.forget()
|
||||
|
||||
|
||||
def test_is_verified_enforces_expiry() -> None:
|
||||
# An expired record still reads back, but no longer unlocks history.
|
||||
auth.write_auth(email="[email protected]", token="t", verified_at=_iso(timedelta(hours=-1)))
|
||||
assert auth.read_auth() is not None
|
||||
assert auth.is_verified() is False
|
||||
|
||||
# A future expiry unlocks it.
|
||||
auth.write_auth(email="[email protected]", token="t", verified_at=_iso(timedelta(hours=1)))
|
||||
assert auth.is_verified() is True
|
||||
|
||||
|
||||
def test_is_verified_when_expiry_absent_or_unparseable() -> None:
|
||||
# No/blank expiry: cannot enforce locally, so treat as valid.
|
||||
auth.write_auth(email="[email protected]", token="t", verified_at="")
|
||||
assert auth.is_verified() is True
|
||||
|
||||
# Garbage expiry is ignored rather than locking the user out.
|
||||
auth.write_auth(email="[email protected]", token="t", verified_at="not-a-date")
|
||||
assert auth.is_verified() is True
|
||||
|
||||
|
||||
def test_write_auth_is_0600() -> None:
|
||||
auth.write_auth(email="[email protected]", token="t", verified_at="")
|
||||
mode = stat.S_IMODE(auth.AUTH_PATH.stat().st_mode)
|
||||
assert mode == 0o600
|
||||
|
||||
|
||||
def test_read_auth_rejects_incomplete_record() -> None:
|
||||
auth.AUTH_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
auth.AUTH_PATH.write_text('{"email": "[email protected]"}', encoding="utf-8")
|
||||
assert auth.read_auth() is None
|
||||
assert auth.is_verified() is False
|
||||
|
||||
|
||||
def _stub_post(monkeypatch: pytest.MonkeyPatch, status: int, body: dict[str, Any]) -> None:
|
||||
def fake(path: str, payload: dict[str, Any], *, timeout: int) -> tuple[int, dict[str, Any]]:
|
||||
return status, body
|
||||
|
||||
monkeypatch.setattr(auth, "_post_json", fake)
|
||||
|
||||
|
||||
def test_otp_start_maps_errors(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_stub_post(monkeypatch, 200, {"ok": True})
|
||||
auth.otp_start("[email protected]") # no raise
|
||||
|
||||
_stub_post(monkeypatch, 429, {"error": "rate_limited"})
|
||||
with pytest.raises(auth.RelayError) as exc:
|
||||
auth.otp_start("[email protected]")
|
||||
assert exc.value.code == "rate_limited"
|
||||
|
||||
_stub_post(monkeypatch, 400, {})
|
||||
with pytest.raises(auth.RelayError) as exc:
|
||||
auth.otp_start("bad")
|
||||
assert exc.value.code == "invalid_email"
|
||||
|
||||
|
||||
def test_otp_verify_success_and_invalid(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_stub_post(monkeypatch, 200, {"token": "t", "email": "[email protected]", "expires_at": "later"})
|
||||
result = auth.otp_verify("[email protected]", "123456")
|
||||
assert result["token"] == "t"
|
||||
|
||||
_stub_post(monkeypatch, 403, {"error": "invalid_code"})
|
||||
with pytest.raises(auth.RelayError) as exc:
|
||||
auth.otp_verify("[email protected]", "000000")
|
||||
assert exc.value.code == "invalid_code"
|
||||
|
||||
|
||||
def test_report_send_never_includes_password(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
def fake(path: str, payload: dict[str, Any], *, timeout: int) -> tuple[int, dict[str, Any]]:
|
||||
captured["payload"] = payload
|
||||
return 200, {"ok": True}
|
||||
|
||||
monkeypatch.setattr(auth, "_post_json", fake)
|
||||
auth.report_send("tok", b"%PDF-fake", "strix-report-x.pdf", "x", "https://example.com")
|
||||
|
||||
payload = captured["payload"]
|
||||
assert set(payload) == {"token", "pdf_base64", "filename", "run_name", "target"}
|
||||
# The password is generated locally and must never appear in the relay body.
|
||||
assert "password" not in payload
|
||||
assert all("password" not in str(k).lower() for k in payload)
|
||||
|
||||
|
||||
def test_report_send_reverify_on_401(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_stub_post(monkeypatch, 401, {"error": "invalid_token"})
|
||||
with pytest.raises(auth.RelayError) as exc:
|
||||
auth.report_send("tok", b"x", "f.pdf", "r", "t")
|
||||
assert exc.value.code == "reverify"
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Tests for the /api/runs gating and the ?run= resolver (pure functions)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from strix.viewer.server import build_runs_payload, resolve_run_dir
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _make_run(base: Path, name: str, *, severity: str = "high") -> Path:
|
||||
run_dir = base / "strix_runs" / name
|
||||
run_dir.mkdir(parents=True)
|
||||
record = {
|
||||
"run_name": name,
|
||||
"targets_info": [{"original": f"https://{name}.example.com"}],
|
||||
"scan_mode": "deep",
|
||||
"status": "completed",
|
||||
"start_time": "2026-01-01T00:00:00Z",
|
||||
"end_time": "2026-01-01T00:10:00Z",
|
||||
}
|
||||
(run_dir / "run.json").write_text(json.dumps(record), encoding="utf-8")
|
||||
(run_dir / "vulnerabilities.json").write_text(
|
||||
json.dumps([{"title": "v", "severity": severity}]), encoding="utf-8"
|
||||
)
|
||||
return run_dir
|
||||
|
||||
|
||||
def test_runs_payload_locked_when_unverified(tmp_path: Path) -> None:
|
||||
base = tmp_path / "strix_runs"
|
||||
_make_run(tmp_path, "alpha")
|
||||
_make_run(tmp_path, "beta")
|
||||
|
||||
payload = build_runs_payload(base, verified=False)
|
||||
assert payload["locked"] is True
|
||||
assert payload["count"] == 2
|
||||
assert payload["runs"] == []
|
||||
|
||||
|
||||
def test_runs_payload_lists_when_verified(tmp_path: Path) -> None:
|
||||
base = tmp_path / "strix_runs"
|
||||
_make_run(tmp_path, "alpha", severity="critical")
|
||||
_make_run(tmp_path, "beta", severity="info")
|
||||
|
||||
payload = build_runs_payload(base, verified=True)
|
||||
assert payload["locked"] is False
|
||||
assert payload["count"] == 2
|
||||
assert len(payload["runs"]) == 2
|
||||
entry = next(r for r in payload["runs"] if r["name"] == "alpha")
|
||||
assert entry["target"] == "https://alpha.example.com"
|
||||
assert entry["severity_counts"]["critical"] == 1
|
||||
# "info" folds into low, matching the SPA's bucketing.
|
||||
beta = next(r for r in payload["runs"] if r["name"] == "beta")
|
||||
assert beta["severity_counts"]["low"] == 1
|
||||
|
||||
|
||||
def test_runs_payload_empty_base(tmp_path: Path) -> None:
|
||||
payload = build_runs_payload(tmp_path / "strix_runs", verified=True)
|
||||
assert payload == {"locked": False, "count": 0, "runs": []}
|
||||
|
||||
|
||||
def test_resolve_run_dir_defaults_when_absent(tmp_path: Path) -> None:
|
||||
base = tmp_path / "strix_runs"
|
||||
default = _make_run(tmp_path, "alpha")
|
||||
assert resolve_run_dir(base, None, default) == default
|
||||
assert resolve_run_dir(base, "", default) == default
|
||||
|
||||
|
||||
def test_resolve_run_dir_valid_named_run(tmp_path: Path) -> None:
|
||||
base = tmp_path / "strix_runs"
|
||||
default = _make_run(tmp_path, "alpha")
|
||||
other = _make_run(tmp_path, "beta")
|
||||
assert resolve_run_dir(base, "beta", default) == other
|
||||
|
||||
|
||||
def test_resolve_run_dir_rejects_unknown_and_traversal(tmp_path: Path) -> None:
|
||||
base = tmp_path / "strix_runs"
|
||||
default = _make_run(tmp_path, "alpha")
|
||||
secret = tmp_path / "secret"
|
||||
secret.mkdir()
|
||||
(secret / "run.json").write_text("{}", encoding="utf-8")
|
||||
|
||||
assert resolve_run_dir(base, "nope", default) is None
|
||||
assert resolve_run_dir(base, "../secret", default) is None
|
||||
assert resolve_run_dir(base, "../../etc", default) is None
|
||||
Reference in new issue
Block a user