diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index 968e18073b2..185c0a8a559 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -886,23 +886,6 @@ export function SideMenu({ }); } - if (user.admin || user.isImpersonating || featureFlags.hasWebhooksAccess) { - staticSections.push({ - id: "webhooks", - title: "Webhooks", - items: [ - { - id: "webhook-deliveries", - name: "Deliveries", - icon: WebhookIcon, - activeIconColor: "text-webhooks", - to: v3WebhooksPath(organization, project, environment), - dataAction: "webhook-deliveries", - }, - ], - }); - } - staticSections.push({ id: "deployments", title: "Deployments", @@ -1204,6 +1187,19 @@ export function SideMenu({ isCollapsed={isCollapsed} yieldActiveToFavorite /> + {(user.admin || user.isImpersonating || featureFlags.hasWebhooksAccess) && ( + } + isCollapsed={isCollapsed} + yieldActiveToFavorite + /> + )} {orderedSectionIds.map((sectionId) => { diff --git a/apps/webapp/app/components/navigation/favoritePages.tsx b/apps/webapp/app/components/navigation/favoritePages.tsx index 4439aadedfd..c07ba2d6d5d 100644 --- a/apps/webapp/app/components/navigation/favoritePages.tsx +++ b/apps/webapp/app/components/navigation/favoritePages.tsx @@ -40,6 +40,7 @@ import { TasksIcon } from "~/assets/icons/TasksIcon"; import { UsageIcon } from "~/assets/icons/UsageIcon"; import { UserGroupIcon } from "~/assets/icons/UserGroupIcon"; import { WaitpointTokenIcon } from "~/assets/icons/WaitpointTokenIcon"; +import { WebhookIcon } from "~/assets/icons/WebhookIcon"; import { VercelLogo } from "~/components/integrations/VercelLogo"; import { useOptionalUser } from "~/hooks/useUser"; import { type FavoritePage } from "~/services/dashboardPreferences.server"; @@ -71,6 +72,7 @@ const FAVORITE_PAGE_ICONS: Record< "task-agent": { icon: CubeSparkleIcon, activeColor: "text-agents" }, runs: { icon: RunsIcon, activeColor: "text-runs" }, sessions: { icon: AIChatIcon, activeColor: "text-sessions" }, + webhooks: { icon: WebhookIcon, activeColor: "text-webhooks" }, prompts: { icon: AIPenIcon, activeColor: "text-aiPrompts" }, models: { icon: Box3DIcon, activeColor: "text-models" }, logs: { icon: LogsIcon, activeColor: "text-logs" }, @@ -213,6 +215,7 @@ const ENV_PAGE_META: Record = { "": { icon: "tasks", name: "Tasks", singular: "Task" }, runs: { icon: "runs", name: "Runs", singular: "Run" }, sessions: { icon: "sessions", name: "Sessions", singular: "Session" }, + webhooks: { icon: "webhooks", name: "Webhook deliveries" }, prompts: { icon: "prompts", name: "Prompts", singular: "Prompt" }, models: { icon: "models", name: "Models", singular: "Model" }, logs: { icon: "logs", name: "Logs" }, diff --git a/apps/webapp/app/components/navigation/sideMenuTypes.ts b/apps/webapp/app/components/navigation/sideMenuTypes.ts index 0aa3dd03985..508c4121175 100644 --- a/apps/webapp/app/components/navigation/sideMenuTypes.ts +++ b/apps/webapp/app/components/navigation/sideMenuTypes.ts @@ -9,7 +9,6 @@ export const SideMenuSectionIdSchema = z.enum([ "deployments", "project-settings", "tasks", - "webhooks", ]); // Inferred type from the schema diff --git a/apps/webapp/app/components/primitives/CopyableText.tsx b/apps/webapp/app/components/primitives/CopyableText.tsx index 9634ad1ac01..bf5898390a3 100644 --- a/apps/webapp/app/components/primitives/CopyableText.tsx +++ b/apps/webapp/app/components/primitives/CopyableText.tsx @@ -12,6 +12,7 @@ export function CopyableText({ asChild, variant, hideTooltip, + truncate, }: { value: string; copyValue?: string; @@ -24,6 +25,12 @@ export function CopyableText({ * fire Radix's global "one tooltip open at a time" close and dismiss the parent. */ hideTooltip?: boolean; + /** + * Ellipsise the value rather than letting it overflow its column. For unbreakable strings + * (hashes, opaque ids) that offer no wrap opportunity. The copy button moves into a reserved + * right gutter so it stays visible instead of sitting outside the column. + */ + truncate?: boolean; }) { const [isHovered, setIsHovered] = useState(false); const { copy, copied } = useCopy(copyValue ?? value); @@ -51,15 +58,26 @@ export function CopyableText({ return ( setIsHovered(false)} > - setIsHovered(true)}>{value} + setIsHovered(true)} + > + {value} + e.stopPropagation()} className={cn( - "absolute -right-6 top-0 z-10 size-6 font-sans", + "absolute top-0 z-10 size-6 font-sans", + // Truncated values reserve a right gutter, so the button sits inside it + truncate ? "right-0" : "-right-6", isHovered ? "flex" : "hidden" )} > diff --git a/apps/webapp/app/components/run/RunTimeline.tsx b/apps/webapp/app/components/run/RunTimeline.tsx index 1672a2b2f78..edc576980c6 100644 --- a/apps/webapp/app/components/run/RunTimeline.tsx +++ b/apps/webapp/app/components/run/RunTimeline.tsx @@ -483,6 +483,12 @@ export type RunTimelineLineProps = { state?: TimelineEventState; variant?: TimelineLineVariant; style?: TimelineStyle; + /** + * Round the top of a thick ("normal") line. Needed when the line itself starts the thick bar, + * as in the delivery timeline, where nothing above it supplies a `start-cap-thick`. The run + * timeline always precedes its thick line with that cap, so it leaves this off. + */ + roundedTop?: boolean; }; export function RunTimelineLine({ @@ -490,11 +496,12 @@ export function RunTimelineLine({ state, variant = "normal", style = "normal", + roundedTop = false, }: RunTimelineLineProps) { return (
- +
{title} @@ -507,10 +514,12 @@ function LineMarker({ state, variant, style, + roundedTop = false, }: { state?: TimelineEventState; variant: TimelineLineVariant; style?: TimelineStyle; + roundedTop?: boolean; }) { let containerClass = "bg-text-dimmed"; switch (state) { @@ -532,7 +541,7 @@ function LineMarker({ switch (variant) { case "normal": return ( -
+
{state === "inprogress" && (
{delivery.webhook ? ( - + {delivery.webhook.slug} ) : ( - Unknown + + Unknown + )} )} {delivery.friendlyId} - {delivery.isTest ? ( - - Test - - ) : null} + {delivery.isTest ? Test : null} - + {delivery.externalDeliveryId ? (
) : ( - None + + None + )}
- + {/* Falls back to the delivery so the whole row stays clickable when there is no target */} + {delivery.session ? ( @@ -166,20 +168,24 @@ export function DeliveriesTable({ {delivery.run.friendlyId} ) : ( - None + + None + )} - + - + {delivery.processedAt ? ( ) : ( - None + + None + )} - + {delivery.status === "FAILED" && delivery.errorMessage ? ( ) : ( - None + + None + )} {item.to ? ( diff --git a/apps/webapp/app/components/webhookDeliveries/v1/WebhookDeliveryFilters.tsx b/apps/webapp/app/components/webhookDeliveries/v1/WebhookDeliveryFilters.tsx index aa6d9edd9fd..fcd3fa67df8 100644 --- a/apps/webapp/app/components/webhookDeliveries/v1/WebhookDeliveryFilters.tsx +++ b/apps/webapp/app/components/webhookDeliveries/v1/WebhookDeliveryFilters.tsx @@ -155,20 +155,13 @@ function Menu(props: MenuProps) { } } -function MainMenu({ searchValue, trigger, clearSearchValue, setFilterType }: MenuProps) { - const filtered = useMemo(() => { - return filterTypes.filter((item) => - item.title.toLowerCase().includes(searchValue.toLowerCase()) - ); - }, [searchValue]); - +function MainMenu({ trigger, clearSearchValue, setFilterType }: MenuProps) { return ( {trigger} - - {filtered.map((type, index) => ( + {filterTypes.map((type, index) => ( { @@ -190,12 +183,10 @@ function MainMenu({ searchValue, trigger, clearSearchValue, setFilterType }: Men function StatusDropdown({ trigger, clearSearchValue, - searchValue, onClose, }: { trigger: ReactNode; clearSearchValue: () => void; - searchValue: string; onClose?: () => void; }) { const { values, replace } = useSearchParams(); @@ -205,12 +196,6 @@ function StatusDropdown({ replace({ statuses: values, cursor: undefined, direction: undefined }); }; - const filtered = useMemo(() => { - return deliveryStatuses.filter((item) => - item.title.toLowerCase().includes(searchValue.toLowerCase()) - ); - }, [searchValue]); - return ( {trigger} @@ -225,9 +210,8 @@ function StatusDropdown({ return true; }} > - - {filtered.map((item, index) => ( + {deliveryStatuses.map((item, index) => ( - {(search, setSearch) => ( + {(_search, setSearch) => ( @@ -309,7 +293,6 @@ function PermanentStatusFilter() { } - searchValue={search} clearSearchValue={() => setSearch("")} /> )} @@ -536,7 +519,7 @@ function PermanentTestFilter() { /> ) : (
- + Test
)} diff --git a/apps/webapp/app/presenters/v3/WebhookDeliveriesListPresenter.server.ts b/apps/webapp/app/presenters/v3/WebhookDeliveriesListPresenter.server.ts index b2c22e244b2..efe5e146129 100644 --- a/apps/webapp/app/presenters/v3/WebhookDeliveriesListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/WebhookDeliveriesListPresenter.server.ts @@ -9,7 +9,7 @@ import { type WebhookDeliveryListItem, } from "./WebhookDetailPresenter.server"; -const DELIVERIES_PAGE_SIZE = 25; +const DELIVERIES_PAGE_SIZE = 60; type Direction = "forward" | "backward"; export type WebhookDeliveriesListResult = { diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.webhooks._index/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.webhooks._index/route.tsx index c8eee2552c7..37ab62c52d7 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.webhooks._index/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.webhooks._index/route.tsx @@ -149,31 +149,35 @@ export default function Page() { const { deliveries, pagination, possibleWebhooks, hasFilters } = useTypedLoaderData(); + const { visibleDeliveries, newDeliveriesButton } = useLiveDeliveries(deliveries); + return ( <> - +
- + {/* The new-deliveries button sits inline, immediately left of the pager */} +
+ {newDeliveriesButton} + +
- + {/* Sits directly in the 1fr row, like the runs, sessions and batches lists. No + stickyHeader: that switches Table's container to overflow-visible, which stops it + being the scroll container. The header is sticky either way (TableHeader always sets + sticky top-0), and the other webhook tables only pass it because an ancestor scrolls. */} +
); } -function LiveDeliveriesTable({ - deliveries, - hasFilters, -}: { - deliveries: WebhookDeliveryListItem[]; - hasFilters: boolean; -}) { +function useLiveDeliveries(deliveries: WebhookDeliveryListItem[]) { const organization = useOrganization(); const project = useProject(); const environment = useEnvironment(); @@ -204,36 +208,22 @@ function LiveDeliveriesTable({ revalidator.revalidate(); }; - return ( -
- {showNewDeliveriesBanner ? ( -
- - - -
- ) : null} -
- -
-
- ); + const newDeliveriesButton = showNewDeliveriesBanner ? ( + + + + ) : null; + + return { visibleDeliveries, newDeliveriesButton }; } diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.webhooks.deliveries.$deliveryParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.webhooks.deliveries.$deliveryParam/route.tsx index 303a54b5035..552cd5fdcd1 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.webhooks.deliveries.$deliveryParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.webhooks.deliveries.$deliveryParam/route.tsx @@ -1,7 +1,7 @@ import { BookOpenIcon } from "@heroicons/react/24/solid"; import { type MetaFunction, useRevalidator } from "@remix-run/react"; import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; -import { useState } from "react"; +import { type ReactNode, useState } from "react"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; import { AIChatIcon } from "~/assets/icons/AIChatIcon"; @@ -106,6 +106,17 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { return typedjson({ delivery, retentionDays: env.WEBHOOK_PARTITION_RETENTION_DAYS }); }; +/** Centred placeholder for a tab whose content was never captured. */ +function EmptyTabMessage({ children }: { children: ReactNode }) { + return ( +
+ + {children} + +
+ ); +} + function formatDuration(createdAt: Date, processedAt: Date | null): string | null { if (!processedAt) return null; const ms = processedAt.getTime() - createdAt.getTime(); @@ -183,7 +194,6 @@ export default function Page() { {delivery.friendlyId} - } /> @@ -224,9 +234,9 @@ export default function Page() { eventJson ? ( ) : ( - + No event payload was captured for this delivery. - + ) ) : headersJson ? ( ) : ( - + No request headers were captured for this delivery. - + )}
@@ -262,7 +272,11 @@ export default function Page() { ID - + @@ -305,7 +319,7 @@ export default function Page() { {delivery.session.friendlyId} @@ -319,7 +333,7 @@ export default function Page() { {delivery.run && runPath ? ( {delivery.run.friendlyId} @@ -335,7 +349,8 @@ export default function Page() { {delivery.externalDeliveryId ? ( ) : ( None @@ -348,7 +363,8 @@ export default function Page() { {delivery.idempotencyKey ? ( ) : ( None @@ -359,7 +375,11 @@ export default function Page() { Raw body hash {delivery.rawBodyHash ? ( - + ) : ( None )} diff --git a/apps/webapp/seed-webhook-deliveries.ts b/apps/webapp/seed-webhook-deliveries.ts index 856e6c37f35..436a7eaa9a3 100644 --- a/apps/webapp/seed-webhook-deliveries.ts +++ b/apps/webapp/seed-webhook-deliveries.ts @@ -469,9 +469,13 @@ async function main() { [ep.spec.signatureHeader.toLowerCase()]: status === "FAILED" ? "tampered" : `sig_${nanoid()}`, }; + // friendlyId must be the id plus the prefix, matching WebhookDeliveryId. The detail + // lookup strips "whd_" and queries Postgres by `id`, so minting the two independently + // makes every seeded delivery's detail page 404. + const deliveryId = nanoid(); rows.push({ - id: nanoid(), - friendlyId: `whd_${nanoid()}`, + id: deliveryId, + friendlyId: `whd_${deliveryId}`, endpointId: ep.id, source: ep.spec.source, status,