diff --git a/packages/core/src/scouts/scoutFindings.test.ts b/packages/core/src/scouts/scoutFindings.test.ts new file mode 100644 index 0000000000..837e7117f4 --- /dev/null +++ b/packages/core/src/scouts/scoutFindings.test.ts @@ -0,0 +1,311 @@ +import type { + LinkedSignalReport, + ScoutEmission, + ScoutRun, +} from "@posthog/api-client/posthog-client"; +import { + availableScouts, + buildFindingRows, + emittedRunsKey, + FINDINGS_SCOUT_FILTER_ALL, + FINDINGS_SEVERITY_FILTER_ALL, + type FindingsSortKey, + filterAndSortFindings, + latestEmittedAt, + mostRecentEmittedRuns, + reportsBySourceId, + type ScoutFindingRow, +} from "@posthog/core/scouts/scoutFindings"; +import { describe, expect, it } from "vitest"; + +function run(partial: Partial & Pick): ScoutRun { + return { + skill_name: "signals-scout-error-tracking", + skill_version: 1, + status: "completed", + started_at: "2026-06-29T10:00:00Z", + completed_at: "2026-06-29T10:05:00Z", + task_id: null, + task_run_id: null, + task_url: null, + summary: "", + emitted_count: 1, + emitted_finding_ids: [], + ...partial, + }; +} + +function emission( + partial: Partial & Pick, +): ScoutEmission { + return { + finding_id: `finding-${partial.id}`, + description: "Something happened", + weight: 1, + confidence: 0.5, + severity: "P2", + source_id: `run:${partial.run_id}:finding:${partial.id}`, + emitted_at: "2026-06-29T10:01:00Z", + ...partial, + }; +} + +const report: LinkedSignalReport = { + id: "report-1", + title: "An incident", + status: "ready", +}; + +describe("mostRecentEmittedRuns", () => { + it("keeps only runs that emitted, newest-first by started_at", () => { + const runs = [ + run({ + run_id: "a", + started_at: "2026-06-29T08:00:00Z", + emitted_count: 2, + }), + run({ + run_id: "b", + started_at: "2026-06-29T09:00:00Z", + emitted_count: 0, + }), + run({ + run_id: "c", + started_at: "2026-06-29T10:00:00Z", + emitted_count: 1, + }), + ]; + expect(mostRecentEmittedRuns(runs).map((r) => r.run_id)).toEqual([ + "c", + "a", + ]); + }); + + it("treats null emitted_count as quiet", () => { + const runs = [run({ run_id: "a", emitted_count: null })]; + expect(mostRecentEmittedRuns(runs)).toEqual([]); + }); + + it("caps the run set", () => { + const runs = Array.from({ length: 5 }, (_, i) => + run({ run_id: `r${i}`, started_at: `2026-06-29T1${i}:00:00Z` }), + ); + expect(mostRecentEmittedRuns(runs, 2)).toHaveLength(2); + }); +}); + +describe("emittedRunsKey", () => { + it("is order-independent and includes emitted_count", () => { + const a = [ + run({ run_id: "x", emitted_count: 1 }), + run({ run_id: "y", emitted_count: 2 }), + ]; + const b = [ + run({ run_id: "y", emitted_count: 2 }), + run({ run_id: "x", emitted_count: 1 }), + ]; + expect(emittedRunsKey(a)).toBe(emittedRunsKey(b)); + }); + + it("changes when a run emits more", () => { + const before = emittedRunsKey([run({ run_id: "x", emitted_count: 1 })]); + const after = emittedRunsKey([run({ run_id: "x", emitted_count: 2 })]); + expect(before).not.toBe(after); + }); +}); + +describe("buildFindingRows", () => { + it("joins emissions to their run and linked report", () => { + const runs = [run({ run_id: "a" })]; + const emissions = [emission({ id: "1", run_id: "a", source_id: "src-1" })]; + const reportBySourceId = reportsBySourceId([ + { source_id: "src-1", report }, + ]); + const rows = buildFindingRows(emissions, runs, reportBySourceId); + expect(rows).toHaveLength(1); + expect(rows[0]?.run.run_id).toBe("a"); + expect(rows[0]?.report).toBe(report); + }); + + it("drops emissions whose run is absent", () => { + const emissions = [emission({ id: "1", run_id: "gone" })]; + expect( + buildFindingRows(emissions, [run({ run_id: "a" })], new Map()), + ).toEqual([]); + }); + + it("leaves report null when unlinked", () => { + const runs = [run({ run_id: "a" })]; + const emissions = [emission({ id: "1", run_id: "a", source_id: "src-1" })]; + expect(buildFindingRows(emissions, runs, new Map())[0]?.report).toBeNull(); + }); +}); + +describe("reportsBySourceId", () => { + it("indexes only links with a report", () => { + const map = reportsBySourceId([ + { source_id: "src-1", report }, + { source_id: "src-2", report: null }, + ]); + expect(map.get("src-1")).toBe(report); + expect(map.has("src-2")).toBe(false); + }); +}); + +function rowsFrom( + specs: { + id: string; + skill: string; + severity: string | null; + confidence: number; + emittedAt: string; + description?: string; + }[], +): ScoutFindingRow[] { + return specs.map((spec) => ({ + emission: emission({ + id: spec.id, + run_id: spec.id, + severity: spec.severity, + confidence: spec.confidence, + emitted_at: spec.emittedAt, + description: spec.description ?? "x", + }), + run: run({ run_id: spec.id, skill_name: spec.skill }), + report: null, + })); +} + +describe("availableScouts", () => { + it("counts distinct scouts and labels them, sorted by label", () => { + const rows = rowsFrom([ + { + id: "1", + skill: "signals-scout-web-analytics", + severity: "P1", + confidence: 0.5, + emittedAt: "2026-06-29T10:00:00Z", + }, + { + id: "2", + skill: "signals-scout-error-tracking", + severity: "P1", + confidence: 0.5, + emittedAt: "2026-06-29T10:00:00Z", + }, + { + id: "3", + skill: "signals-scout-error-tracking", + severity: "P1", + confidence: 0.5, + emittedAt: "2026-06-29T10:00:00Z", + }, + ]); + const scouts = availableScouts(rows); + expect(scouts.map((s) => s.skillName)).toEqual([ + "signals-scout-error-tracking", + "signals-scout-web-analytics", + ]); + expect(scouts[0]?.count).toBe(2); + expect(scouts[0]?.label).toBe("Error tracking"); + }); +}); + +describe("filterAndSortFindings", () => { + const base = { + searchText: "", + scoutFilter: FINDINGS_SCOUT_FILTER_ALL, + severityFilter: FINDINGS_SEVERITY_FILTER_ALL, + sortKey: "newest" as const, + }; + const rows = rowsFrom([ + { + id: "a", + skill: "signals-scout-error-tracking", + severity: "P0", + confidence: 0.3, + emittedAt: "2026-06-29T08:00:00Z", + description: "database outage", + }, + { + id: "b", + skill: "signals-scout-web-analytics", + severity: "P3", + confidence: 0.9, + emittedAt: "2026-06-29T10:00:00Z", + description: "traffic dip", + }, + { + id: "c", + skill: "signals-scout-error-tracking", + severity: "P2", + confidence: 0.6, + emittedAt: "2026-06-29T09:00:00Z", + description: "slow query", + }, + ]); + + it.each<[FindingsSortKey, string[]]>([ + ["newest", ["b", "c", "a"]], + ["oldest", ["a", "c", "b"]], + ["severity", ["a", "c", "b"]], + ["confidence", ["b", "c", "a"]], + ])("sorts by %s", (sortKey, expected) => { + expect( + filterAndSortFindings(rows, { ...base, sortKey }).map( + (r) => r.emission.id, + ), + ).toEqual(expected); + }); + + it.each<[string, Partial, string[]]>([ + ["scout", { scoutFilter: "signals-scout-web-analytics" }, ["b"]], + ["severity", { severityFilter: "P0" }, ["a"]], + ])("filters by %s", (_label, override, expected) => { + expect( + filterAndSortFindings(rows, { ...base, ...override }).map( + (r) => r.emission.id, + ), + ).toEqual(expected); + }); + + it("searches over description and prettified scout name", () => { + expect( + filterAndSortFindings(rows, { ...base, searchText: "outage" }).map( + (r) => r.emission.id, + ), + ).toEqual(["a"]); + // "Error tracking" is the prettified skill name for two rows. + expect( + filterAndSortFindings(rows, { ...base, searchText: "error tracking" }) + .map((r) => r.emission.id) + .sort(), + ).toEqual(["a", "c"]); + }); +}); + +describe("latestEmittedAt", () => { + it("returns the max emitted_at", () => { + const rows = rowsFrom([ + { + id: "a", + skill: "s", + severity: "P1", + confidence: 0.5, + emittedAt: "2026-06-29T08:00:00Z", + }, + { + id: "b", + skill: "s", + severity: "P1", + confidence: 0.5, + emittedAt: "2026-06-29T10:00:00Z", + }, + ]); + expect(latestEmittedAt(rows)).toBe("2026-06-29T10:00:00Z"); + }); + + it("returns null for no rows", () => { + expect(latestEmittedAt([])).toBeNull(); + }); +}); diff --git a/packages/core/src/scouts/scoutFindings.ts b/packages/core/src/scouts/scoutFindings.ts new file mode 100644 index 0000000000..2cc93b2abd --- /dev/null +++ b/packages/core/src/scouts/scoutFindings.ts @@ -0,0 +1,217 @@ +import type { + LinkedSignalReport, + ScoutEmission, + ScoutRun, +} from "@posthog/api-client/posthog-client"; +import { prettifyScoutSkillName } from "@posthog/core/scouts/scoutPresentation"; + +/** + * Cross-fleet findings model — the pure counterpart of the per-scout + * {@link ScoutSignalsSection} aggregation, shared by the findings page. Mirrors + * the PostHog Cloud `findingsLogic` selectors so the two surfaces stay in + * parity: flatten every recent emission into one row joined to its run (for the + * emitting scout + task-run link) and the inbox report it grouped into, then + * search / filter / sort over that flat list. + */ + +/** A single finding paired with its run and the inbox report it fed into. */ +export interface ScoutFindingRow { + emission: ScoutEmission; + run: ScoutRun; + /** The inbox report this finding's signal grouped into, or null when unlinked. */ + report: LinkedSignalReport | null; +} + +export type FindingsSortKey = "newest" | "oldest" | "severity" | "confidence"; + +export const FINDINGS_SCOUT_FILTER_ALL = "all"; +export const FINDINGS_SEVERITY_FILTER_ALL = "all"; + +/** Severities the fleet emits, most severe first — drives the severity sort + filter options. */ +export const FINDINGS_SEVERITIES = ["P0", "P1", "P2", "P3", "P4"] as const; + +/** + * Cap on the emitted runs whose findings the page fans out to fetch. A scout is + * bounded to ~48 runs per 72h window by its 30-minute minimum cadence, but a + * larger fleet multiplies that, so capping the run set caps the per-run + * emissions-query fan-out the page mounts. + */ +export const FINDINGS_MAX_EMITTED_RUNS = 40; + +/** Lowest number = most severe, so the severity sort is a plain ascending compare. Unknown sinks last. */ +const SEVERITY_RANK: Record = { + P0: 0, + P1: 1, + P2: 2, + P3: 3, + P4: 4, +}; +function severityRank(severity: string | null): number { + return severity == null ? 99 : (SEVERITY_RANK[severity] ?? 98); +} + +/** Newest-first by the run's start time; runs without one sink last. */ +function byRunStartedDesc(a: ScoutRun, b: ScoutRun): number { + return (b.started_at ?? "").localeCompare(a.started_at ?? ""); +} + +/** + * The emitted runs whose findings the page fetches: those that actually emitted, + * newest-first, capped fleet-wide. Mirrors the Cloud `mostRecentEmittedRuns` + * util so the page and any callout summary count the exact same run set. + */ +export function mostRecentEmittedRuns( + runs: ScoutRun[], + cap: number = FINDINGS_MAX_EMITTED_RUNS, +): ScoutRun[] { + return runs + .filter((run) => (run.emitted_count ?? 0) > 0) + .sort(byRunStartedDesc) + .slice(0, cap); +} + +/** + * Stable key over the emitted-run set — lets the page refetch only when the set + * changes, not on every poll. Includes `emitted_count` so an in-progress run + * that emits more findings retriggers. + */ +export function emittedRunsKey(runs: ScoutRun[]): string { + return runs + .map((run) => `${run.run_id}:${run.emitted_count ?? 0}`) + .sort() + .join(","); +} + +/** + * Join each emission back to its run and the report it grouped into. Emissions + * whose run is absent from the set (e.g. evicted past the cap) are dropped. + */ +export function buildFindingRows( + emissions: ScoutEmission[], + runs: ScoutRun[], + reportBySourceId: Map, +): ScoutFindingRow[] { + const runsById = new Map(runs.map((run) => [run.run_id, run])); + return emissions + .map((emission): ScoutFindingRow | null => { + const run = runsById.get(emission.run_id); + return run + ? { + emission, + run, + report: reportBySourceId.get(emission.source_id) ?? null, + } + : null; + }) + .filter((row): row is ScoutFindingRow => row !== null); +} + +/** A linked report indexed by its finding's `source_id`, for {@link buildFindingRows}. */ +export function reportsBySourceId( + links: { source_id: string; report: LinkedSignalReport | null }[], +): Map { + const map = new Map(); + for (const link of links) { + if (link.report) map.set(link.source_id, link.report); + } + return map; +} + +export interface ScoutAvailableScout { + skillName: string; + label: string; + count: number; +} + +/** Distinct scouts present in the rows, with a per-scout count, for the scout filter. */ +export function availableScouts( + rows: ScoutFindingRow[], +): ScoutAvailableScout[] { + const counts = new Map(); + for (const row of rows) { + counts.set(row.run.skill_name, (counts.get(row.run.skill_name) ?? 0) + 1); + } + return [...counts.entries()] + .map(([skillName, count]) => ({ + skillName, + label: prettifyScoutSkillName(skillName), + count, + })) + .sort((a, b) => a.label.localeCompare(b.label)); +} + +export interface FindingsFilter { + searchText: string; + /** A `skill_name`, or {@link FINDINGS_SCOUT_FILTER_ALL}. */ + scoutFilter: string; + /** A severity, or {@link FINDINGS_SEVERITY_FILTER_ALL}. */ + severityFilter: string; + sortKey: FindingsSortKey; +} + +function byEmittedDesc(a: ScoutFindingRow, b: ScoutFindingRow): number { + return (b.emission.emitted_at ?? "").localeCompare( + a.emission.emitted_at ?? "", + ); +} + +/** + * Visible set: search (over finding text + prettified scout name) + scout + + * severity, then sort. Pure; the page wires it to its filter state. + */ +export function filterAndSortFindings( + rows: ScoutFindingRow[], + { searchText, scoutFilter, severityFilter, sortKey }: FindingsFilter, +): ScoutFindingRow[] { + const needle = searchText.trim().toLowerCase(); + const filtered = rows.filter((row) => { + if ( + scoutFilter !== FINDINGS_SCOUT_FILTER_ALL && + row.run.skill_name !== scoutFilter + ) { + return false; + } + if ( + severityFilter !== FINDINGS_SEVERITY_FILTER_ALL && + row.emission.severity !== severityFilter + ) { + return false; + } + if (needle) { + const haystack = + `${row.emission.description ?? ""} ${prettifyScoutSkillName(row.run.skill_name)}`.toLowerCase(); + if (!haystack.includes(needle)) { + return false; + } + } + return true; + }); + + return filtered.sort((a, b) => { + if (sortKey === "oldest") { + return -byEmittedDesc(a, b); + } + if (sortKey === "severity") { + const diff = + severityRank(a.emission.severity) - severityRank(b.emission.severity); + return diff !== 0 ? diff : byEmittedDesc(a, b); + } + if (sortKey === "confidence") { + const diff = (b.emission.confidence ?? 0) - (a.emission.confidence ?? 0); + return diff !== 0 ? diff : byEmittedDesc(a, b); + } + return byEmittedDesc(a, b); + }); +} + +/** Most recent `emitted_at` across the rows, for the page's "latest" hint. */ +export function latestEmittedAt(rows: ScoutFindingRow[]): string | null { + let latest: string | null = null; + for (const row of rows) { + const at = row.emission.emitted_at; + if (at && (!latest || at > latest)) { + latest = at; + } + } + return latest; +} diff --git a/packages/ui/src/features/scouts/components/FleetFindingsCallout.tsx b/packages/ui/src/features/scouts/components/FleetFindingsCallout.tsx new file mode 100644 index 0000000000..e988ebcdbf --- /dev/null +++ b/packages/ui/src/features/scouts/components/FleetFindingsCallout.tsx @@ -0,0 +1,70 @@ +import { ArrowRightIcon, CompassIcon } from "@phosphor-icons/react"; +import { RelativeTimestamp } from "@posthog/ui/primitives/RelativeTimestamp"; +import { Flex, Text } from "@radix-ui/themes"; +import { Link } from "@tanstack/react-router"; +import { useMemo } from "react"; +import { useScoutRuns } from "../hooks/useScoutRuns"; + +/** + * Findings stat card for the scout fleet section. Surfaces that the troop has + * emitted findings recently — count + recency — and links into the full + * cross-fleet findings browse/filter surface. Reads the cheap `emitted_count` + * sum off the already-polled runs window (no per-run emissions fan-out, that's + * the page's job). Renders nothing until there's at least one finding, so a + * fresh project isn't nudged toward an empty page. + */ +export function FleetFindingsCallout() { + const { data: runsWindow } = useScoutRuns(); + + // Every emitted run in the window — uncapped. The page caps its per-run + // emissions fan-out, but this card only sums the cheap `emitted_count` + // metadata, so it must count the whole fleet or larger fleets undercount. + const emittedRuns = useMemo( + () => + (runsWindow?.runs ?? []).filter((run) => (run.emitted_count ?? 0) > 0), + [runsWindow], + ); + + const totalFindings = emittedRuns.reduce( + (sum, run) => sum + (run.emitted_count ?? 0), + 0, + ); + if (totalFindings === 0) { + return null; + } + + // The newest emitted run dates the most recent finding. + const lastEmittedAt = emittedRuns.reduce((latest, run) => { + const at = run.completed_at ?? run.started_at; + return at && (!latest || at > latest) ? at : latest; + }, null); + + return ( + + + + + Scout findings + + + {totalFindings} finding{totalFindings === 1 ? "" : "s"} your scouts + emitted recently, across the fleet + {lastEmittedAt ? ( + <> + {" · latest "} + + + ) : null} + + + + + + ); +} diff --git a/packages/ui/src/features/scouts/components/ScoutEmissionCard.tsx b/packages/ui/src/features/scouts/components/ScoutEmissionCard.tsx index b2197a74af..73c1431247 100644 --- a/packages/ui/src/features/scouts/components/ScoutEmissionCard.tsx +++ b/packages/ui/src/features/scouts/components/ScoutEmissionCard.tsx @@ -3,6 +3,7 @@ import type { LinkedSignalReport, ScoutEmission, } from "@posthog/api-client/posthog-client"; +import { prettifyScoutSkillName } from "@posthog/core/scouts/scoutPresentation"; import { ANALYTICS_EVENTS } from "@posthog/shared"; import { MarkdownRenderer } from "@posthog/ui/features/editor/components/MarkdownRenderer"; import { RelativeTimestamp } from "@posthog/ui/primitives/RelativeTimestamp"; @@ -20,6 +21,7 @@ export function ScoutEmissionCard({ linkedReport, defaultExpanded = false, highlighted = false, + showScout = false, }: { emission: ScoutEmission; /** The emitting scout, attached to analytics events when known. */ @@ -37,6 +39,11 @@ export function ScoutEmissionCard({ defaultExpanded?: boolean; /** True when a shared finding link targets this card – scrolls it into view. */ highlighted?: boolean; + /** + * Cross-fleet surfaces (the findings page) mix scouts, so the header titles + * the card with the emitting scout instead of the generic "Finding". + */ + showScout?: boolean; }) { const [expanded, setExpanded] = useState(defaultExpanded); const cardRef = useRef(null); @@ -76,7 +83,11 @@ export function ScoutEmissionCard({ className={`shrink-0 text-gray-9 transition-transform duration-150 ${expanded ? "rotate-90" : ""}`} /> - Finding + + {showScout && skillName + ? prettifyScoutSkillName(skillName) + : "Finding"} + confidence {Math.round(emission.confidence * 100)}% diff --git a/packages/ui/src/features/scouts/components/ScoutFindingsView.tsx b/packages/ui/src/features/scouts/components/ScoutFindingsView.tsx new file mode 100644 index 0000000000..442c790a3b --- /dev/null +++ b/packages/ui/src/features/scouts/components/ScoutFindingsView.tsx @@ -0,0 +1,339 @@ +import { + ArrowLeftIcon, + CompassIcon, + MagnifyingGlassIcon, +} from "@phosphor-icons/react"; +import { + availableScouts as deriveAvailableScouts, + latestEmittedAt as deriveLatestEmittedAt, + FINDINGS_SCOUT_FILTER_ALL, + FINDINGS_SEVERITIES, + FINDINGS_SEVERITY_FILTER_ALL, + type FindingsSortKey, + filterAndSortFindings, +} from "@posthog/core/scouts/scoutFindings"; +import { SCOUT_RUNS_WINDOW_SPAN } from "@posthog/core/scouts/scoutRunsWindow"; +import { useSetHeaderContent } from "@posthog/ui/hooks/useSetHeaderContent"; +import { RelativeTimestamp } from "@posthog/ui/primitives/RelativeTimestamp"; +import { getPostHogUrl } from "@posthog/ui/utils/urls"; +import { Box, Flex, Select, Text, TextField } from "@radix-ui/themes"; +import { Link } from "@tanstack/react-router"; +import { useMemo, useState } from "react"; +import { useScoutFindings } from "../hooks/useScoutFindings"; +import { ScoutEmissionCard } from "./ScoutEmissionCard"; +import { ScoutFindingDiscussButton } from "./ScoutFindingDiscussButton"; +import { ScoutFindingShareButton } from "./ScoutFindingShareButton"; +import { ScoutTaskRunLink } from "./ScoutTaskRunLink"; + +const SORT_OPTIONS: { value: FindingsSortKey; label: string }[] = [ + { value: "newest", label: "Newest" }, + { value: "oldest", label: "Oldest" }, + { value: "severity", label: "Severity" }, + { value: "confidence", label: "Confidence" }, +]; + +/** + * Cross-fleet findings browser — every finding the troop emitted in the recent + * runs window, in one place, newest first, searchable and filterable by scout / + * severity with a sort toggle. Reuses the per-scout {@link ScoutEmissionCard} + * with `showScout` on. Read-only; acting on a finding happens in its inbox + * report. Mirrors the PostHog Cloud `FindingsPanel`, kept structurally aligned + * so the two surfaces stay in parity as the backend evolves. + */ +export function ScoutFindingsView() { + const { rows, isLoading, isError, incompleteRuns, windowTruncated, refetch } = + useScoutFindings(); + const [searchText, setSearchText] = useState(""); + const [scoutFilter, setScoutFilter] = useState(FINDINGS_SCOUT_FILTER_ALL); + const [severityFilter, setSeverityFilter] = useState( + FINDINGS_SEVERITY_FILTER_ALL, + ); + const [sortKey, setSortKey] = useState("newest"); + + const headerContent = useMemo( + () => ( + + + + Scout findings + + + ), + [], + ); + useSetHeaderContent(headerContent); + + const availableScouts = useMemo(() => deriveAvailableScouts(rows), [rows]); + const filteredRows = useMemo( + () => + filterAndSortFindings(rows, { + searchText, + scoutFilter, + severityFilter, + sortKey, + }), + [rows, searchText, scoutFilter, severityFilter, sortKey], + ); + + const totalCount = rows.length; + const scoutCount = availableScouts.length; + const latest = useMemo(() => deriveLatestEmittedAt(rows), [rows]); + const isFiltering = + searchText.trim().length > 0 || + scoutFilter !== FINDINGS_SCOUT_FILTER_ALL || + severityFilter !== FINDINGS_SEVERITY_FILTER_ALL; + + return ( + + + + + Scouts + + + + + Scout findings + + + + Every signal your scouts have emitted recently, in one place — newest + first. See what's been surfaced across the whole troop, which + scout found it, and the inbox report it fed into. + + {totalCount > 0 ? ( + + + {totalCount} finding{totalCount === 1 ? "" : "s"} · {scoutCount}{" "} + scout{scoutCount === 1 ? "" : "s"} + + {latest ? ( + <> + · latest + + + ) : null} + + ) : null} + + Covers findings from the most recent {SCOUT_RUNS_WINDOW_SPAN} of troop + runs. Older findings live on in the inbox reports they produced. + + + +
+
+ + + setSearchText(event.target.value)} + size="2" + className="min-w-[14rem] flex-1" + > + + + + + + + + + + All scouts + + {availableScouts.map((scout) => ( + + {scout.label} ({scout.count}) + + ))} + + + + + + + + All severities + + {FINDINGS_SEVERITIES.map((severity) => ( + + {severity} + + ))} + + + + setSortKey(value as FindingsSortKey)} + > + + + {SORT_OPTIONS.map((option) => ( + + Sort: {option.label} + + ))} + + + + + {incompleteRuns > 0 || windowTruncated ? ( + + + {incompleteRuns > 0 + ? "Some findings couldn't be loaded, so this list may be incomplete." + : "Showing the most recent runs only — older findings in this window may be missing."} + + + + ) : null} + + refetch()} + rows={filteredRows} + isFiltering={isFiltering} + /> + +
+
+
+ ); +} + +function FindingsBody({ + isLoading, + isError, + onRetry, + rows, + isFiltering, +}: { + isLoading: boolean; + isError: boolean; + onRetry: () => void; + rows: ReturnType; + isFiltering: boolean; +}) { + if (isLoading) { + return ( + + {[0, 1, 2].map((key) => ( + + ))} + + ); + } + + if (isError) { + return ( + + + Couldn't load findings. The scout API may be unavailable or this + project may not be enrolled yet. + + + + ); + } + + if (rows.length === 0) { + return ( + + {isFiltering + ? "No findings match your search and filters." + : "Your scouts haven't emitted any findings yet. As they scan your project, what they surface shows up here."} + + ); + } + + return ( + + {rows.map((row) => { + const taskRunUrl = row.run.task_url + ? getPostHogUrl(row.run.task_url) + : null; + return ( + + + + + } + footerEnd={ + taskRunUrl ? ( + + ) : undefined + } + /> + ); + })} + + ); +} diff --git a/packages/ui/src/features/scouts/components/ScoutsFleetSection.tsx b/packages/ui/src/features/scouts/components/ScoutsFleetSection.tsx index bb978a3773..4f1d1e1f9e 100644 --- a/packages/ui/src/features/scouts/components/ScoutsFleetSection.tsx +++ b/packages/ui/src/features/scouts/components/ScoutsFleetSection.tsx @@ -31,6 +31,7 @@ import { useScoutChatTask } from "../hooks/useScoutChatTask"; import { useScoutConfigMutations } from "../hooks/useScoutConfigMutations"; import { useScoutConfigs } from "../hooks/useScoutConfigs"; import { useScoutRuns } from "../hooks/useScoutRuns"; +import { FleetFindingsCallout } from "./FleetFindingsCallout"; import { FleetMemoryCallout } from "./FleetMemoryCallout"; import { ScoutAlphaBanner } from "./ScoutAlphaBanner"; import { ScoutHelperSkillLinks } from "./ScoutHelperSkillLinks"; @@ -228,6 +229,9 @@ function ScoutsFleetList({ configs }: { configs: ScoutConfig[] }) { /> + {/* Cross-fleet findings: renders only once the troop has emitted something. */} + + {/* Fleet memory: renders only once scouts have written scratchpad notes. */} diff --git a/packages/ui/src/features/scouts/hooks/useScoutFindings.ts b/packages/ui/src/features/scouts/hooks/useScoutFindings.ts new file mode 100644 index 0000000000..4c49cc28a3 --- /dev/null +++ b/packages/ui/src/features/scouts/hooks/useScoutFindings.ts @@ -0,0 +1,173 @@ +import type { + ScoutEmission, + ScoutEmissionReportLink, +} from "@posthog/api-client/posthog-client"; +import { + buildFindingRows, + mostRecentEmittedRuns, + reportsBySourceId, + type ScoutFindingRow, +} from "@posthog/core/scouts/scoutFindings"; +import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; +import { AUTH_SCOPED_QUERY_META } from "@posthog/ui/features/auth/useCurrentUser"; +import { useQueries } from "@tanstack/react-query"; +import { useMemo } from "react"; +import { useAuthStateValue } from "../../auth/store"; +import { scoutQueryKeys } from "./scoutQueryKeys"; +import { useScoutRuns } from "./useScoutRuns"; + +export interface ScoutFindings { + rows: ScoutFindingRow[]; + /** True until the runs window and its first emissions fan-out have resolved once. */ + isLoading: boolean; + /** True when the runs window failed, or every emitted run's emissions fetch failed. */ + isError: boolean; + /** + * Emitted runs whose promised findings are missing from the list — the + * emissions fetch either failed or resolved empty despite `emitted_count > 0`. + * Non-zero means the visible list is an undercount. + */ + incompleteRuns: number; + /** + * True when the recent-runs set the page covers is itself clipped — pagination + * stopped early, or more emitted runs exist than the fan-out cap fetches. The + * window's own findings may therefore be missing, beyond {@link incompleteRuns}. + */ + windowTruncated: boolean; + refetch: () => void; +} + +/** + * Cross-fleet findings — the data behind the findings page. Reads the polled + * runs window, narrows it to the recent emitted runs, then fans out one + * emissions query + one report-link query per run (emissions are only fetchable + * per run), and flattens the lot into one row list via the core + * {@link buildFindingRows}. The per-scout {@link ScoutSignalsSection} renders a + * child component per run instead; a single sortable list can't, so the fan-out + * lives here as `useQueries`. + */ +export function useScoutFindings(): ScoutFindings { + const client = useOptionalAuthenticatedClient(); + const projectId = useAuthStateValue((state) => state.currentProjectId); + const runsQuery = useScoutRuns(); + + const allEmittedRuns = useMemo( + () => + (runsQuery.data?.runs ?? []).filter( + (run) => (run.emitted_count ?? 0) > 0, + ), + [runsQuery.data], + ); + const emittedRuns = useMemo( + () => mostRecentEmittedRuns(runsQuery.data?.runs ?? []), + [runsQuery.data], + ); + + const emissionsResults = useQueries({ + queries: emittedRuns.map((run) => ({ + // emitted_count in the key so an in-progress run that emits more retriggers + // its fetch; completed runs hold a stable count and never refetch. The + // shared prefix stays intact for prefix-targeted invalidation. + queryKey: [ + ...scoutQueryKeys.emissions(projectId, run.run_id), + run.emitted_count ?? 0, + ], + queryFn: async (): Promise => { + if (!client) throw new Error("Not authenticated"); + if (!projectId) return []; + return client.listScoutRunEmissions(projectId, run.run_id); + }, + enabled: !!client && !!projectId, + staleTime: 60_000, + meta: AUTH_SCOPED_QUERY_META, + })), + }); + + const reportResults = useQueries({ + queries: emittedRuns.map((run) => ({ + // emitted_count in the key alongside the emissions query, so a run that + // emits more refetches its report links too — otherwise the newly loaded + // findings would show without their inbox-report chip until focus/retry. + queryKey: [ + ...scoutQueryKeys.emissionReports(projectId, run.run_id), + run.emitted_count ?? 0, + ], + queryFn: async (): Promise => { + if (!client) throw new Error("Not authenticated"); + if (!projectId) return []; + return client.listScoutEmissionReports(projectId, run.run_id); + }, + enabled: !!client && !!projectId, + staleTime: 60_000, + meta: AUTH_SCOPED_QUERY_META, + })), + }); + + const emissions = useMemo( + () => emissionsResults.flatMap((result) => result.data ?? []), + [emissionsResults], + ); + const reportLinks = useMemo( + () => reportResults.flatMap((result) => result.data ?? []), + [reportResults], + ); + + const rows = useMemo( + () => + buildFindingRows(emissions, emittedRuns, reportsBySourceId(reportLinks)), + [emissions, emittedRuns, reportLinks], + ); + + // First load only — `isLoading` stays false across background polls so the + // list doesn't flash a skeleton each refetch. + const emissionsLoading = emissionsResults.some((result) => result.isLoading); + const isLoading = + runsQuery.isLoading || + (emittedRuns.length > 0 && emissionsLoading && emissions.length === 0); + + // Total failure: the window failed, or it loaded but every emitted run's + // emissions fetch rejected (so there is nothing to show). + const allEmissionsFailed = + emittedRuns.length > 0 && + emissionsResults.every((result) => result.isError); + const isError = runsQuery.isError || allEmissionsFailed; + + // Runs that promised findings (emitted_count > 0) but didn't deliver them: + // the fetch errored, or it resolved empty (eventual-consistency lag, or a + // backend quirk). Either way those findings are missing from the list, so + // the page can warn rather than imply completeness. Only meaningful when it + // isn't a total failure (that surfaces as `isError`). + const incompleteRuns = allEmissionsFailed + ? 0 + : emittedRuns.reduce((count, run, index) => { + const result = emissionsResults[index]; + if (!result) return count; + if (result.isError) return count + 1; + // Promised more findings than it returned — empty, or a partial + // response (eventual-consistency lag). Either undercounts the list. + const loaded = result.data?.length ?? 0; + if (result.isSuccess && loaded < (run.emitted_count ?? 0)) { + return count + 1; + } + return count; + }, 0); + + // The run set itself is clipped when pagination stopped early, or when more + // emitted runs exist than the fan-out cap fetches. + const windowTruncated = + !(runsQuery.data?.complete ?? true) || + allEmittedRuns.length > emittedRuns.length; + + return { + rows, + isLoading, + isError, + incompleteRuns, + windowTruncated, + refetch: () => { + void runsQuery.refetch(); + for (const result of emissionsResults) void result.refetch(); + for (const result of reportResults) void result.refetch(); + }, + }; +} diff --git a/packages/ui/src/router/routeTree.gen.ts b/packages/ui/src/router/routeTree.gen.ts index 66faae5075..134257efba 100644 --- a/packages/ui/src/router/routeTree.gen.ts +++ b/packages/ui/src/router/routeTree.gen.ts @@ -55,6 +55,7 @@ import { Route as CodeInboxReportsReportIdRouteImport } from './routes/code/inbo import { Route as CodeInboxPullsReportIdRouteImport } from './routes/code/inbox/pulls.$reportId' import { Route as CodeInboxDismissedReportIdRouteImport } from './routes/code/inbox/dismissed.$reportId' import { Route as CodeAgentsScoutsScratchpadRouteImport } from './routes/code/agents/scouts.scratchpad' +import { Route as CodeAgentsScoutsFindingsRouteImport } from './routes/code/agents/scouts.findings' import { Route as CodeAgentsScoutsSkillNameRouteImport } from './routes/code/agents/scouts.$skillName' import { Route as CodeAgentsApplicationsApprovalsRouteImport } from './routes/code/agents/applications/approvals' import { Route as CodeAgentsApplicationsIdOrSlugRouteImport } from './routes/code/agents/applications/$idOrSlug' @@ -305,6 +306,12 @@ const CodeAgentsScoutsScratchpadRoute = path: '/scratchpad', getParentRoute: () => CodeAgentsScoutsRoute, } as any) +const CodeAgentsScoutsFindingsRoute = + CodeAgentsScoutsFindingsRouteImport.update({ + id: '/findings', + path: '/findings', + getParentRoute: () => CodeAgentsScoutsRoute, + } as any) const CodeAgentsScoutsSkillNameRoute = CodeAgentsScoutsSkillNameRouteImport.update({ id: '/$skillName', @@ -420,6 +427,7 @@ export interface FileRoutesByFullPath { '/code/agents/applications/$idOrSlug': typeof CodeAgentsApplicationsIdOrSlugRouteWithChildren '/code/agents/applications/approvals': typeof CodeAgentsApplicationsApprovalsRoute '/code/agents/scouts/$skillName': typeof CodeAgentsScoutsSkillNameRouteWithChildren + '/code/agents/scouts/findings': typeof CodeAgentsScoutsFindingsRoute '/code/agents/scouts/scratchpad': typeof CodeAgentsScoutsScratchpadRoute '/code/inbox/dismissed/$reportId': typeof CodeInboxDismissedReportIdRoute '/code/inbox/pulls/$reportId': typeof CodeInboxPullsReportIdRoute @@ -470,6 +478,7 @@ export interface FileRoutesByTo { '/code/inbox': typeof CodeInboxIndexRoute '/website/$channelId': typeof WebsiteChannelIdIndexRoute '/code/agents/applications/approvals': typeof CodeAgentsApplicationsApprovalsRoute + '/code/agents/scouts/findings': typeof CodeAgentsScoutsFindingsRoute '/code/agents/scouts/scratchpad': typeof CodeAgentsScoutsScratchpadRoute '/code/inbox/dismissed/$reportId': typeof CodeInboxDismissedReportIdRoute '/code/inbox/pulls/$reportId': typeof CodeInboxPullsReportIdRoute @@ -532,6 +541,7 @@ export interface FileRoutesById { '/code/agents/applications/$idOrSlug': typeof CodeAgentsApplicationsIdOrSlugRouteWithChildren '/code/agents/applications/approvals': typeof CodeAgentsApplicationsApprovalsRoute '/code/agents/scouts/$skillName': typeof CodeAgentsScoutsSkillNameRouteWithChildren + '/code/agents/scouts/findings': typeof CodeAgentsScoutsFindingsRoute '/code/agents/scouts/scratchpad': typeof CodeAgentsScoutsScratchpadRoute '/code/inbox/dismissed/$reportId': typeof CodeInboxDismissedReportIdRoute '/code/inbox/pulls/$reportId': typeof CodeInboxPullsReportIdRoute @@ -595,6 +605,7 @@ export interface FileRouteTypes { | '/code/agents/applications/$idOrSlug' | '/code/agents/applications/approvals' | '/code/agents/scouts/$skillName' + | '/code/agents/scouts/findings' | '/code/agents/scouts/scratchpad' | '/code/inbox/dismissed/$reportId' | '/code/inbox/pulls/$reportId' @@ -645,6 +656,7 @@ export interface FileRouteTypes { | '/code/inbox' | '/website/$channelId' | '/code/agents/applications/approvals' + | '/code/agents/scouts/findings' | '/code/agents/scouts/scratchpad' | '/code/inbox/dismissed/$reportId' | '/code/inbox/pulls/$reportId' @@ -706,6 +718,7 @@ export interface FileRouteTypes { | '/code/agents/applications/$idOrSlug' | '/code/agents/applications/approvals' | '/code/agents/scouts/$skillName' + | '/code/agents/scouts/findings' | '/code/agents/scouts/scratchpad' | '/code/inbox/dismissed/$reportId' | '/code/inbox/pulls/$reportId' @@ -1074,6 +1087,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof CodeAgentsScoutsScratchpadRouteImport parentRoute: typeof CodeAgentsScoutsRoute } + '/code/agents/scouts/findings': { + id: '/code/agents/scouts/findings' + path: '/findings' + fullPath: '/code/agents/scouts/findings' + preLoaderRoute: typeof CodeAgentsScoutsFindingsRouteImport + parentRoute: typeof CodeAgentsScoutsRoute + } '/code/agents/scouts/$skillName': { id: '/code/agents/scouts/$skillName' path: '/$skillName' @@ -1274,12 +1294,14 @@ const CodeAgentsScoutsSkillNameRouteWithChildren = interface CodeAgentsScoutsRouteChildren { CodeAgentsScoutsSkillNameRoute: typeof CodeAgentsScoutsSkillNameRouteWithChildren + CodeAgentsScoutsFindingsRoute: typeof CodeAgentsScoutsFindingsRoute CodeAgentsScoutsScratchpadRoute: typeof CodeAgentsScoutsScratchpadRoute CodeAgentsScoutsIndexRoute: typeof CodeAgentsScoutsIndexRoute } const CodeAgentsScoutsRouteChildren: CodeAgentsScoutsRouteChildren = { CodeAgentsScoutsSkillNameRoute: CodeAgentsScoutsSkillNameRouteWithChildren, + CodeAgentsScoutsFindingsRoute: CodeAgentsScoutsFindingsRoute, CodeAgentsScoutsScratchpadRoute: CodeAgentsScoutsScratchpadRoute, CodeAgentsScoutsIndexRoute: CodeAgentsScoutsIndexRoute, } diff --git a/packages/ui/src/router/routes/code/agents/scouts.findings.tsx b/packages/ui/src/router/routes/code/agents/scouts.findings.tsx new file mode 100644 index 0000000000..1a619d90b7 --- /dev/null +++ b/packages/ui/src/router/routes/code/agents/scouts.findings.tsx @@ -0,0 +1,6 @@ +import { ScoutFindingsView } from "@posthog/ui/features/scouts/components/ScoutFindingsView"; +import { createFileRoute } from "@tanstack/react-router"; + +export const Route = createFileRoute("/code/agents/scouts/findings")({ + component: ScoutFindingsView, +});