{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 ? (
-
-
- }
- tooltip="Refresh to see new deliveries"
- aria-label="New deliveries received. Refresh to see them."
- >
- {newDeliveriesCount >= 100
- ? "99+ new deliveries"
- : `${newDeliveriesCount} new ${
- newDeliveriesCount === 1 ? "delivery" : "deliveries"
- }`}
-
-
-
- ) : null}
-
-
-
-
- );
+ const newDeliveriesButton = showNewDeliveriesBanner ? (
+
+ }
+ tooltip="Refresh to see new deliveries"
+ aria-label="New deliveries received. Refresh to see them."
+ >
+ {newDeliveriesCount >= 100
+ ? "99+ new deliveries"
+ : `${newDeliveriesCount} new ${newDeliveriesCount === 1 ? "delivery" : "deliveries"}`}
+
+
+ ) : 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 (
+
+ );
+}
+
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,