diff --git a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/[reportId]/loading.tsx b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/[reportId]/loading.tsx new file mode 100644 index 0000000000..24845a8d1d --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/[reportId]/loading.tsx @@ -0,0 +1,9 @@ +import { + DetailMainSkeleton, + LoadingShell, +} from '../_components/LoadingShell'; + +/** Detail-route loading state. Mobile shows ONLY the detail-shape main pane. */ +export default function Loading() { + return } />; +} diff --git a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/CompletedDetail.tsx b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/CompletedDetail.tsx index 4abd99ed18..b2b69b136a 100644 --- a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/CompletedDetail.tsx +++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/CompletedDetail.tsx @@ -43,7 +43,7 @@ export function CompletedDetail({ return (
-
+
diff --git a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/EmptyState.tsx b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/EmptyState.tsx index 3a83908c54..ab3e054742 100644 --- a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/EmptyState.tsx +++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/EmptyState.tsx @@ -46,7 +46,7 @@ export function EmptyState({ ? "You've used your trial run. Paid plans are coming soon — contact support if you need access today." : 'Automated black-box pen testing. Start a scan to see findings here.'; return ( -
+

diff --git a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/FailedDetail.tsx b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/FailedDetail.tsx index 046dd6a384..8664ed61ad 100644 --- a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/FailedDetail.tsx +++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/FailedDetail.tsx @@ -16,7 +16,7 @@ export function FailedDetail({ run, onRetry }: FailedDetailProps) { return (
-
+
diff --git a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/FindingDetail.tsx b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/FindingDetail.tsx index 785f0df71a..8d262b69e8 100644 --- a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/FindingDetail.tsx +++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/FindingDetail.tsx @@ -34,7 +34,7 @@ export function FindingDetail({ issue, onBack }: FindingDetailProps) { return (
-
+
+ ); +} + +/** Overview-shape main-pane skeleton (header + hero card + 4-stat band). */ +export function OverviewMainSkeleton() { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {Array.from({ length: 4 }).map((_, i) => ( +
0 + ? 'space-y-3 md:border-l md:border-border md:pl-6' + : 'space-y-3' + } + > +
+
+
+
+ ))} +
+
+
+ ); +} + +/** Detail-shape main-pane skeleton (header + sev tally + agent grid + table). */ +export function DetailMainSkeleton() { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {Array.from({ length: 22 }).map((_, i) => ( +
+ ))} +
+
+
+
+
+
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ ))} +
+
+
+
+
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ ))} +
+
+
+
+ ); +} + +/** Create-form-shape main-pane skeleton. */ +export function CreateMainSkeleton() { + return ( +
+
+
+
+
+
+
+
+ {Array.from({ length: 2 }).map((_, i) => ( +
+
+
+
+
+ ))} +
+
+ {Array.from({ length: 3 }).map((_, i) => ( +
+ ))} +
+
+
+
+
+
+
+
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/OverviewPane.tsx b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/OverviewPane.tsx index 5aa3d849ec..67f7407fd8 100644 --- a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/OverviewPane.tsx +++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/OverviewPane.tsx @@ -1,7 +1,8 @@ 'use client'; import { Button } from '@trycompai/design-system'; -import { Add } from '@trycompai/design-system/icons'; +import { Add, ArrowRight } from '@trycompai/design-system/icons'; +import { useRouter } from 'next/navigation'; import type { PentestRun } from '@/lib/security/penetration-tests-client'; import { LatestAssessment, @@ -17,6 +18,8 @@ import { sortByUpdatedDesc, uniqueTargets, } from './overview-internals'; +import { StatusPill } from './StatusPill'; +import { isRunInProgress } from './severity'; interface OverviewPaneProps { orgId: string; @@ -29,10 +32,13 @@ interface OverviewPaneProps { } /** - * Right pane shown when no scan is selected. Two real states: + * Right pane shown when no scan is selected. Three real states: * - * - 0 completed scans → onboarding card with primary CTA - * - 1+ completed scans → posture overview: real counts, recent scans, + * - 0 completed, 0 in-progress → onboarding card with primary CTA + * - 0 completed, 1+ in-progress → in-progress card surfacing the running + * scan(s); avoids the "No scans yet" lie when the sidebar already + * shows a running scan. + * - 1+ completed → posture overview: real counts, recent scans, * stale-coverage list. NO cross-scan severity aggregation, NO trend * chart, NO "open findings" queue — those need per-run issue counts * in the list endpoint (backend aggregation), which we don't have @@ -50,6 +56,22 @@ export function OverviewPane({ const completed = runs.filter((r) => r.status === 'completed'); if (completed.length === 0) { + // If there are no completed runs but a scan is currently running, surface + // it instead of the "No scans yet" onboarding — that headline contradicts + // the sidebar (which already shows the running scan) and was the source + // of a customer report: "I left, came back, it says no scans yet instead + // of just showing the latest one which had already started." + const inProgress = runs.filter((r) => isRunInProgress(r.status)); + if (inProgress.length > 0) { + return ( + + ); + } return ; } @@ -66,6 +88,106 @@ export function OverviewPane({ ); } +interface InProgressStateProps { + orgId: string; + runs: PentestRun[]; + onCreateClick: () => void; + canCreate: boolean; +} + +/** + * Shown on `/pentests` when the org has only in-progress scans (no + * completed history yet). Lists the running scans with a click-to-view + * card so the user lands on something that matches the sidebar instead + * of the onboarding empty state. + */ +function InProgressState({ + orgId, + runs, + onCreateClick, + canCreate, +}: InProgressStateProps) { + const router = useRouter(); + const sorted = [...runs].sort( + (a, b) => + new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(), + ); + const headline = + sorted.length === 1 ? 'Scan in progress' : `${sorted.length} scans in progress`; + + return ( +
+
+
+ Penetration tests · Overview +
+

+ {headline} +

+

+ Findings stream in as agents discover them. You don't need to keep + this page open — open the run any time to see live progress. +

+ +
    + {sorted.slice(0, 3).map((run) => ( +
  • + +
  • + ))} +
+ + {sorted.length > 3 ? ( +

+ +{sorted.length - 3} more in the sidebar. +

+ ) : null} + +
+ +
+
+
+ ); +} + +function toShortRunId(fullId: string): string { + const tail = fullId.replace(/[^0-9]/g, '').slice(-4); + return tail ? `PT-${tail}` : fullId; +} + +function targetHost(url: string): string { + try { + return new URL(url).host; + } catch { + return url; + } +} + function OnboardingState({ onCreateClick, canCreate, @@ -74,7 +196,7 @@ function OnboardingState({ canCreate: boolean; }) { return ( -
+
Penetration tests · Overview @@ -128,21 +250,26 @@ function PostureOverview({ onDownloadMarkdown, onDownloadPdf, }: PostureOverviewProps) { - // Coverage and stale-target stats use ONLY completed runs — a target - // that's only ever had failed/cancelled scans isn't truly "covered," - // and a target whose latest scan failed shouldn't reset the staleness - // clock. The full `runs` list is only used for the recent activity - // sidebar elsewhere. + // Coverage / avg-duration / stale stats are completed-only on purpose: + // a target whose only scans are running or failed isn't actually + // "covered," a running scan has no real duration yet, and a failed + // scan shouldn't reset the staleness clock. + // + // "Recent scans" is the exception — that's an activity feed, not a + // success metric, so it uses the full `runs` list. Otherwise a user + // with completed history but a fresh running scan would see the + // running one in the sidebar but NOT here, which contradicts the + // sidebar and hides the live scan from the overview. const targets = uniqueTargets(completed); const lastScan = mostRecent(completed); const avgDuration = avgDurationMs(completed); const scansLast30d = countWithin(completed, 30 * 24 * 60 * 60 * 1000); - const recentScans = sortByUpdatedDesc(completed).slice(0, 6); + const recentScans = sortByUpdatedDesc(runs).slice(0, 6); const staleTargets = computeStaleTargets(completed, targets); return (
-
+

diff --git a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/RunList.tsx b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/RunList.tsx index 3cb7023000..2f0d84f96e 100644 --- a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/RunList.tsx +++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/RunList.tsx @@ -36,7 +36,7 @@ export function RunList({ : 'No pentest runs remaining.' : 'Start a new scan'; return ( -