mirror of
https://github.com/usestrix/strix.git
synced 2026-08-16 01:16:40 +02:00
Local viewer: prominent scan switcher + rename to "pentest" terminology (#848)
This commit is contained in:
@@ -180,6 +180,14 @@ def viewer_email_event(step: str, purpose: str | None = None) -> None:
|
||||
)
|
||||
|
||||
|
||||
def viewer_feedback_submitted() -> None:
|
||||
_send("viewer_feedback_submitted", {**base_props()})
|
||||
|
||||
|
||||
def viewer_agent_steered() -> None:
|
||||
_send("viewer_agent_steered", {**base_props()})
|
||||
|
||||
|
||||
def error(error_type: str) -> None:
|
||||
props = {**base_props(), "error_type": error_type}
|
||||
_send("error", props)
|
||||
|
||||
@@ -355,7 +355,7 @@ export default function App() {
|
||||
{/* 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
|
||||
Pentest Overview
|
||||
</TabButton>
|
||||
<TabButton active={view === "issues"} onClick={() => userSetView("issues")}>
|
||||
Issues{run.vulnerabilities.length > 0 ? ` (${run.vulnerabilities.length})` : ""}
|
||||
@@ -425,33 +425,37 @@ function RunSwitcher({
|
||||
<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" }}
|
||||
aria-label="Switch pentest"
|
||||
className="flex items-center gap-2 rounded-lg border border-[#3a3a3a] bg-[rgba(255,255,255,0.05)] px-3 py-2 text-sm text-white transition-colors hover:border-[#555] hover:bg-[rgba(255,255,255,0.09)]"
|
||||
>
|
||||
<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" />
|
||||
<History className="h-4 w-4 flex-shrink-0 text-[#888]" aria-hidden="true" />
|
||||
<span className="flex-shrink-0 text-[#888]">Pentest</span>
|
||||
<span className="max-w-[260px] truncate font-medium">{current}</span>
|
||||
<ChevronDown className="h-4 w-4 flex-shrink-0 text-[#aaa]" 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" }}
|
||||
className="absolute right-0 z-50 mt-2 max-h-96 w-96 overflow-y-auto rounded-xl py-1.5 shadow-2xl"
|
||||
style={{ border: "1px solid #3a3a3a", background: "#0a0a0a" }}
|
||||
>
|
||||
<div className="border-b border-[#222] px-3 py-2 text-[11px] font-semibold uppercase tracking-wide text-[#666]">
|
||||
Switch pentest
|
||||
</div>
|
||||
{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]"
|
||||
className={`flex w-full items-center gap-2 px-3 py-2.5 text-left text-sm transition-colors hover:bg-[rgba(255,255,255,0.06)] ${
|
||||
active ? "bg-[rgba(255,255,255,0.04)] 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 className="block truncate font-medium">{runTitle(r.target, r.name)}</span>
|
||||
{r.target && <span className="block truncate font-mono text-xs text-[#666]">{r.target}</span>}
|
||||
</span>
|
||||
{active && <span className="h-1.5 w-1.5 flex-shrink-0 rounded-full bg-emerald-400" />}
|
||||
{active && <span className="h-2 w-2 flex-shrink-0 rounded-full bg-emerald-400" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
@@ -495,7 +499,7 @@ function SummaryHeader({ summary }: { summary: ParsedRunSummary }) {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-white">
|
||||
{runTitle(summary.targets[0] ?? null, summary.runName ?? summary.runId ?? "Scan results")}
|
||||
{runTitle(summary.targets[0] ?? null, summary.runName ?? summary.runId ?? "Pentest 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 && (
|
||||
@@ -534,7 +538,7 @@ function FindingsList({
|
||||
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…"}
|
||||
{finished ? "No findings in this run." : "No findings yet. The pentest is still running…"}
|
||||
</div>
|
||||
{finished && (
|
||||
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5">
|
||||
@@ -760,12 +764,12 @@ function AgentsTab({ run, canSteer }: { run: LoadedRun; canSteer: boolean }) {
|
||||
|
||||
{/* 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>
|
||||
<p className="text-sm font-semibold text-white">Run this pentest with more depth</p>
|
||||
<p className="mt-0.5 text-xs text-[#666]">Re-run this pentest 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."
|
||||
label="Re-run in Strix Pro with more depth"
|
||||
desc="Run this pentest on managed infra with more depth."
|
||||
slug="live_scan"
|
||||
surface="agents"
|
||||
icon={Rocket}
|
||||
|
||||
@@ -239,7 +239,7 @@ export default function EmailReportView({
|
||||
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"}
|
||||
Export report
|
||||
</button>
|
||||
{verified && auth?.email && (
|
||||
<p className="text-center text-xs text-[#666]">Sending to {auth.email}</p>
|
||||
|
||||
@@ -152,7 +152,7 @@ export function RunDetails({
|
||||
<span className="text-[#666]">None</span>
|
||||
)}
|
||||
</Field>
|
||||
{scanMode && <Field label="Scan mode">{scanMode}</Field>}
|
||||
{scanMode && <Field label="Pentest mode">{scanMode}</Field>}
|
||||
<Field label="Scope">{scope}</Field>
|
||||
<Field label="Mode">{nonInteractive ? "Non-interactive" : "Interactive"}</Field>
|
||||
{localSources.length > 0 && (
|
||||
|
||||
@@ -85,6 +85,13 @@ export default function Sidebar({
|
||||
const [upgradeFeature, setUpgradeFeature] = useState<string | null>(null);
|
||||
const userMenuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Open the upgrade dialog for a platform feature, recording which feature
|
||||
// drove the open (the dialog's own CTAs track the deeper conversion).
|
||||
const openUpgrade = (slug: string, description: string) => {
|
||||
trackCta(slug, "sidebar");
|
||||
setUpgradeFeature(description);
|
||||
};
|
||||
|
||||
const persistWidth = useCallback((w: number) => {
|
||||
setWidth(w);
|
||||
try {
|
||||
@@ -219,7 +226,7 @@ export default function Sidebar({
|
||||
<div className="relative flex flex-col gap-px px-2">
|
||||
<NavItem
|
||||
icon={<ProjectsIcon />}
|
||||
label="Overview"
|
||||
label="Pentest Overview"
|
||||
active={view === "overview"}
|
||||
onClick={() => onSelectView("overview")}
|
||||
/>
|
||||
@@ -268,7 +275,8 @@ export default function Sidebar({
|
||||
label="PR Security Reviews"
|
||||
active={false}
|
||||
onClick={() =>
|
||||
setUpgradeFeature(
|
||||
openUpgrade(
|
||||
"pr_reviews",
|
||||
"Strix reviews every pull request and flags exploitable changes before they merge."
|
||||
)
|
||||
}
|
||||
@@ -278,7 +286,8 @@ export default function Sidebar({
|
||||
label="Integrations"
|
||||
active={false}
|
||||
onClick={() =>
|
||||
setUpgradeFeature(
|
||||
openUpgrade(
|
||||
"integrations",
|
||||
"Sync findings to Jira, Linear, and Slack so fixes happen where your team already works."
|
||||
)
|
||||
}
|
||||
@@ -288,7 +297,8 @@ export default function Sidebar({
|
||||
label="Members"
|
||||
active={false}
|
||||
onClick={() =>
|
||||
setUpgradeFeature(
|
||||
openUpgrade(
|
||||
"members",
|
||||
"Invite your team, set roles, and share findings and run history across your org."
|
||||
)
|
||||
}
|
||||
|
||||
@@ -248,7 +248,7 @@ export function ScanPromptComposer({
|
||||
void handleSend();
|
||||
}
|
||||
}}
|
||||
placeholder="Send a live prompt to the running scan…"
|
||||
placeholder="Send a live prompt to the running pentest…"
|
||||
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"
|
||||
|
||||
@@ -57,5 +57,5 @@ export function parseTarget(target: string): ParsedTarget {
|
||||
*/
|
||||
export function runTitle(target: string | null, fallback: string): string {
|
||||
if (target) return parseTarget(target).display.replace(/\/$/, "");
|
||||
return fallback || "Untitled scan";
|
||||
return fallback || "Untitled pentest";
|
||||
}
|
||||
|
||||
@@ -220,6 +220,10 @@ def _make_handler(state: _ViewerState) -> type[BaseHTTPRequestHandler]:
|
||||
|
||||
purpose = body.get("purpose")
|
||||
posthog.viewer_email_event(str(event), purpose=str(purpose) if purpose else None)
|
||||
elif event == "agent_steered":
|
||||
from strix.telemetry import posthog
|
||||
|
||||
posthog.viewer_agent_steered()
|
||||
self.send_response(HTTPStatus.NO_CONTENT)
|
||||
self.end_headers()
|
||||
|
||||
@@ -405,6 +409,11 @@ def _make_handler(state: _ViewerState) -> type[BaseHTTPRequestHandler]:
|
||||
except auth.RelayError as exc:
|
||||
self._send_relay_error(exc)
|
||||
return
|
||||
# Server-authoritative: fire only after a successful relay (respects
|
||||
# the telemetry opt-out; no message/email content is sent).
|
||||
from strix.telemetry import posthog
|
||||
|
||||
posthog.viewer_feedback_submitted()
|
||||
self._send_json(HTTPStatus.OK, {"ok": True})
|
||||
|
||||
# Cap on a steering message so a runaway client cannot flood the agent.
|
||||
|
||||
+22
-22
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -6,8 +6,8 @@
|
||||
<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-_2j_QfOq.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-BtBksasm.css">
|
||||
<script type="module" crossorigin src="./assets/index-BNKUksp9.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-BdiSGmzb.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
+58
-6
@@ -191,6 +191,62 @@ def test_server_event_endpoint_forwards_email_funnel(
|
||||
httpd.server_close()
|
||||
|
||||
|
||||
def test_server_event_endpoint_forwards_agent_steered(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
run_dir = _make_run(tmp_path, "steerevt", status="running", end_time=None)
|
||||
_bundle(tmp_path, monkeypatch)
|
||||
|
||||
seen: list[bool] = []
|
||||
monkeypatch.setattr("strix.telemetry.posthog.viewer_agent_steered", lambda: seen.append(True))
|
||||
|
||||
httpd, url, _ = serve(run_dir, open_browser=False)
|
||||
try:
|
||||
req = urllib.request.Request( # noqa: S310 - localhost test server
|
||||
f"{url}/api/event",
|
||||
data=json.dumps({"event": "agent_steered"}).encode(),
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
with urllib.request.urlopen(req) as resp: # noqa: S310
|
||||
assert resp.status == 204
|
||||
assert seen == [True]
|
||||
finally:
|
||||
httpd.shutdown()
|
||||
httpd.server_close()
|
||||
|
||||
|
||||
def test_feedback_records_telemetry_on_success(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
run_dir = _make_run(tmp_path, "fbtel", status="running", end_time=None)
|
||||
_bundle(tmp_path, monkeypatch)
|
||||
|
||||
sent: list[bool] = []
|
||||
monkeypatch.setattr("strix.viewer.auth.feedback_submit", lambda *_a: None)
|
||||
monkeypatch.setattr(
|
||||
"strix.telemetry.posthog.viewer_feedback_submitted", lambda: sent.append(True)
|
||||
)
|
||||
|
||||
httpd, url, token = serve(run_dir, open_browser=False)
|
||||
try:
|
||||
cookie = _session_cookie(url, token)
|
||||
# A successful, session-holding submission relays and records telemetry.
|
||||
status, _ = _post(
|
||||
url, "/api/feedback", {"email": "a@b.com", "message": "hi"}, cookie=cookie
|
||||
)
|
||||
assert status == 200
|
||||
assert sent == [True]
|
||||
|
||||
# A cookie-less caller is rejected and records nothing.
|
||||
sent.clear()
|
||||
status, _ = _post(url, "/api/feedback", {"email": "a@b.com", "message": "hi"})
|
||||
assert status == 403
|
||||
assert sent == []
|
||||
finally:
|
||||
httpd.shutdown()
|
||||
httpd.server_close()
|
||||
|
||||
|
||||
def _post(
|
||||
url: str, path: str, payload: Mapping[str, object], *, cookie: str | None = None
|
||||
) -> tuple[int, bytes]:
|
||||
@@ -395,9 +451,7 @@ def test_report_send_requires_session_cookie(
|
||||
httpd.server_close()
|
||||
|
||||
|
||||
def test_report_send_rejects_live_run(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
def test_report_send_rejects_live_run(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# A running scan would only produce a partial report, so the endpoint must
|
||||
# fail closed even for a verified, session-holding caller.
|
||||
run_dir = _make_run(tmp_path, "live", status="running", end_time=None)
|
||||
@@ -406,9 +460,7 @@ def test_report_send_rejects_live_run(
|
||||
|
||||
httpd, url, token = serve(run_dir, open_browser=False)
|
||||
try:
|
||||
status, _ = _post(
|
||||
url, "/api/report/send", {}, cookie=_session_cookie(url, token)
|
||||
)
|
||||
status, _ = _post(url, "/api/report/send", {}, cookie=_session_cookie(url, token))
|
||||
assert status == 409
|
||||
finally:
|
||||
httpd.shutdown()
|
||||
|
||||
Reference in New Issue
Block a user