diff --git a/.changeset/packet-v2-packets-api.md b/.changeset/packet-v2-packets-api.md new file mode 100644 index 00000000000..9ec5fa1b338 --- /dev/null +++ b/.changeset/packet-v2-packets-api.md @@ -0,0 +1,5 @@ +--- +"@trigger.dev/core": patch +--- + +Large run outputs can use the new API which allows switching object storage providers. \ No newline at end of file diff --git a/.env.example b/.env.example index 35c8c976ff6..69d5acdc560 100644 --- a/.env.example +++ b/.env.example @@ -77,9 +77,28 @@ POSTHOG_PROJECT_KEY= # DEPOT_TOKEN= # DEV_OTEL_EXPORTER_OTLP_ENDPOINT="http://0.0.0.0:4318" # These are needed for the object store (for handling large payloads/outputs) -# OBJECT_STORE_BASE_URL="https://{bucket}.{accountId}.r2.cloudflarestorage.com" -# OBJECT_STORE_ACCESS_KEY_ID= -# OBJECT_STORE_SECRET_ACCESS_KEY= +# +# Default provider +# OBJECT_STORE_BASE_URL=http://localhost:9005 +# OBJECT_STORE_BUCKET=packets +# OBJECT_STORE_ACCESS_KEY_ID=minioadmin +# OBJECT_STORE_SECRET_ACCESS_KEY=minioadmin +# OBJECT_STORE_REGION=us-east-1 +# OBJECT_STORE_SERVICE=s3 +# +# OBJECT_STORE_DEFAULT_PROTOCOL=s3 # Only specify this if you're going to migrate object storage and set protocol values below +# Named providers (protocol-prefixed data) - optional for multi-provider support +# OBJECT_STORE_S3_BASE_URL=https://s3.amazonaws.com +# OBJECT_STORE_S3_ACCESS_KEY_ID= +# OBJECT_STORE_S3_SECRET_ACCESS_KEY= +# OBJECT_STORE_S3_REGION=us-east-1 +# OBJECT_STORE_S3_SERVICE=s3 +# +# OBJECT_STORE_R2_BASE_URL=https://{bucket}.{accountId}.r2.cloudflarestorage.com +# OBJECT_STORE_R2_ACCESS_KEY_ID= +# OBJECT_STORE_R2_SECRET_ACCESS_KEY= +# OBJECT_STORE_R2_REGION=auto +# OBJECT_STORE_R2_SERVICE=s3 # CHECKPOINT_THRESHOLD_IN_MS=10000 # These control the server-side internal telemetry diff --git a/.server-changes/batch-r2-upload-retry.md b/.server-changes/batch-r2-upload-retry.md new file mode 100644 index 00000000000..a2c6415635b --- /dev/null +++ b/.server-changes/batch-r2-upload-retry.md @@ -0,0 +1,9 @@ +--- +area: webapp +type: fix +--- + +Fix transient R2/object store upload failures during batchTrigger() item streaming. + +- Added p-retry (3 attempts, 500ms–2s exponential backoff) around `uploadPacketToObjectStore` in `BatchPayloadProcessor.process()` so transient network errors self-heal server-side rather than aborting the entire batch stream. +- Removed `x-should-retry: false` from the 500 response on the batch items route so the SDK's existing 5xx retry path can recover if server-side retries are exhausted. Item deduplication by index makes full-stream retries safe. diff --git a/.server-changes/enqueue-fast-path.md b/.server-changes/enqueue-fast-path.md new file mode 100644 index 00000000000..65ff0dbaca8 --- /dev/null +++ b/.server-changes/enqueue-fast-path.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: improvement +--- + +Reduce run start latency by skipping the intermediate queue when concurrency is available. This optimization is rolled out per-region and enabled automatically for development environments. diff --git a/.server-changes/env-variables-search-by-environment.md b/.server-changes/env-variables-search-by-environment.md new file mode 100644 index 00000000000..c3f9ed8bc2a --- /dev/null +++ b/.server-changes/env-variables-search-by-environment.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: improvement +--- + +Extended the search filter on the environment variables page to match on environment type (production, staging, development, preview) and branch name, not just variable name and value. diff --git a/.server-changes/multi-provider-object-storage.md b/.server-changes/multi-provider-object-storage.md new file mode 100644 index 00000000000..6749b5dcdbb --- /dev/null +++ b/.server-changes/multi-provider-object-storage.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: feature +--- + +Multi-provider object storage with protocol-based routing for zero-downtime migration diff --git a/.server-changes/object-store-iam-auth.md b/.server-changes/object-store-iam-auth.md new file mode 100644 index 00000000000..4a400eb29fe --- /dev/null +++ b/.server-changes/object-store-iam-auth.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: feature +--- + +Add IAM role-based auth support for object stores (no access keys required). diff --git a/.server-changes/prisma-application-name.md b/.server-changes/prisma-application-name.md new file mode 100644 index 00000000000..825058f3b34 --- /dev/null +++ b/.server-changes/prisma-application-name.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: improvement +--- + +Set `application_name` on Prisma connections from SERVICE_NAME so DB load can be attributed by service diff --git a/apps/webapp/app/assets/icons/SlackMonoIcon.tsx b/apps/webapp/app/assets/icons/SlackMonoIcon.tsx new file mode 100644 index 00000000000..666393a229d --- /dev/null +++ b/apps/webapp/app/assets/icons/SlackMonoIcon.tsx @@ -0,0 +1,10 @@ +export function SlackMonoIcon({ className }: { className?: string }) { + return ( + + + + + + + ); +} diff --git a/apps/webapp/app/components/BackgroundWrapper.tsx b/apps/webapp/app/components/BackgroundWrapper.tsx index ecff3af6dd4..aaf06d56aaf 100644 --- a/apps/webapp/app/components/BackgroundWrapper.tsx +++ b/apps/webapp/app/components/BackgroundWrapper.tsx @@ -5,10 +5,9 @@ import blurredDashboardBackgroundTable from "~/assets/images/blurred-dashboard-b export function BackgroundWrapper({ children }: { children: ReactNode }) { return ( -
- {/* Left menu top background - fixed width 260px, maintains aspect ratio */} +
- {/* Left menu bottom background - fixed width 260px, maintains aspect ratio */}
- {/* Right table background - fixed width 2000px, positioned next to menu */}
- {/* Content layer */}
{children}
); diff --git a/apps/webapp/app/components/GitMetadata.tsx b/apps/webapp/app/components/GitMetadata.tsx index efe3fb0efb7..fb53ee6bfea 100644 --- a/apps/webapp/app/components/GitMetadata.tsx +++ b/apps/webapp/app/components/GitMetadata.tsx @@ -25,9 +25,10 @@ export function GitMetadataBranch({ } + leadingIconClassName="group-hover/table-row:text-text-bright" iconSpacing="gap-x-1" to={git.branchUrl} - className="pl-1" + className="pl-1 duration-0 [&_span]:duration-0 [&_span]:group-hover/table-row:text-text-bright" > {git.branchName} @@ -49,8 +50,9 @@ export function GitMetadataCommit({ variant="minimal/small" to={git.commitUrl} LeadingIcon={} + leadingIconClassName="group-hover/table-row:text-text-bright" iconSpacing="gap-x-1" - className="pl-1" + className="pl-1 duration-0 [&_span]:duration-0 [&_span]:group-hover/table-row:text-text-bright" > {`${git.shortSha} / ${git.commitMessage}`} @@ -74,8 +76,9 @@ export function GitMetadataPullRequest({ variant="minimal/small" to={git.pullRequestUrl} LeadingIcon={} + leadingIconClassName="group-hover/table-row:text-text-bright" iconSpacing="gap-x-1" - className="pl-1" + className="pl-1 duration-0 [&_span]:duration-0 [&_span]:group-hover/table-row:text-text-bright" > #{git.pullRequestNumber} {git.pullRequestTitle} diff --git a/apps/webapp/app/components/LoginPageLayout.tsx b/apps/webapp/app/components/LoginPageLayout.tsx index d9ac7ceb4d7..3e42cd6894f 100644 --- a/apps/webapp/app/components/LoginPageLayout.tsx +++ b/apps/webapp/app/components/LoginPageLayout.tsx @@ -46,10 +46,10 @@ export function LoginPageLayout({ children }: { children: React.ReactNode }) { }, []); return ( -
-
-
-
+
+
+
+
@@ -63,12 +63,12 @@ export function LoginPageLayout({ children }: { children: React.ReactNode }) {
{children}
- Having login issues? Email us{" "} + Having login issues? Email us{" "} or ask us in Discord
-
+
{randomQuote?.quote} diff --git a/apps/webapp/app/components/errors/ConfigureErrorAlerts.tsx b/apps/webapp/app/components/errors/ConfigureErrorAlerts.tsx new file mode 100644 index 00000000000..dc586c89438 --- /dev/null +++ b/apps/webapp/app/components/errors/ConfigureErrorAlerts.tsx @@ -0,0 +1,365 @@ +import { conform, list, requestIntent, useFieldList, useForm } from "@conform-to/react"; +import { parse } from "@conform-to/zod"; +import { + EnvelopeIcon, + GlobeAltIcon, + HashtagIcon, + LockClosedIcon, + XMarkIcon, +} from "@heroicons/react/20/solid"; +import { useFetcher, useNavigate } from "@remix-run/react"; +import { SlackIcon } from "@trigger.dev/companyicons"; +import { Fragment, useEffect, useRef, useState } from "react"; +import { z } from "zod"; +import { Button, LinkButton } from "~/components/primitives/Buttons"; +import { Callout, variantClasses } from "~/components/primitives/Callout"; +import { useToast } from "~/components/primitives/Toast"; +import { Fieldset } from "~/components/primitives/Fieldset"; +import { FormError } from "~/components/primitives/FormError"; +import { Header2, Header3 } from "~/components/primitives/Headers"; +import { Hint } from "~/components/primitives/Hint"; +import { InlineCode } from "~/components/code/InlineCode"; +import { Input } from "~/components/primitives/Input"; +import { InputGroup } from "~/components/primitives/InputGroup"; +import { Paragraph } from "~/components/primitives/Paragraph"; +import { Select, SelectItem } from "~/components/primitives/Select"; +import { UnorderedList } from "~/components/primitives/UnorderedList"; +import type { ErrorAlertChannelData } from "~/presenters/v3/ErrorAlertChannelPresenter.server"; +import { useOptimisticLocation } from "~/hooks/useOptimisticLocation"; +import { useOrganization } from "~/hooks/useOrganizations"; +import { cn } from "~/utils/cn"; +import { organizationSlackIntegrationPath } from "~/utils/pathBuilder"; +import { ExitIcon } from "~/assets/icons/ExitIcon"; +import { TextLink } from "~/components/primitives/TextLink"; +import { BellAlertIcon } from "@heroicons/react/24/solid"; + +export const ErrorAlertsFormSchema = z.object({ + emails: z.preprocess((i) => { + if (typeof i === "string") return i === "" ? [] : [i]; + if (Array.isArray(i)) return i.filter((v) => typeof v === "string" && v !== ""); + return []; + }, z.string().email().array()), + slackChannel: z.string().optional(), + slackIntegrationId: z.string().optional(), + webhooks: z.preprocess((i) => { + if (typeof i === "string") return i === "" ? [] : [i]; + if (Array.isArray(i)) return i.filter((v) => typeof v === "string" && v !== ""); + return []; + }, z.string().url().array()), +}); + +type ConfigureErrorAlertsProps = ErrorAlertChannelData & { + connectToSlackHref?: string; + formAction: string; +}; + +export function ConfigureErrorAlerts({ + emails: existingEmails, + webhooks: existingWebhooks, + slackChannel: existingSlackChannel, + slack, + emailAlertsEnabled, + connectToSlackHref, + formAction, +}: ConfigureErrorAlertsProps) { + const organization = useOrganization(); + const fetcher = useFetcher<{ ok?: boolean }>(); + const navigate = useNavigate(); + const toast = useToast(); + const location = useOptimisticLocation(); + const isSubmitting = fetcher.state !== "idle"; + + const [selectedSlackChannelValue, setSelectedSlackChannelValue] = useState( + existingSlackChannel + ? `${existingSlackChannel.channelId}/${existingSlackChannel.channelName}` + : undefined + ); + + const selectedSlackChannel = + slack.status === "READY" + ? slack.channels?.find((s) => selectedSlackChannelValue === `${s.id}/${s.name}`) + : undefined; + + const closeHref = (() => { + const params = new URLSearchParams(location.search); + params.delete("alerts"); + const qs = params.toString(); + return qs ? `?${qs}` : location.pathname; + })(); + + const hasHandledSuccess = useRef(false); + useEffect(() => { + if (fetcher.state === "idle" && fetcher.data?.ok && !hasHandledSuccess.current) { + hasHandledSuccess.current = true; + toast.success("Alert settings saved"); + navigate(closeHref, { replace: true }); + } + }, [fetcher.state, fetcher.data, closeHref, navigate, toast]); + + const emailFieldValues = useRef( + existingEmails.length > 0 ? [...existingEmails.map((e) => e.email), ""] : [""] + ); + + const webhookFieldValues = useRef( + existingWebhooks.length > 0 ? [...existingWebhooks.map((w) => w.url), ""] : [""] + ); + + const [form, { emails, webhooks, slackChannel, slackIntegrationId }] = useForm({ + id: "configure-error-alerts", + onValidate({ formData }) { + return parse(formData, { schema: ErrorAlertsFormSchema }); + }, + shouldRevalidate: "onSubmit", + defaultValue: { + emails: emailFieldValues.current, + webhooks: webhookFieldValues.current, + }, + }); + + const emailFields = useFieldList(form.ref, emails); + const webhookFields = useFieldList(form.ref, webhooks); + + return ( +
+
+ + Configure alerts + + +
+ + +
+
+
+ Receive alerts when + +
  • An error is seen for the first time
  • +
  • A resolved error re-occurs
  • +
  • An ignored error re-occurs based on settings you configured
  • +
    +
    + + {/* Email section */} +
    + Email + {emailAlertsEnabled ? ( + + {emailFields.map((emailField, index) => ( + + { + emailFieldValues.current[index] = e.target.value; + if ( + emailFields.length === emailFieldValues.current.length && + emailFieldValues.current.every((v) => v !== "") + ) { + requestIntent(form.ref.current ?? undefined, list.append(emails.name)); + } + }} + /> + {emailField.error} + + ))} + + ) : ( + + Email integration is not available. Please contact your organization + administrator. + + )} +
    + + {/* Slack section */} +
    + Slack + + + {slack.status === "READY" ? ( + <> + + {selectedSlackChannel && selectedSlackChannel.is_private && ( + + To receive alerts in the{" "} + {selectedSlackChannel.name}{" "} + channel, you need to invite the @Trigger.dev Slack Bot. Go to the channel in + Slack and type:{" "} + /invite @Trigger.dev. + + )} + + + Manage Slack connection + + + + + ) : slack.status === "NOT_CONFIGURED" ? ( + connectToSlackHref ? ( + + + Connect to Slack + + + ) : ( + + Slack is not connected. Connect Slack from the{" "} + Alerts page to enable + Slack notifications. + + ) + ) : slack.status === "TOKEN_REVOKED" || slack.status === "TOKEN_EXPIRED" ? ( + connectToSlackHref ? ( +
    + + The Slack integration in your workspace has been revoked or has expired. + Please re-connect your Slack workspace. + + + + Connect to Slack + + +
    + ) : ( + + The Slack integration in your workspace has been revoked or expired. Please + re-connect from the{" "} + Alerts page. + + ) + ) : slack.status === "FAILED_FETCHING_CHANNELS" ? ( + + Failed loading channels from Slack. Please try again later. + + ) : ( + + Slack integration is not available. Please contact your organization + administrator. + + )} +
    +
    + + {/* Webhook section */} +
    + Webhook + + {webhookFields.map((webhookField, index) => ( + + { + webhookFieldValues.current[index] = e.target.value; + if ( + webhookFields.length === webhookFieldValues.current.length && + webhookFieldValues.current.every((v) => v !== "") + ) { + requestIntent(form.ref.current ?? undefined, list.append(webhooks.name)); + } + }} + /> + {webhookField.error} + + ))} + We'll issue POST requests to these URLs with a JSON payload. + +
    + + {form.error} +
    +
    + +
    + + Cancel + + +
    +
    +
    + ); +} + +function SlackChannelTitle({ name, is_private }: { name?: string; is_private?: boolean }) { + return ( +
    + {is_private ? : } + {name} +
    + ); +} diff --git a/apps/webapp/app/components/errors/ErrorStatusBadge.tsx b/apps/webapp/app/components/errors/ErrorStatusBadge.tsx new file mode 100644 index 00000000000..571a209ddf1 --- /dev/null +++ b/apps/webapp/app/components/errors/ErrorStatusBadge.tsx @@ -0,0 +1,34 @@ +import { type ErrorGroupStatus } from "@trigger.dev/database"; +import { cn } from "~/utils/cn"; + +const styles: Record = { + UNRESOLVED: "bg-error/10 text-error", + RESOLVED: "bg-success/10 text-success", + IGNORED: "bg-blue-500/10 text-blue-400", +}; + +const labels: Record = { + UNRESOLVED: "Unresolved", + RESOLVED: "Resolved", + IGNORED: "Ignored", +}; + +export function ErrorStatusBadge({ + status, + className, +}: { + status: ErrorGroupStatus; + className?: string; +}) { + return ( + + {labels[status]} + + ); +} diff --git a/apps/webapp/app/components/errors/ErrorStatusMenu.tsx b/apps/webapp/app/components/errors/ErrorStatusMenu.tsx new file mode 100644 index 00000000000..a981c8eee52 --- /dev/null +++ b/apps/webapp/app/components/errors/ErrorStatusMenu.tsx @@ -0,0 +1,250 @@ +import { CheckIcon } from "@heroicons/react/20/solid"; +import { + IconAlarmSnooze as IconAlarmSnoozeBase, + IconArrowBackUp as IconArrowBackUpBase, + IconBugOff as IconBugOffBase, +} from "@tabler/icons-react"; +import { useEffect, useRef, useState } from "react"; +import { type ErrorGroupStatus } from "@trigger.dev/database"; +import { useFetcher } from "@remix-run/react"; +import { Button } from "~/components/primitives/Buttons"; +import { useToast } from "~/components/primitives/Toast"; +import { FormError } from "~/components/primitives/FormError"; +import { Input } from "~/components/primitives/Input"; +import { InputGroup } from "~/components/primitives/InputGroup"; +import { Label } from "~/components/primitives/Label"; +import { PopoverMenuItem } from "~/components/primitives/Popover"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "~/components/primitives/Dialog"; + +const AlarmSnoozeIcon = ({ className }: { className?: string }) => ( + +); +const ArrowBackUpIcon = ({ className }: { className?: string }) => ( + +); +const BugOffIcon = ({ className }: { className?: string }) => ( + +); + +export function statusActionToastMessage(data: Record): string { + switch (data.action) { + case "resolve": + return "Error marked as resolved"; + case "unresolve": + return "Error marked as unresolved"; + case "ignore": { + const duration = data.duration ? Number(data.duration) : undefined; + if (!duration) return "Error ignored indefinitely"; + const hours = duration / (60 * 60 * 1000); + if (hours < 24) return `Error ignored for ${hours} ${hours === 1 ? "hour" : "hours"}`; + const days = hours / 24; + return `Error ignored for ${days} ${days === 1 ? "day" : "days"}`; + } + default: + return "Error status updated"; + } +} + +export function ErrorStatusMenuItems({ + status, + taskIdentifier, + onAction, + onCustomIgnore, +}: { + status: ErrorGroupStatus; + taskIdentifier: string; + onAction: (data: Record) => void; + onCustomIgnore: () => void; +}) { + return ( + <> + {status === "UNRESOLVED" && ( + <> + onAction({ taskIdentifier, action: "resolve" })} + /> + + onAction({ + taskIdentifier, + action: "ignore", + duration: String(60 * 60 * 1000), + }) + } + /> + + onAction({ + taskIdentifier, + action: "ignore", + duration: String(24 * 60 * 60 * 1000), + }) + } + /> + onAction({ taskIdentifier, action: "ignore" })} + /> + + + )} + + {status === "IGNORED" && ( + <> + onAction({ taskIdentifier, action: "resolve" })} + /> + onAction({ taskIdentifier, action: "unresolve" })} + /> + + )} + + {status === "RESOLVED" && ( + onAction({ taskIdentifier, action: "unresolve" })} + /> + )} + + ); +} + +export function CustomIgnoreDialog({ + open, + onOpenChange, + taskIdentifier, + formAction, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + taskIdentifier: string; + formAction?: string; +}) { + const fetcher = useFetcher<{ ok?: boolean }>(); + const isSubmitting = fetcher.state !== "idle"; + const [conditionError, setConditionError] = useState(null); + const toast = useToast(); + const hasHandledSuccess = useRef(false); + + useEffect(() => { + if (fetcher.state === "idle" && fetcher.data?.ok && !hasHandledSuccess.current) { + hasHandledSuccess.current = true; + toast.success("Error ignored with custom condition"); + onOpenChange(false); + } + }, [fetcher.state, fetcher.data, onOpenChange, toast]); + + return ( + + + + + + Custom ignore condition + + + { + e.preventDefault(); + const formData = new FormData(e.currentTarget); + const rate = formData.get("occurrenceRate")?.toString().trim(); + const total = formData.get("totalOccurrences")?.toString().trim(); + + if (!rate && !total) { + setConditionError("At least one unignore condition is required"); + return; + } + + setConditionError(null); + hasHandledSuccess.current = false; + fetcher.submit(e.currentTarget, { method: "post", action: formAction }); + }} + > + + + +
    + + + conditionError && setConditionError(null)} + /> + + + + + conditionError && setConditionError(null)} + /> + + + {conditionError && {conditionError}} + + + + + +
    + + + + + +
    +
    +
    + ); +} diff --git a/apps/webapp/app/components/layout/AppLayout.tsx b/apps/webapp/app/components/layout/AppLayout.tsx index 635489400ac..3da2bfbb5a5 100644 --- a/apps/webapp/app/components/layout/AppLayout.tsx +++ b/apps/webapp/app/components/layout/AppLayout.tsx @@ -60,13 +60,26 @@ export function PageBody({ export function MainCenteredContainer({ children, className, + variant = "default", }: { children: React.ReactNode; className?: string; + variant?: "default" | "onboarding"; }) { return ( -
    -
    +
    +
    {children}
    diff --git a/apps/webapp/app/components/logs/LogsTable.tsx b/apps/webapp/app/components/logs/LogsTable.tsx index dcbd2d6868f..ed8e6793e5f 100644 --- a/apps/webapp/app/components/logs/LogsTable.tsx +++ b/apps/webapp/app/components/logs/LogsTable.tsx @@ -167,8 +167,8 @@ export function LogsTable({ > - - + + {log.runId} {log.taskIdentifier} diff --git a/apps/webapp/app/components/logs/LogsVersionFilter.tsx b/apps/webapp/app/components/logs/LogsVersionFilter.tsx new file mode 100644 index 00000000000..4cc10545060 --- /dev/null +++ b/apps/webapp/app/components/logs/LogsVersionFilter.tsx @@ -0,0 +1,58 @@ +import * as Ariakit from "@ariakit/react"; +import { SelectTrigger } from "~/components/primitives/Select"; +import { useSearchParams } from "~/hooks/useSearchParam"; +import { appliedSummary, FilterMenuProvider } from "~/components/runs/v3/SharedFilters"; +import { filterIcon, VersionsDropdown } from "~/components/runs/v3/RunFilters"; +import { AppliedFilter } from "~/components/primitives/AppliedFilter"; + +const shortcut = { key: "v" }; + +export function LogsVersionFilter() { + const { values, del } = useSearchParams(); + const selectedVersions = values("versions"); + + if (selectedVersions.length === 0 || selectedVersions.every((v) => v === "")) { + return ( + + {(search, setSearch) => ( + + Versions + + } + searchValue={search} + clearSearchValue={() => setSearch("")} + /> + )} + + ); + } + + return ( + + {(search, setSearch) => ( + }> + del(["versions", "cursor", "direction"])} + variant="secondary/small" + /> + + } + searchValue={search} + clearSearchValue={() => setSearch("")} + /> + )} + + ); +} diff --git a/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx b/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx index b3cc17724a3..e274ad20f43 100644 --- a/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx +++ b/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx @@ -83,6 +83,7 @@ export function OrganizationSettingsSideMenu({ name="Usage" icon={ChartBarIcon} activeIconColor="text-indigo-500" + inactiveIconColor="text-indigo-500" to={v3UsagePath(organization)} data-action="usage" /> @@ -90,6 +91,7 @@ export function OrganizationSettingsSideMenu({ name="Billing" icon={CreditCardIcon} activeIconColor="text-emerald-500" + inactiveIconColor="text-emerald-500" to={v3BillingPath(organization)} data-action="billing" badge={ @@ -102,6 +104,7 @@ export function OrganizationSettingsSideMenu({ name="Billing alerts" icon={BellAlertIcon} activeIconColor="text-rose-500" + inactiveIconColor="text-rose-500" to={v3BillingAlertsPath(organization)} data-action="billing-alerts" /> @@ -112,6 +115,7 @@ export function OrganizationSettingsSideMenu({ name="Private Connections" icon={LockClosedIcon} activeIconColor="text-purple-500" + inactiveIconColor="text-purple-500" to={v3PrivateConnectionsPath(organization)} data-action="private-connections" /> @@ -120,6 +124,7 @@ export function OrganizationSettingsSideMenu({ name="Team" icon={UserGroupIcon} activeIconColor="text-amber-500" + inactiveIconColor="text-amber-500" to={organizationTeamPath(organization)} data-action="team" /> @@ -127,6 +132,7 @@ export function OrganizationSettingsSideMenu({ name="Settings" icon={Cog8ToothIcon} activeIconColor="text-orgSettings" + inactiveIconColor="text-orgSettings" to={organizationSettingsPath(organization)} data-action="settings" /> @@ -139,6 +145,8 @@ export function OrganizationSettingsSideMenu({ name="Vercel" icon={VercelLogo} activeIconColor="text-white" + inactiveIconColor="text-white" + iconClassName="size-4 ml-0.5" to={organizationVercelIntegrationPath(organization)} data-action="integrations" /> @@ -146,6 +154,7 @@ export function OrganizationSettingsSideMenu({ name="Slack" icon={SlackIcon} activeIconColor="text-white" + inactiveIconColor="text-white" to={organizationSlackIntegrationPath(organization)} data-action="integrations" /> diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index d64fc96488c..0693a2418b1 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -464,10 +464,7 @@ export function SideMenu({ title="AI" isSideMenuCollapsed={isCollapsed} itemSpacingClassName="space-y-0" - initialCollapsed={getSectionCollapsed( - user.dashboardPreferences.sideMenu, - "ai" - )} + initialCollapsed={getSectionCollapsed(user.dashboardPreferences.sideMenu, "ai")} onCollapseToggle={handleSectionToggle("ai")} > - {(user.admin || user.isImpersonating || featureFlags.hasAiModelsAccess) && ( + {(user.admin || user.isImpersonating || featureFlags.hasAiAccess) && (
    diff --git a/apps/webapp/app/components/navigation/SideMenuItem.tsx b/apps/webapp/app/components/navigation/SideMenuItem.tsx index 844782ed615..26aad3908df 100644 --- a/apps/webapp/app/components/navigation/SideMenuItem.tsx +++ b/apps/webapp/app/components/navigation/SideMenuItem.tsx @@ -10,6 +10,7 @@ export function SideMenuItem({ icon, activeIconColor, inactiveIconColor, + iconClassName, trailingIcon, trailingIconClassName, name, @@ -22,6 +23,7 @@ export function SideMenuItem({ icon?: RenderIcon; activeIconColor?: string; inactiveIconColor?: string; + iconClassName?: string; trailingIcon?: RenderIcon; trailingIconClassName?: string; name: string; @@ -47,7 +49,8 @@ export function SideMenuItem({ icon={icon} className={cn( "size-5 shrink-0", - isActive ? activeIconColor : inactiveIconColor ?? "text-text-dimmed" + isActive ? activeIconColor : inactiveIconColor ?? "text-text-dimmed", + iconClassName )} /> -
    +
    {icon} {label && (
    diff --git a/apps/webapp/app/components/primitives/Buttons.tsx b/apps/webapp/app/components/primitives/Buttons.tsx index 8ba196b5dc2..86e23801e72 100644 --- a/apps/webapp/app/components/primitives/Buttons.tsx +++ b/apps/webapp/app/components/primitives/Buttons.tsx @@ -341,7 +341,7 @@ export const Button = forwardRef( disabled: isDisabled || !props.shortcut, }); - return ( + const buttonElement = ( ); + + if (props.tooltip) { + return ( + + + + + {buttonElement} + + + + {props.tooltip} {props.shortcut && !props.hideShortcutKey && ( + + )} + + + + ); + } + + return buttonElement; } ); diff --git a/apps/webapp/app/components/primitives/Checkbox.tsx b/apps/webapp/app/components/primitives/Checkbox.tsx index 59f12a40481..3b9ca1c532d 100644 --- a/apps/webapp/app/components/primitives/Checkbox.tsx +++ b/apps/webapp/app/components/primitives/Checkbox.tsx @@ -184,8 +184,8 @@ export const Checkbox = forwardRef( + {copied ? "Copied" : "Copy"} + {copied ? ( + + ) : ( + + )} + + ); +} diff --git a/apps/webapp/app/components/primitives/DateTime.tsx b/apps/webapp/app/components/primitives/DateTime.tsx index 27dc75415b9..4dae92731af 100644 --- a/apps/webapp/app/components/primitives/DateTime.tsx +++ b/apps/webapp/app/components/primitives/DateTime.tsx @@ -361,33 +361,35 @@ function formatDateTimeAccurate( type RelativeDateTimeProps = { date: Date | string; timeZone?: string; + capitalize?: boolean; }; -function getRelativeText(date: Date): string { +function getRelativeText(date: Date, capitalize = true): string { const text = formatDistanceToNow(date, { addSuffix: true }); + if (!capitalize) return text; return text.charAt(0).toUpperCase() + text.slice(1); } -export const RelativeDateTime = ({ date, timeZone }: RelativeDateTimeProps) => { +export const RelativeDateTime = ({ date, timeZone, capitalize = true }: RelativeDateTimeProps) => { const locales = useLocales(); const userTimeZone = useUserTimeZone(); const realDate = useMemo(() => (typeof date === "string" ? new Date(date) : date), [date]); - const [relativeText, setRelativeText] = useState(() => getRelativeText(realDate)); + const [relativeText, setRelativeText] = useState(() => getRelativeText(realDate, capitalize)); // Every 60s refresh useEffect(() => { const interval = setInterval(() => { - setRelativeText(getRelativeText(realDate)); + setRelativeText(getRelativeText(realDate, capitalize)); }, 60_000); return () => clearInterval(interval); - }, [realDate]); + }, [realDate, capitalize]); // On first render useEffect(() => { - setRelativeText(getRelativeText(realDate)); - }, [realDate]); + setRelativeText(getRelativeText(realDate, capitalize)); + }, [realDate, capitalize]); return ( ["type"]; + danger?: boolean; } >( ( @@ -80,18 +84,26 @@ const PopoverMenuItem = React.forwardRef< onClick, disabled, openInNewTab = false, + name, + value, + type, + danger = false, }, ref ) => { const contentProps = { variant: variant.variant, LeadingIcon: icon, - leadingIconClassName, + leadingIconClassName: danger + ? cn(leadingIconClassName, "transition-colors group-hover/button:text-error") + : leadingIconClassName, fullWidth: true, textAlignLeft: true, TrailingIcon: isSelected ? CheckIcon : undefined, className: cn( - "group-hover:bg-charcoal-700", + danger + ? "transition-colors group-hover/button:bg-error/10 group-hover/button:text-error [&_span]:transition-colors [&_span]:group-hover/button:text-error" + : "group-hover:bg-charcoal-700", isSelected ? "bg-charcoal-750 group-hover:bg-charcoal-600/50" : undefined, className ), @@ -114,7 +126,6 @@ const PopoverMenuItem = React.forwardRef< return ( @@ -197,6 +211,18 @@ const popoverArrowTriggerVariants = { text: "group-hover:text-text-bright", icon: "text-text-dimmed group-hover:text-text-bright", }, + primary: { + trigger: + "bg-indigo-600 border border-indigo-500 text-text-bright hover:bg-indigo-500 hover:border-indigo-400 disabled:opacity-50 disabled:pointer-events-none", + text: "text-text-bright hover:text-white", + icon: "text-text-bright", + }, + secondary: { + trigger: + "bg-secondary border border-charcoal-600 text-text-bright hover:bg-charcoal-600 hover:border-charcoal-550 disabled:opacity-60 disabled:pointer-events-none", + text: "text-text-bright", + icon: "text-text-bright", + }, tertiary: { trigger: "bg-tertiary text-text-bright hover:bg-charcoal-600", text: "text-text-bright", @@ -245,8 +271,7 @@ function PopoverArrowTrigger({ const popoverVerticalEllipseVariants = { minimal: { - trigger: - "size-6 rounded-[3px] text-text-dimmed hover:bg-tertiary hover:text-text-bright", + trigger: "size-6 rounded-[3px] text-text-dimmed hover:bg-tertiary hover:text-text-bright", icon: "size-5", }, secondary: { diff --git a/apps/webapp/app/components/primitives/Resizable.tsx b/apps/webapp/app/components/primitives/Resizable.tsx index ba6ed35490e..2efaae4258e 100644 --- a/apps/webapp/app/components/primitives/Resizable.tsx +++ b/apps/webapp/app/components/primitives/Resizable.tsx @@ -1,6 +1,6 @@ "use client"; -import React from "react"; +import React, { useRef } from "react"; import { PanelGroup, Panel, PanelResizer } from "react-window-splitter"; import { cn } from "~/utils/cn"; @@ -69,6 +69,30 @@ const ResizableHandle = ({ ); -export { ResizableHandle, ResizablePanel, ResizablePanelGroup }; +const RESIZABLE_PANEL_ANIMATION = { + easing: "ease-in-out" as const, + duration: 200, +}; + +const COLLAPSIBLE_HANDLE_CLASSNAME = "transition-opacity duration-200"; + +function collapsibleHandleClassName(show: boolean) { + return cn(COLLAPSIBLE_HANDLE_CLASSNAME, !show && "pointer-events-none opacity-0"); +} + +function useFrozenValue(value: T | null | undefined): T | null | undefined { + const ref = useRef(value); + if (value != null) ref.current = value; + return ref.current; +} + +export { + RESIZABLE_PANEL_ANIMATION, + ResizableHandle, + ResizablePanel, + ResizablePanelGroup, + collapsibleHandleClassName, + useFrozenValue, +}; export type ResizableSnapshot = React.ComponentProps["snapshot"]; diff --git a/apps/webapp/app/components/primitives/SearchInput.tsx b/apps/webapp/app/components/primitives/SearchInput.tsx index ea9156839a3..639a4d1a737 100644 --- a/apps/webapp/app/components/primitives/SearchInput.tsx +++ b/apps/webapp/app/components/primitives/SearchInput.tsx @@ -10,11 +10,13 @@ export type SearchInputProps = { placeholder?: string; /** Additional URL params to reset when searching or clearing (e.g. pagination). Defaults to ["cursor", "direction"]. */ resetParams?: string[]; + autoFocus?: boolean; }; export function SearchInput({ placeholder = "Search logs…", resetParams = ["cursor", "direction"], + autoFocus, }: SearchInputProps) { const inputRef = useRef(null); @@ -71,6 +73,7 @@ export function SearchInput({ value={text} onChange={(e) => setText(e.target.value)} fullWidth + autoFocus={autoFocus} className={cn("", isFocused && "placeholder:text-text-dimmed/70")} onKeyDown={(e) => { if (e.key === "Enter") { diff --git a/apps/webapp/app/components/primitives/Table.tsx b/apps/webapp/app/components/primitives/Table.tsx index dfff784853d..1a30bc82b8a 100644 --- a/apps/webapp/app/components/primitives/Table.tsx +++ b/apps/webapp/app/components/primitives/Table.tsx @@ -431,7 +431,7 @@ export const TableCellMenu = forwardRef< onClick?: (event: React.MouseEvent) => void; visibleButtons?: ReactNode; hiddenButtons?: ReactNode; - popoverContent?: ReactNode; + popoverContent?: ReactNode | ((close: () => void) => ReactNode); children?: ReactNode; isSelected?: boolean; } @@ -451,6 +451,8 @@ export const TableCellMenu = forwardRef< ) => { const [isOpen, setIsOpen] = useState(false); const { variant } = useContext(TableContext); + const resolvedContent = + typeof popoverContent === "function" ? popoverContent(() => setIsOpen(false)) : popoverContent; return ( setIsOpen(open)}> + {resolvedContent && ( + setIsOpen(open)}> -
    {popoverContent}
    + {typeof popoverContent === "function" ? ( + resolvedContent + ) : ( +
    {resolvedContent}
    + )}
    )} diff --git a/apps/webapp/app/components/primitives/Toast.tsx b/apps/webapp/app/components/primitives/Toast.tsx index 742715fa6ad..175d5ccb604 100644 --- a/apps/webapp/app/components/primitives/Toast.tsx +++ b/apps/webapp/app/components/primitives/Toast.tsx @@ -1,7 +1,7 @@ import { EnvelopeIcon, ExclamationCircleIcon, XMarkIcon } from "@heroicons/react/20/solid"; import { CheckCircleIcon } from "@heroicons/react/24/solid"; import { useSearchParams } from "@remix-run/react"; -import { useEffect } from "react"; +import { useEffect, useMemo } from "react"; import { useTypedLoaderData } from "remix-typedjson"; import { Toaster, toast } from "sonner"; import { type ToastMessageAction } from "~/models/message.server"; @@ -43,6 +43,32 @@ export function Toast() { return ; } +export function useToast() { + return useMemo( + () => ({ + success(message: string, options?: { title?: string; ephemeral?: boolean }) { + const ephemeral = options?.ephemeral ?? true; + toast.custom( + (t) => ( + + ), + { duration: ephemeral ? defaultToastDuration : permanentToastDuration } + ); + }, + error(message: string, options?: { title?: string; ephemeral?: boolean }) { + const ephemeral = options?.ephemeral ?? true; + toast.custom( + (t) => ( + + ), + { duration: ephemeral ? defaultToastDuration : permanentToastDuration } + ); + }, + }), + [] + ); +} + export function ToastUI({ variant, message, diff --git a/apps/webapp/app/components/primitives/TreeView/TreeView.tsx b/apps/webapp/app/components/primitives/TreeView/TreeView.tsx index d1e002abb58..5f720c24fe9 100644 --- a/apps/webapp/app/components/primitives/TreeView/TreeView.tsx +++ b/apps/webapp/app/components/primitives/TreeView/TreeView.tsx @@ -197,6 +197,18 @@ export function useTree({ concreteStateFromInput({ tree, selectedId, collapsedIds, filter }) ); + //sync external selectedId prop into internal state + useEffect(() => { + const internalSelectedId = selectedIdFromState(state.nodes); + if (selectedId !== internalSelectedId) { + if (selectedId === undefined) { + dispatch({ type: "DESELECT_ALL_NODES" }); + } else { + dispatch({ type: "SELECT_NODE", payload: { id: selectedId, scrollToNode: false, scrollToNodeFn } }); + } + } + }, [selectedId]); + //fire onSelectedIdChanged() useEffect(() => { const selectedId = selectedIdFromState(state.nodes); diff --git a/apps/webapp/app/components/primitives/UnorderedList.tsx b/apps/webapp/app/components/primitives/UnorderedList.tsx new file mode 100644 index 00000000000..e65dfe6673f --- /dev/null +++ b/apps/webapp/app/components/primitives/UnorderedList.tsx @@ -0,0 +1,129 @@ +import { cn } from "~/utils/cn"; +import { type ParagraphVariant } from "./Paragraph"; + +const listVariants: Record< + ParagraphVariant, + { text: string; spacing: string; items: string } +> = { + base: { + text: "font-sans text-base font-normal text-text-dimmed", + spacing: "mb-3", + items: "space-y-1 [&>li]:gap-1.5", + }, + "base/bright": { + text: "font-sans text-base font-normal text-text-bright", + spacing: "mb-3", + items: "space-y-1 [&>li]:gap-1.5", + }, + small: { + text: "font-sans text-sm font-normal text-text-dimmed", + spacing: "mb-2", + items: "space-y-0.5 [&>li]:gap-1", + }, + "small/bright": { + text: "font-sans text-sm font-normal text-text-bright", + spacing: "mb-2", + items: "space-y-0.5 [&>li]:gap-1", + }, + "small/dimmed": { + text: "font-sans text-sm font-normal text-text-dimmed", + spacing: "mb-2", + items: "space-y-0.5 [&>li]:gap-1", + }, + "extra-small": { + text: "font-sans text-xs font-normal text-text-dimmed", + spacing: "mb-1.5", + items: "space-y-0.5 [&>li]:gap-1", + }, + "extra-small/bright": { + text: "font-sans text-xs font-normal text-text-bright", + spacing: "mb-1.5", + items: "space-y-0.5 [&>li]:gap-1", + }, + "extra-small/dimmed": { + text: "font-sans text-xs font-normal text-text-dimmed", + spacing: "mb-1.5", + items: "space-y-0.5 [&>li]:gap-1", + }, + "extra-small/dimmed/mono": { + text: "font-mono text-xs font-normal text-text-dimmed", + spacing: "mb-1.5", + items: "space-y-0.5 [&>li]:gap-1", + }, + "extra-small/mono": { + text: "font-mono text-xs font-normal text-text-dimmed", + spacing: "mb-1.5", + items: "space-y-0.5 [&>li]:gap-1", + }, + "extra-small/bright/mono": { + text: "font-mono text-xs text-text-bright", + spacing: "mb-1.5", + items: "space-y-0.5 [&>li]:gap-1", + }, + "extra-small/caps": { + text: "font-sans text-xs uppercase tracking-wider font-normal text-text-dimmed", + spacing: "mb-1.5", + items: "space-y-0.5 [&>li]:gap-1", + }, + "extra-small/bright/caps": { + text: "font-sans text-xs uppercase tracking-wider font-normal text-text-bright", + spacing: "mb-1.5", + items: "space-y-0.5 [&>li]:gap-1", + }, + "extra-extra-small": { + text: "font-sans text-xxs font-normal text-text-dimmed", + spacing: "mb-1", + items: "space-y-0.5 [&>li]:gap-0.5", + }, + "extra-extra-small/bright": { + text: "font-sans text-xxs font-normal text-text-bright", + spacing: "mb-1", + items: "space-y-0.5 [&>li]:gap-0.5", + }, + "extra-extra-small/caps": { + text: "font-sans text-xxs uppercase tracking-wider font-normal text-text-dimmed", + spacing: "mb-1", + items: "space-y-0.5 [&>li]:gap-0.5", + }, + "extra-extra-small/bright/caps": { + text: "font-sans text-xxs uppercase tracking-wider font-normal text-text-bright", + spacing: "mb-1", + items: "space-y-0.5 [&>li]:gap-0.5", + }, + "extra-extra-small/dimmed/caps": { + text: "font-sans text-xxs uppercase tracking-wider font-normal text-text-dimmed", + spacing: "mb-1", + items: "space-y-0.5 [&>li]:gap-0.5", + }, +}; + +type UnorderedListProps = { + variant?: ParagraphVariant; + className?: string; + spacing?: boolean; + children: React.ReactNode; +} & React.HTMLAttributes; + +export function UnorderedList({ + variant = "base", + className, + spacing = false, + children, + ...props +}: UnorderedListProps) { + const v = listVariants[variant]; + return ( +
      li]:flex [&>li]:items-baseline [&>li]:before:shrink-0 [&>li]:before:content-['•']", + v.text, + v.items, + spacing && v.spacing, + className + )} + {...props} + > + {children} +
    + ); +} diff --git a/apps/webapp/app/components/primitives/charts/ChartBar.tsx b/apps/webapp/app/components/primitives/charts/ChartBar.tsx index 28493070c06..0b560747297 100644 --- a/apps/webapp/app/components/primitives/charts/ChartBar.tsx +++ b/apps/webapp/app/components/primitives/charts/ChartBar.tsx @@ -174,6 +174,7 @@ export function ChartBarRenderer({ } labelFormatter={tooltipLabelFormatter} allowEscapeViewBox={{ x: false, y: true }} + animationDuration={0} /> {/* Zoom selection area - rendered before bars to appear behind them */} diff --git a/apps/webapp/app/components/primitives/charts/ChartLegendCompound.tsx b/apps/webapp/app/components/primitives/charts/ChartLegendCompound.tsx index 6cf3f7d7f24..7fe77d97e81 100644 --- a/apps/webapp/app/components/primitives/charts/ChartLegendCompound.tsx +++ b/apps/webapp/app/components/primitives/charts/ChartLegendCompound.tsx @@ -180,7 +180,7 @@ export function ChartLegendCompound({ )} > {currentTotalLabel} - + {currentTotal != null ? ( valueFormatter ? ( valueFormatter(currentTotal) @@ -253,7 +253,7 @@ export function ChartLegendCompound({ /> @@ -350,7 +350,7 @@ function HoveredHiddenItemRow({ item, value, remainingCount, valueFormatter }: H {item.label} {remainingCount > 0 && +{remainingCount} more}
    - + {value != null ? ( valueFormatter ? ( valueFormatter(value) diff --git a/apps/webapp/app/components/primitives/charts/ChartRoot.tsx b/apps/webapp/app/components/primitives/charts/ChartRoot.tsx index 9a366c9789d..3b2a2c6a3c1 100644 --- a/apps/webapp/app/components/primitives/charts/ChartRoot.tsx +++ b/apps/webapp/app/components/primitives/charts/ChartRoot.tsx @@ -40,6 +40,8 @@ export type ChartRootProps = { onViewAllLegendItems?: () => void; /** When true, constrains legend to max 50% height with scrolling */ legendScrollable?: boolean; + /** Additional className for the legend */ + legendClassName?: string; /** When true, chart fills its parent container height and distributes space between chart and legend */ fillContainer?: boolean; /** Content rendered between the chart and the legend */ @@ -87,6 +89,7 @@ export function ChartRoot({ legendValueFormatter, onViewAllLegendItems, legendScrollable = false, + legendClassName, fillContainer = false, beforeLegend, children, @@ -114,6 +117,7 @@ export function ChartRoot({ legendValueFormatter={legendValueFormatter} onViewAllLegendItems={onViewAllLegendItems} legendScrollable={legendScrollable} + legendClassName={legendClassName} fillContainer={fillContainer} beforeLegend={beforeLegend} > @@ -133,6 +137,7 @@ type ChartRootInnerProps = { legendValueFormatter?: (value: number) => string; onViewAllLegendItems?: () => void; legendScrollable?: boolean; + legendClassName?: string; fillContainer?: boolean; beforeLegend?: React.ReactNode; children: React.ComponentProps["children"]; @@ -148,6 +153,7 @@ function ChartRootInner({ legendValueFormatter, onViewAllLegendItems, legendScrollable = false, + legendClassName, fillContainer = false, beforeLegend, children, @@ -193,6 +199,7 @@ function ChartRootInner({ valueFormatter={legendValueFormatter} onViewAllLegendItems={onViewAllLegendItems} scrollable={legendScrollable} + className={legendClassName} /> )}
    diff --git a/apps/webapp/app/components/runs/v3/EnabledStatus.tsx b/apps/webapp/app/components/runs/v3/EnabledStatus.tsx index 9e1f7163239..ff902147f19 100644 --- a/apps/webapp/app/components/runs/v3/EnabledStatus.tsx +++ b/apps/webapp/app/components/runs/v3/EnabledStatus.tsx @@ -1,4 +1,4 @@ -import { BoltSlashIcon, CheckCircleIcon } from "@heroicons/react/20/solid"; +import { NoSymbolIcon, CheckIcon } from "@heroicons/react/20/solid"; type EnabledStatusProps = { enabled: boolean; @@ -8,8 +8,8 @@ type EnabledStatusProps = { export function EnabledStatus({ enabled, - enabledIcon = CheckCircleIcon, - disabledIcon = BoltSlashIcon, + enabledIcon = CheckIcon, + disabledIcon = NoSymbolIcon, }: EnabledStatusProps) { const EnabledIcon = enabledIcon; const DisabledIcon = disabledIcon; diff --git a/apps/webapp/app/components/runs/v3/RunFilters.tsx b/apps/webapp/app/components/runs/v3/RunFilters.tsx index f643209b8cb..dc3657b42a9 100644 --- a/apps/webapp/app/components/runs/v3/RunFilters.tsx +++ b/apps/webapp/app/components/runs/v3/RunFilters.tsx @@ -1216,7 +1216,7 @@ function AppliedMachinesFilter() { ); } -function VersionsDropdown({ +export function VersionsDropdown({ trigger, clearSearchValue, searchValue, diff --git a/apps/webapp/app/db.server.ts b/apps/webapp/app/db.server.ts index 47b67a1a406..4668b58fb02 100644 --- a/apps/webapp/app/db.server.ts +++ b/apps/webapp/app/db.server.ts @@ -113,6 +113,7 @@ function getClient() { connection_limit: env.DATABASE_CONNECTION_LIMIT.toString(), pool_timeout: env.DATABASE_POOL_TIMEOUT.toString(), connection_timeout: env.DATABASE_CONNECTION_TIMEOUT.toString(), + application_name: env.SERVICE_NAME, }); console.log(`🔌 setting up prisma client to ${redactUrlSecrets(databaseUrl)}`); @@ -236,6 +237,7 @@ function getReplicaClient() { connection_limit: env.DATABASE_CONNECTION_LIMIT.toString(), pool_timeout: env.DATABASE_POOL_TIMEOUT.toString(), connection_timeout: env.DATABASE_CONNECTION_TIMEOUT.toString(), + application_name: env.SERVICE_NAME, }); console.log(`🔌 setting up read replica connection to ${redactUrlSecrets(replicaUrl)}`); diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index a29fe4b9796..8d72b2e51b2 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -349,11 +349,18 @@ const EnvironmentSchema = z .default(60 * 1000 * 15), // 15 minutes OBJECT_STORE_BASE_URL: z.string().optional(), + OBJECT_STORE_BUCKET: z.string().optional(), OBJECT_STORE_ACCESS_KEY_ID: z.string().optional(), OBJECT_STORE_SECRET_ACCESS_KEY: z.string().optional(), OBJECT_STORE_REGION: z.string().optional(), OBJECT_STORE_SERVICE: z.string().default("s3"), + // Protocol to use for new uploads (e.g., "s3", "r2"). Data without protocol uses default provider above. + // If specified, you must configure the corresponding provider using OBJECT_STORE_{PROTOCOL}_* env vars. + // Example: OBJECT_STORE_DEFAULT_PROTOCOL=s3 requires OBJECT_STORE_S3_BASE_URL, OBJECT_STORE_S3_ACCESS_KEY_ID, etc. + // Enables zero-downtime migration between providers (old data keeps working, new data uses new provider). + OBJECT_STORE_DEFAULT_PROTOCOL: z.string().regex(/^[a-z0-9]+$/).optional(), + ARTIFACTS_OBJECT_STORE_BUCKET: z.string().optional(), ARTIFACTS_OBJECT_STORE_BASE_URL: z.string().optional(), ARTIFACTS_OBJECT_STORE_ACCESS_KEY_ID: z.string().optional(), @@ -1230,9 +1237,6 @@ const EnvironmentSchema = z // AI features (Prompts, Models, AI Metrics sidebar section) AI_FEATURES_ENABLED: z.string().default("0"), - // AI Models feature (Models sidebar item within AI section) - AI_MODELS_ENABLED: z.string().default("0"), - // Logs page ClickHouse URL (for logs queries) LOGS_CLICKHOUSE_URL: z .string() diff --git a/apps/webapp/app/hooks/useFuzzyFilter.ts b/apps/webapp/app/hooks/useFuzzyFilter.ts index 1c0f6048268..3f0797179f2 100644 --- a/apps/webapp/app/hooks/useFuzzyFilter.ts +++ b/apps/webapp/app/hooks/useFuzzyFilter.ts @@ -8,7 +8,7 @@ import { matchSorter } from "match-sorter"; * * @param params - The parameters object * @param params.items - Array of objects to filter - * @param params.keys - Array of object keys to perform the fuzzy search on + * @param params.keys - Array of object keys to perform the fuzzy search on (supports dot-notation for nested properties) * @returns An object containing: * - filterText: The current filter text * - setFilterText: Function to update the filter text @@ -28,7 +28,7 @@ export function useFuzzyFilter({ keys, }: { items: T[]; - keys: Extract[]; + keys: (Extract | (string & {}))[]; }) { const [filterText, setFilterText] = useState(""); diff --git a/apps/webapp/app/models/projectAlert.server.ts b/apps/webapp/app/models/projectAlert.server.ts index d2ab0be1d1a..dbcb672ad7d 100644 --- a/apps/webapp/app/models/projectAlert.server.ts +++ b/apps/webapp/app/models/projectAlert.server.ts @@ -32,3 +32,9 @@ export const ProjectAlertSlackStorage = z.object({ }); export type ProjectAlertSlackStorage = z.infer; + +export const ErrorAlertConfig = z.object({ + evaluationIntervalMs: z.number().min(60_000).default(300_000), +}); + +export type ErrorAlertConfig = z.infer; diff --git a/apps/webapp/app/presenters/OrganizationsPresenter.server.ts b/apps/webapp/app/presenters/OrganizationsPresenter.server.ts index 591dba5753c..99ced5e3efb 100644 --- a/apps/webapp/app/presenters/OrganizationsPresenter.server.ts +++ b/apps/webapp/app/presenters/OrganizationsPresenter.server.ts @@ -159,6 +159,7 @@ export class OrganizationsPresenter { // Get global feature flags with env-var-based defaults const globalFlags = await flags({ defaultValues: { + hasAiAccess: env.AI_FEATURES_ENABLED === "1", hasPrivateConnections: env.PRIVATE_CONNECTIONS_ENABLED === "1", }, }); diff --git a/apps/webapp/app/presenters/v3/ApiAlertChannelPresenter.server.ts b/apps/webapp/app/presenters/v3/ApiAlertChannelPresenter.server.ts index 4bc4c776e85..83ab09c177c 100644 --- a/apps/webapp/app/presenters/v3/ApiAlertChannelPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/ApiAlertChannelPresenter.server.ts @@ -17,6 +17,7 @@ export const ApiAlertType = z.enum([ "attempt_failure", "deployment_failure", "deployment_success", + "error_group", ]); export type ApiAlertType = z.infer; @@ -85,6 +86,8 @@ export class ApiAlertChannelPresenter { return "deployment_failure"; case "DEPLOYMENT_SUCCESS": return "deployment_success"; + case "ERROR_GROUP": + return "error_group"; default: assertNever(alertType); } @@ -100,6 +103,8 @@ export class ApiAlertChannelPresenter { return "DEPLOYMENT_FAILURE"; case "deployment_success": return "DEPLOYMENT_SUCCESS"; + case "error_group": + return "ERROR_GROUP"; default: assertNever(alertType); } diff --git a/apps/webapp/app/presenters/v3/ApiRetrieveRunPresenter.server.ts b/apps/webapp/app/presenters/v3/ApiRetrieveRunPresenter.server.ts index 8d1a312c5d7..dc19457cdd1 100644 --- a/apps/webapp/app/presenters/v3/ApiRetrieveRunPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/ApiRetrieveRunPresenter.server.ts @@ -15,7 +15,7 @@ import assertNever from "assert-never"; import { API_VERSIONS, CURRENT_API_VERSION, RunStatusUnspecifiedApiVersion } from "~/api/versions"; import { $replica, prisma } from "~/db.server"; import { AuthenticatedEnvironment } from "~/services/apiAuth.server"; -import { generatePresignedUrl } from "~/v3/r2.server"; +import { generatePresignedUrl } from "~/v3/objectStore.server"; import { tracer } from "~/v3/tracer.server"; import { startSpanWithEnv } from "~/v3/tracing.server"; diff --git a/apps/webapp/app/presenters/v3/ErrorAlertChannelPresenter.server.ts b/apps/webapp/app/presenters/v3/ErrorAlertChannelPresenter.server.ts new file mode 100644 index 00000000000..e2d207555fe --- /dev/null +++ b/apps/webapp/app/presenters/v3/ErrorAlertChannelPresenter.server.ts @@ -0,0 +1,73 @@ +import type { RuntimeEnvironmentType } from "@trigger.dev/database"; +import { + ProjectAlertEmailProperties, + ProjectAlertSlackProperties, + ProjectAlertWebhookProperties, +} from "~/models/projectAlert.server"; +import { BasePresenter } from "./basePresenter.server"; +import { NewAlertChannelPresenter } from "./NewAlertChannelPresenter.server"; +import { env } from "~/env.server"; + +export type ErrorAlertChannelData = Awaited>; + +export class ErrorAlertChannelPresenter extends BasePresenter { + public async call(projectId: string, environmentType: RuntimeEnvironmentType) { + const channels = await this._prisma.projectAlertChannel.findMany({ + where: { + projectId, + alertTypes: { has: "ERROR_GROUP" }, + environmentTypes: { has: environmentType }, + }, + orderBy: { createdAt: "asc" }, + }); + + const emails: Array<{ id: string; email: string }> = []; + const webhooks: Array<{ id: string; url: string }> = []; + let slackChannel: { id: string; channelId: string; channelName: string } | null = null; + + for (const channel of channels) { + switch (channel.type) { + case "EMAIL": { + const parsed = ProjectAlertEmailProperties.safeParse(channel.properties); + if (parsed.success) { + emails.push({ id: channel.id, email: parsed.data.email }); + } + break; + } + case "SLACK": { + if (!channel.enabled) break; + const parsed = ProjectAlertSlackProperties.safeParse(channel.properties); + if (parsed.success) { + slackChannel = { + id: channel.id, + channelId: parsed.data.channelId, + channelName: parsed.data.channelName, + }; + } + break; + } + case "WEBHOOK": { + const parsed = ProjectAlertWebhookProperties.safeParse(channel.properties); + if (parsed.success) { + webhooks.push({ id: channel.id, url: parsed.data.url }); + } + break; + } + } + } + + const slackPresenter = new NewAlertChannelPresenter(this._prisma, this._replica); + const slackResult = await slackPresenter.call(projectId); + + const emailAlertsEnabled = + env.ALERT_FROM_EMAIL !== undefined && env.ALERT_RESEND_API_KEY !== undefined; + + return { + emails, + webhooks, + slackChannel, + slack: slackResult.slack, + emailAlertsEnabled, + }; + } +} diff --git a/apps/webapp/app/presenters/v3/ErrorGroupPresenter.server.ts b/apps/webapp/app/presenters/v3/ErrorGroupPresenter.server.ts index 024ac1e95ea..5e9df362e4c 100644 --- a/apps/webapp/app/presenters/v3/ErrorGroupPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/ErrorGroupPresenter.server.ts @@ -2,7 +2,7 @@ import { z } from "zod"; import { type ClickHouse, msToClickHouseInterval } from "@internal/clickhouse"; import { TimeGranularity } from "~/utils/timeGranularity"; import { ErrorId } from "@trigger.dev/core/v3/isomorphic"; -import { type PrismaClientOrTransaction } from "@trigger.dev/database"; +import { type ErrorGroupStatus, type PrismaClientOrTransaction } from "@trigger.dev/database"; import { timeFilterFromTo } from "~/components/runs/v3/SharedFilters"; import { type Direction, DirectionSchema } from "~/components/ListPagination"; import { findDisplayableEnvironment } from "~/models/runtimeEnvironment.server"; @@ -27,6 +27,7 @@ export type ErrorGroupOptions = { userId?: string; projectId: string; fingerprint: string; + versions?: string[]; runsPageSize?: number; period?: string; from?: number; @@ -39,6 +40,7 @@ export const ErrorGroupOptionsSchema = z.object({ userId: z.string().optional(), projectId: z.string(), fingerprint: z.string(), + versions: z.array(z.string()).optional(), runsPageSize: z.number().int().positive().max(1000).optional(), period: z.string().optional(), from: z.number().int().nonnegative().optional(), @@ -59,6 +61,21 @@ function parseClickHouseDateTime(value: string): Date { return new Date(value.replace(" ", "T") + "Z"); } +export type ErrorGroupState = { + status: ErrorGroupStatus; + resolvedAt: Date | null; + resolvedInVersion: string | null; + resolvedBy: string | null; + ignoredAt: Date | null; + ignoredUntil: Date | null; + ignoredReason: string | null; + ignoredByUserId: string | null; + ignoredByUserDisplayName: string | null; + ignoredUntilOccurrenceRate: number | null; + ignoredUntilTotalOccurrences: number | null; + ignoredAtOccurrenceCount: number | null; +}; + export type ErrorGroupSummary = { fingerprint: string; errorType: string; @@ -68,10 +85,12 @@ export type ErrorGroupSummary = { firstSeen: Date; lastSeen: Date; affectedVersions: string[]; + state: ErrorGroupState; }; export type ErrorGroupOccurrences = Awaited>; export type ErrorGroupActivity = ErrorGroupOccurrences["data"]; +export type ErrorGroupActivityVersions = ErrorGroupOccurrences["versions"]; export class ErrorGroupPresenter extends BasePresenter { constructor( @@ -89,6 +108,7 @@ export class ErrorGroupPresenter extends BasePresenter { userId, projectId, fingerprint, + versions, runsPageSize = DEFAULT_RUNS_PAGE_SIZE, period, from, @@ -110,23 +130,40 @@ export class ErrorGroupPresenter extends BasePresenter { defaultPeriod: "7d", }); - const [summary, affectedVersions, runList] = await Promise.all([ - this.getSummary(organizationId, projectId, environmentId, fingerprint), + const summary = await this.getSummary(organizationId, projectId, environmentId, fingerprint); + + const [affectedVersions, runList, stateRow] = await Promise.all([ this.getAffectedVersions(organizationId, projectId, environmentId, fingerprint), this.getRunList(organizationId, environmentId, { userId, projectId, fingerprint, + versions, pageSize: runsPageSize, from: time.from.getTime(), to: time.to.getTime(), cursor, direction, }), + this.getState(environmentId, summary?.taskIdentifier, fingerprint), ]); if (summary) { summary.affectedVersions = affectedVersions; + summary.state = stateRow ?? { + status: "UNRESOLVED", + resolvedAt: null, + resolvedInVersion: null, + resolvedBy: null, + ignoredAt: null, + ignoredUntil: null, + ignoredReason: null, + ignoredByUserId: null, + ignoredByUserDisplayName: null, + ignoredUntilOccurrenceRate: null, + ignoredUntilTotalOccurrences: null, + ignoredAtOccurrenceCount: null, + }; } return { @@ -140,8 +177,8 @@ export class ErrorGroupPresenter extends BasePresenter { } /** - * Returns bucketed occurrence counts for a single fingerprint over a time range. - * Granularity is determined automatically from the range span. + * Returns bucketed occurrence counts for a single fingerprint over a time range, + * grouped by task_version for stacked charts. */ public async getOccurrences( organizationId: string, @@ -149,14 +186,17 @@ export class ErrorGroupPresenter extends BasePresenter { environmentId: string, fingerprint: string, from: Date, - to: Date + to: Date, + versions?: string[] ): Promise<{ - data: Array<{ date: Date; count: number }>; + data: Array>; + versions: string[]; }> { const granularityMs = errorGroupGranularity.getTimeGranularityMs(from, to); const intervalExpr = msToClickHouseInterval(granularityMs); - const queryBuilder = this.logsClickhouse.errors.createOccurrencesQueryBuilder(intervalExpr); + const queryBuilder = + this.logsClickhouse.errors.createOccurrencesByVersionQueryBuilder(intervalExpr); queryBuilder.where("organization_id = {organizationId: String}", { organizationId }); queryBuilder.where("project_id = {projectId: String}", { projectId }); @@ -169,7 +209,11 @@ export class ErrorGroupPresenter extends BasePresenter { toTimeMs: to.getTime(), }); - queryBuilder.groupBy("error_fingerprint, bucket_epoch"); + if (versions && versions.length > 0) { + queryBuilder.where("task_version IN {versions: Array(String)}", { versions }); + } + + queryBuilder.groupBy("error_fingerprint, task_version, bucket_epoch"); queryBuilder.orderBy("bucket_epoch ASC"); const [queryError, records] = await queryBuilder.execute(); @@ -186,17 +230,27 @@ export class ErrorGroupPresenter extends BasePresenter { buckets.push(epoch); } - const byBucket = new Map(); + // Collect distinct versions and index results by (epoch, version) + const versionSet = new Set(); + const byBucketVersion = new Map(); for (const row of records ?? []) { - byBucket.set(row.bucket_epoch, (byBucket.get(row.bucket_epoch) ?? 0) + row.count); + const version = row.task_version || "unknown"; + versionSet.add(version); + const key = `${row.bucket_epoch}:${version}`; + byBucketVersion.set(key, (byBucketVersion.get(key) ?? 0) + row.count); } - return { - data: buckets.map((epoch) => ({ - date: new Date(epoch * 1000), - count: byBucket.get(epoch) ?? 0, - })), - }; + const sortedVersions = sortVersionsDescending([...versionSet]); + + const data = buckets.map((epoch) => { + const point: Record = { date: new Date(epoch * 1000) }; + for (const version of sortedVersions) { + point[version] = byBucketVersion.get(`${epoch}:${version}`) ?? 0; + } + return point; + }); + + return { data, versions: sortedVersions }; } private async getSummary( @@ -235,6 +289,20 @@ export class ErrorGroupPresenter extends BasePresenter { firstSeen: parseClickHouseDateTime(record.first_seen), lastSeen: parseClickHouseDateTime(record.last_seen), affectedVersions: [], + state: { + status: "UNRESOLVED" as const, + resolvedAt: null, + resolvedInVersion: null, + resolvedBy: null, + ignoredAt: null, + ignoredUntil: null, + ignoredReason: null, + ignoredByUserId: null, + ignoredByUserDisplayName: null, + ignoredUntilOccurrenceRate: null, + ignoredUntilTotalOccurrences: null, + ignoredAtOccurrenceCount: null, + }, }; } @@ -268,6 +336,65 @@ export class ErrorGroupPresenter extends BasePresenter { return sortVersionsDescending(versions).slice(0, 5); } + private async getState( + environmentId: string, + taskIdentifier: string | undefined, + fingerprint: string + ): Promise { + const row = await this.replica.errorGroupState.findFirst({ + where: { + environmentId, + ...(taskIdentifier ? { taskIdentifier } : {}), + errorFingerprint: fingerprint, + }, + select: { + status: true, + resolvedAt: true, + resolvedInVersion: true, + resolvedBy: true, + ignoredAt: true, + ignoredUntil: true, + ignoredReason: true, + ignoredByUserId: true, + ignoredUntilOccurrenceRate: true, + ignoredUntilTotalOccurrences: true, + ignoredAtOccurrenceCount: true, + }, + }); + + if (!row) { + return null; + } + + let ignoredByUserDisplayName: string | null = null; + if (row.ignoredByUserId) { + const user = await this.replica.user.findFirst({ + where: { id: row.ignoredByUserId }, + select: { displayName: true, name: true, email: true }, + }); + if (user) { + ignoredByUserDisplayName = user.displayName ?? user.name ?? user.email; + } + } + + return { + status: row.status, + resolvedAt: row.resolvedAt, + resolvedInVersion: row.resolvedInVersion, + resolvedBy: row.resolvedBy, + ignoredAt: row.ignoredAt, + ignoredUntil: row.ignoredUntil, + ignoredReason: row.ignoredReason, + ignoredByUserId: row.ignoredByUserId, + ignoredByUserDisplayName, + ignoredUntilOccurrenceRate: row.ignoredUntilOccurrenceRate, + ignoredUntilTotalOccurrences: row.ignoredUntilTotalOccurrences, + ignoredAtOccurrenceCount: row.ignoredAtOccurrenceCount + ? Number(row.ignoredAtOccurrenceCount) + : null, + }; + } + private async getRunList( organizationId: string, environmentId: string, @@ -275,6 +402,7 @@ export class ErrorGroupPresenter extends BasePresenter { userId?: string; projectId: string; fingerprint: string; + versions?: string[]; pageSize: number; from?: number; to?: number; @@ -289,6 +417,7 @@ export class ErrorGroupPresenter extends BasePresenter { projectId: options.projectId, rootOnly: false, errorId: ErrorId.toFriendlyId(options.fingerprint), + versions: options.versions, pageSize: options.pageSize, from: options.from, to: options.to, diff --git a/apps/webapp/app/presenters/v3/ErrorsListPresenter.server.ts b/apps/webapp/app/presenters/v3/ErrorsListPresenter.server.ts index 89832b28340..13da4ff91f8 100644 --- a/apps/webapp/app/presenters/v3/ErrorsListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/ErrorsListPresenter.server.ts @@ -9,7 +9,7 @@ const errorsListGranularity = new TimeGranularity([ { max: "3 months", granularity: "1w" }, { max: "Infinity", granularity: "30d" }, ]); -import { type PrismaClientOrTransaction } from "@trigger.dev/database"; +import { type ErrorGroupStatus, type PrismaClientOrTransaction } from "@trigger.dev/database"; import { type Direction } from "~/components/ListPagination"; import { timeFilterFromTo } from "~/components/runs/v3/SharedFilters"; import { findDisplayableEnvironment } from "~/models/runtimeEnvironment.server"; @@ -22,6 +22,8 @@ export type ErrorsListOptions = { projectId: string; // filters tasks?: string[]; + versions?: string[]; + statuses?: ErrorGroupStatus[]; period?: string; from?: number; to?: number; @@ -39,6 +41,8 @@ export const ErrorsListOptionsSchema = z.object({ userId: z.string().optional(), projectId: z.string(), tasks: z.array(z.string()).optional(), + versions: z.array(z.string()).optional(), + statuses: z.array(z.enum(["UNRESOLVED", "RESOLVED", "IGNORED"])).optional(), period: z.string().optional(), from: z.number().int().nonnegative().optional(), to: z.number().int().nonnegative().optional(), @@ -88,7 +92,11 @@ function decodeCursor(cursor: string): ErrorGroupCursor | null { } } -function cursorFromRow(row: { occurrence_count: number; error_fingerprint: string; task_identifier: string }): string { +function cursorFromRow(row: { + occurrence_count: number; + error_fingerprint: string; + task_identifier: string; +}): string { return encodeCursor({ occurrenceCount: row.occurrence_count, fingerprint: row.error_fingerprint, @@ -123,6 +131,8 @@ export class ErrorsListPresenter extends BasePresenter { userId, projectId, tasks, + versions, + statuses, period, search, from, @@ -156,20 +166,49 @@ export class ErrorsListPresenter extends BasePresenter { const hasFilters = (tasks !== undefined && tasks.length > 0) || + (versions !== undefined && versions.length > 0) || (search !== undefined && search !== "") || - !time.isDefault; + (statuses !== undefined && statuses.length > 0); const possibleTasksAsync = getAllTaskIdentifiers(this.replica, environmentId); - const [possibleTasks, displayableEnvironment] = await Promise.all([ + // Pre-filter by status: since status lives in Postgres (ErrorGroupState) and the error + // list comes from ClickHouse, we resolve inclusion/exclusion sets upfront so that + // ClickHouse pagination operates on the correctly filtered dataset. + const statusFilterAsync = this.resolveStatusFilter(environmentId, statuses); + + const [possibleTasks, displayableEnvironment, statusFilter] = await Promise.all([ possibleTasksAsync, findDisplayableEnvironment(environmentId, userId), + statusFilterAsync, ]); if (!displayableEnvironment) { throw new ServiceValidationError("No environment found"); } + if (statusFilter.empty) { + return { + errorGroups: [], + pagination: { + next: undefined, + previous: undefined, + }, + filters: { + tasks, + versions, + statuses, + search, + period: time, + from: effectiveFrom, + to: effectiveTo, + hasFilters, + possibleTasks, + wasClampedByRetention, + }, + }; + } + // Query the per-minute error_occurrences_v1 table for time-scoped counts const queryBuilder = this.clickhouse.errors.occurrencesListQueryBuilder(); @@ -189,6 +228,23 @@ export class ErrorsListPresenter extends BasePresenter { queryBuilder.where("task_identifier IN {tasks: Array(String)}", { tasks }); } + if (versions && versions.length > 0) { + queryBuilder.where("task_version IN {versions: Array(String)}", { versions }); + } + + if (statusFilter.includeKeys) { + queryBuilder.where( + "concat(task_identifier, '::', error_fingerprint) IN {statusIncludeKeys: Array(String)}", + { statusIncludeKeys: statusFilter.includeKeys } + ); + } + if (statusFilter.excludeKeys) { + queryBuilder.where( + "concat(task_identifier, '::', error_fingerprint) NOT IN {statusExcludeKeys: Array(String)}", + { statusExcludeKeys: statusFilter.excludeKeys } + ); + } + queryBuilder.groupBy("error_fingerprint, task_identifier"); // Text search via HAVING (operates on aggregated values) @@ -254,15 +310,14 @@ export class ErrorsListPresenter extends BasePresenter { // Fetch global first_seen / last_seen from the errors_v1 summary table const fingerprints = errorGroups.map((e) => e.error_fingerprint); - const globalSummaryMap = await this.getGlobalSummary( - organizationId, - projectId, - environmentId, - fingerprints - ); + const [globalSummaryMap, stateMap] = await Promise.all([ + this.getGlobalSummary(organizationId, projectId, environmentId, fingerprints), + this.getErrorGroupStates(environmentId, errorGroups), + ]); - const transformedErrorGroups = errorGroups.map((error) => { + let transformedErrorGroups = errorGroups.map((error) => { const global = globalSummaryMap.get(error.error_fingerprint); + const state = stateMap.get(`${error.task_identifier}:${error.error_fingerprint}`); return { errorType: error.error_type, errorMessage: error.error_message, @@ -271,6 +326,9 @@ export class ErrorsListPresenter extends BasePresenter { firstSeen: global?.firstSeen ?? new Date(), lastSeen: global?.lastSeen ?? new Date(), count: error.occurrence_count, + status: state?.status ?? "UNRESOLVED", + resolvedAt: state?.resolvedAt ?? null, + ignoredUntil: state?.ignoredUntil ?? null, }; }); @@ -282,6 +340,8 @@ export class ErrorsListPresenter extends BasePresenter { }, filters: { tasks, + versions, + statuses, search, period: time, from: effectiveFrom, @@ -367,6 +427,106 @@ export class ErrorsListPresenter extends BasePresenter { return { data }; } + /** + * Determines which (task, fingerprint) pairs to include or exclude from the ClickHouse + * query based on the requested status filter. Since status lives in Postgres and errors + * live in ClickHouse, we resolve the filter set here so ClickHouse pagination is correct. + * + * - UNRESOLVED is the default (no ErrorGroupState row), so filtering FOR it means + * excluding groups with non-matching explicit statuses. + * - RESOLVED/IGNORED are explicit, so filtering for them means including only matching groups. + */ + private async resolveStatusFilter( + environmentId: string, + statuses?: ErrorGroupStatus[] + ): Promise<{ + includeKeys?: string[]; + excludeKeys?: string[]; + empty: boolean; + }> { + if (!statuses || statuses.length === 0) { + return { empty: false }; + } + + const allStatuses: ErrorGroupStatus[] = ["UNRESOLVED", "RESOLVED", "IGNORED"]; + const excludedStatuses = allStatuses.filter((s) => !statuses.includes(s)); + + if (excludedStatuses.length === 0) { + return { empty: false }; + } + + if (statuses.includes("UNRESOLVED")) { + const excluded = await this.replica.errorGroupState.findMany({ + where: { environmentId, status: { in: excludedStatuses } }, + select: { taskIdentifier: true, errorFingerprint: true }, + }); + if (excluded.length === 0) { + return { empty: false }; + } + return { + excludeKeys: excluded.map((g) => `${g.taskIdentifier}::${g.errorFingerprint}`), + empty: false, + }; + } + + const included = await this.replica.errorGroupState.findMany({ + where: { environmentId, status: { in: statuses } }, + select: { taskIdentifier: true, errorFingerprint: true }, + }); + if (included.length === 0) { + return { empty: true }; + } + return { + includeKeys: included.map((g) => `${g.taskIdentifier}::${g.errorFingerprint}`), + empty: false, + }; + } + + /** + * Batch-fetch ErrorGroupState rows from Postgres for the given ClickHouse error groups. + * Returns a map keyed by `${taskIdentifier}:${errorFingerprint}`. + */ + private async getErrorGroupStates( + environmentId: string, + errorGroups: Array<{ task_identifier: string; error_fingerprint: string }> + ) { + type StateValue = { + status: ErrorGroupStatus; + resolvedAt: Date | null; + ignoredUntil: Date | null; + }; + + const result = new Map(); + if (errorGroups.length === 0) return result; + + const states = await this.replica.errorGroupState.findMany({ + where: { + environmentId, + OR: errorGroups.map((e) => ({ + taskIdentifier: e.task_identifier, + errorFingerprint: e.error_fingerprint, + })), + }, + select: { + taskIdentifier: true, + errorFingerprint: true, + status: true, + resolvedAt: true, + ignoredUntil: true, + }, + }); + + for (const state of states) { + result.set(`${state.taskIdentifier}:${state.errorFingerprint}`, { + status: state.status, + resolvedAt: state.resolvedAt, + ignoredUntil: state.ignoredUntil, + }); + } + + return result; + } + /** * Fetches global first_seen / last_seen for a set of fingerprints from errors_v1. */ diff --git a/apps/webapp/app/presenters/v3/ModelRegistryPresenter.server.ts b/apps/webapp/app/presenters/v3/ModelRegistryPresenter.server.ts index 9096ab67b71..16a0aa75046 100644 --- a/apps/webapp/app/presenters/v3/ModelRegistryPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/ModelRegistryPresenter.server.ts @@ -64,14 +64,12 @@ export type ModelCatalogItem = { description: string | null; contextWindow: number | null; maxOutputTokens: number | null; - capabilities: string[]; + /** Combined capabilities (from DB) and boolean feature flags (from catalog) as slug strings. */ + features: string[]; inputPrice: number | null; outputPrice: number | null; /** When the model was publicly released (from startDate on LlmModel). */ releaseDate: string | null; - supportsStructuredOutput: boolean; - supportsParallelToolCalls: boolean; - supportsStreamingToolCalls: boolean; /** Dated variants of this model (only populated on base models). */ variants: ModelVariant[]; }; @@ -98,6 +96,17 @@ export type ModelDetail = ModelCatalogItem & { }>; }; +function buildFeatures( + capabilities: string[], + catalogEntry: { supportsStructuredOutput: boolean; supportsParallelToolCalls: boolean; supportsStreamingToolCalls: boolean } | undefined +): string[] { + const features = new Set(capabilities); + if (catalogEntry?.supportsStructuredOutput) features.add("structured_output"); + if (catalogEntry?.supportsParallelToolCalls) features.add("parallel_tool_calls"); + if (catalogEntry?.supportsStreamingToolCalls) features.add("streaming_tool_calls"); + return Array.from(features); +} + export type ModelMetricsPoint = { minute: string; callCount: number; @@ -214,13 +223,10 @@ export class ModelRegistryPresenter extends BasePresenter { description: m.description, contextWindow: m.contextWindow, maxOutputTokens: m.maxOutputTokens, - capabilities: m.capabilities, + features: buildFeatures(m.capabilities, catalogEntry), inputPrice: inputPrice ? Number(inputPrice.price) : null, outputPrice: outputPrice ? Number(outputPrice.price) : null, releaseDate: m.startDate ? m.startDate.toISOString().split("T")[0] : null, - supportsStructuredOutput: catalogEntry?.supportsStructuredOutput ?? false, - supportsParallelToolCalls: catalogEntry?.supportsParallelToolCalls ?? false, - supportsStreamingToolCalls: catalogEntry?.supportsStreamingToolCalls ?? false, variants: [], _baseModelName: m.baseModelName, }; @@ -304,7 +310,7 @@ export class ModelRegistryPresenter extends BasePresenter { /** Get a single model with full pricing details. */ async getModelDetail(friendlyId: string): Promise { - const model = await this._replica.llmModel.findUnique({ + const model = await this._replica.llmModel.findFirst({ where: { friendlyId }, include: { pricingTiers: { @@ -331,13 +337,10 @@ export class ModelRegistryPresenter extends BasePresenter { description: model.description, contextWindow: model.contextWindow, maxOutputTokens: model.maxOutputTokens, - capabilities: model.capabilities, + features: buildFeatures(model.capabilities, catalogEntry), inputPrice: inputPrice ? Number(inputPrice.price) : null, outputPrice: outputPrice ? Number(outputPrice.price) : null, releaseDate: model.startDate ? model.startDate.toISOString().split("T")[0] : null, - supportsStructuredOutput: catalogEntry?.supportsStructuredOutput ?? false, - supportsParallelToolCalls: catalogEntry?.supportsParallelToolCalls ?? false, - supportsStreamingToolCalls: catalogEntry?.supportsStreamingToolCalls ?? false, variants: [], matchPattern: model.matchPattern, source: model.source, diff --git a/apps/webapp/app/presenters/v3/NewAlertChannelPresenter.server.ts b/apps/webapp/app/presenters/v3/NewAlertChannelPresenter.server.ts index 08bccc66ef7..bde51bda91f 100644 --- a/apps/webapp/app/presenters/v3/NewAlertChannelPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/NewAlertChannelPresenter.server.ts @@ -20,6 +20,7 @@ export class NewAlertChannelPresenter extends BasePresenter { where: { service: "SLACK", organizationId: project.organizationId, + deletedAt: null, }, orderBy: { createdAt: "desc", diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam._index/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam._index/route.tsx index 5d6a947a424..2cf8b844a9e 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam._index/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam._index/route.tsx @@ -6,16 +6,18 @@ import { ExclamationTriangleIcon, LightBulbIcon, MagnifyingGlassIcon, + XMarkIcon, UserPlusIcon, VideoCameraIcon, } from "@heroicons/react/20/solid"; import { json, type MetaFunction } from "@remix-run/node"; -import { Link, useRevalidator, useSubmit } from "@remix-run/react"; +import { Link, useFetcher, useRevalidator } from "@remix-run/react"; import { type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { DiscordIcon } from "@trigger.dev/companyicons"; import { formatDurationMilliseconds } from "@trigger.dev/core/v3"; import type { TaskRunStatus } from "@trigger.dev/database"; -import { Fragment, Suspense, useEffect, useState } from "react"; +import { Fragment, Suspense, useCallback, useEffect, useRef, useState } from "react"; +import type { PanelHandle } from "react-window-splitter"; import { Bar, BarChart, ResponsiveContainer, Tooltip, type TooltipProps } from "recharts"; import { TypedAwait, typeddefer, useTypedLoaderData } from "remix-typedjson"; import { ExitIcon } from "~/assets/icons/ExitIcon"; @@ -42,9 +44,11 @@ import { Paragraph } from "~/components/primitives/Paragraph"; import { PopoverMenuItem } from "~/components/primitives/Popover"; import * as Property from "~/components/primitives/PropertyTable"; import { + RESIZABLE_PANEL_ANIMATION, ResizableHandle, ResizablePanel, ResizablePanelGroup, + collapsibleHandleClassName, } from "~/components/primitives/Resizable"; import { Spinner } from "~/components/primitives/Spinner"; import { StepNumber } from "~/components/primitives/StepNumber"; @@ -84,6 +88,7 @@ import { uiPreferencesStorage, } from "~/services/preferences/uiPreferences.server"; import { requireUserId } from "~/services/session.server"; +import { motion } from "framer-motion"; import { cn } from "~/utils/cn"; import { docsPath, @@ -192,14 +197,20 @@ export default function Page() { }, [streamedEvents]); // eslint-disable-line react-hooks/exhaustive-deps const [showUsefulLinks, setShowUsefulLinks] = useState(usefulLinksPreference ?? true); + const usefulLinksPanelRef = useRef(null); + const fetcher = useFetcher(); + const fetcherRef = useRef(fetcher); + fetcherRef.current = fetcher; - // Create a submit handler to save the preference - const submit = useSubmit(); - - const handleUsefulLinksToggle = (show: boolean) => { + const toggleUsefulLinks = useCallback((show: boolean) => { setShowUsefulLinks(show); - submit({ showUsefulLinks: show.toString() }, { method: "post" }); - }; + if (show) { + usefulLinksPanelRef.current?.expand(); + } else { + usefulLinksPanelRef.current?.collapse(); + } + fetcherRef.current.submit({ showUsefulLinks: show.toString() }, { method: "post" }); + }, []); return ( @@ -226,27 +237,24 @@ export default function Page() { - +
    {hasTasks ? (
    {tasks.length === 0 ? : null}
    -
    - + setFilterText(e.target.value)} + onChange={setFilterText} + placeholder="Search tasks…" autoFocus /> - {!showUsefulLinks && ( + {!showUsefulLinks && (
    - {hasTasks && showUsefulLinks ? ( - <> - - - handleUsefulLinksToggle(false)} /> - - - ) : null} + + {}} + collapsedSize="0px" + collapseAnimation={RESIZABLE_PANEL_ANIMATION} + > +
    + {hasTasks && ( + toggleUsefulLinks(false)} /> + )} +
    +
    @@ -850,3 +867,54 @@ function FailedToLoadStats() { /> ); } + +function AnimatedSearchField({ + value, + onChange, + placeholder, + autoFocus, +}: { + value: string; + onChange: (value: string) => void; + placeholder?: string; + autoFocus?: boolean; +}) { + const [isFocused, setIsFocused] = useState(false); + + return ( + 0 ? "24rem" : "auto" }} + transition={{ type: "spring", stiffness: 300, damping: 30 }} + className="relative h-6 min-w-52" + > + onChange(e.target.value)} + fullWidth + autoFocus={autoFocus} + className={cn(isFocused && "placeholder:text-text-dimmed/70")} + onFocus={() => setIsFocused(true)} + onBlur={() => setIsFocused(false)} + onKeyDown={(e) => { + if (e.key === "Escape") e.currentTarget.blur(); + }} + icon={} + accessory={ + value.length > 0 ? ( + + ) : undefined + } + /> + + ); +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.alerts.new.connect-to-slack.ts b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.alerts.new.connect-to-slack.ts index 6800ab2ed88..ddd1bf646b7 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.alerts.new.connect-to-slack.ts +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.alerts.new.connect-to-slack.ts @@ -28,6 +28,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) { where: { service: "SLACK", organizationId: project.organizationId, + deletedAt: null, }, }); diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.alerts/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.alerts/route.tsx index 1bedd30d0f9..9b888a43624 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.alerts/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.alerts/route.tsx @@ -63,6 +63,7 @@ import { v3NewProjectAlertPath, v3ProjectAlertsPath, } from "~/utils/pathBuilder"; +import { alertsWorker } from "~/v3/alertsWorker.server"; export const meta: MetaFunction = () => { return [ @@ -156,6 +157,17 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { data: { enabled: true }, }); + if (alertChannel.alertTypes.includes("ERROR_GROUP")) { + await alertsWorker.enqueue({ + id: `evaluateErrorAlerts:${project.id}`, + job: "v3.evaluateErrorAlerts", + payload: { + projectId: project.id, + scheduledAt: Date.now(), + }, + }); + } + return redirectWithSuccessMessage( v3ProjectAlertsPath({ slug: organizationSlug }, { slug: projectParam }, { slug: envParam }), request, @@ -555,8 +567,10 @@ export function alertTypeTitle(alertType: ProjectAlertType): string { return "Deployment failure"; case "DEPLOYMENT_SUCCESS": return "Deployment success"; + case "ERROR_GROUP": + return "Error group"; default: { - assertNever(alertType); + throw new Error(`Unknown alertType: ${alertType}`); } } } diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.batches.$batchParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.batches.$batchParam/route.tsx index 91403f4597d..6f5fc89341f 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.batches.$batchParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.batches.$batchParam/route.tsx @@ -1,4 +1,4 @@ -import { ArrowRightIcon, ExclamationTriangleIcon } from "@heroicons/react/20/solid"; +import { ExclamationTriangleIcon } from "@heroicons/react/20/solid"; import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { tryCatch } from "@trigger.dev/core"; import { motion } from "framer-motion"; @@ -12,17 +12,14 @@ import { DateTime } from "~/components/primitives/DateTime"; import { Header2, Header3 } from "~/components/primitives/Headers"; import { Paragraph } from "~/components/primitives/Paragraph"; import * as Property from "~/components/primitives/PropertyTable"; -import { - BatchStatusCombo, - descriptionForBatchStatus, -} from "~/components/runs/v3/BatchStatus"; +import { BatchStatusCombo, descriptionForBatchStatus } from "~/components/runs/v3/BatchStatus"; import { useAutoRevalidate } from "~/hooks/useAutoRevalidate"; import { useEnvironment } from "~/hooks/useEnvironment"; import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; import { findProjectBySlug } from "~/models/project.server"; import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; -import { BatchPresenter, type BatchPresenterData } from "~/presenters/v3/BatchPresenter.server"; +import { BatchPresenter } from "~/presenters/v3/BatchPresenter.server"; import { requireUserId } from "~/services/session.server"; import { cn } from "~/utils/cn"; import { formatNumber } from "~/utils/numberFormatter"; @@ -35,8 +32,7 @@ const BatchParamSchema = EnvironmentParamSchema.extend({ export const loader = async ({ request, params }: LoaderFunctionArgs) => { const userId = await requireUserId(request); - const { organizationSlug, projectParam, envParam, batchParam } = - BatchParamSchema.parse(params); + const { organizationSlug, projectParam, envParam, batchParam } = BatchParamSchema.parse(params); const project = await findProjectBySlug(organizationSlug, projectParam, userId); if (!project) { @@ -85,7 +81,8 @@ export default function Page() { disabled: batch.hasFinished, }); - const showProgressMeter = batch.isV2 && (batch.status === "PROCESSING" || batch.status === "PARTIAL_FAILED"); + const showProgressMeter = + batch.isV2 && (batch.status === "PROCESSING" || batch.status === "PARTIAL_FAILED"); return (
    @@ -141,9 +138,7 @@ export default function Page() { Version - - {batch.isV2 ? "v2 (Run Engine)" : "v1 (Legacy)"} - + {batch.isV2 ? "v2 (Run Engine)" : "v1 (Legacy)"} Total runs @@ -243,11 +238,11 @@ export default function Page() { {/* Footer */}
    View runs @@ -304,4 +299,3 @@ function BatchProgressMeter({ successCount, failureCount, totalCount }: BatchPro
    ); } - diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.batches/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.batches/route.tsx index a66e85c0f86..eaa040c4081 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.batches/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.batches/route.tsx @@ -1,9 +1,10 @@ -import { ArrowRightIcon, ExclamationCircleIcon } from "@heroicons/react/20/solid"; +import { ExclamationCircleIcon } from "@heroicons/react/20/solid"; import { BookOpenIcon } from "@heroicons/react/24/solid"; -import { type MetaFunction, Outlet, useNavigation, useParams, useLocation } from "@remix-run/react"; +import { type MetaFunction, Outlet, useLocation, useNavigation, useParams } from "@remix-run/react"; import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { formatDuration } from "@trigger.dev/core/v3/utils/durations"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; +import { RunsIcon } from "~/assets/icons/RunsIcon"; import { BatchesNone } from "~/components/BlankStatePanels"; import { ListPagination } from "~/components/ListPagination"; import { AdminDebugTooltip } from "~/components/admin/debugTooltip"; @@ -13,6 +14,8 @@ import { DateTime } from "~/components/primitives/DateTime"; import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader"; import { Paragraph } from "~/components/primitives/Paragraph"; import { + collapsibleHandleClassName, + RESIZABLE_PANEL_ANIMATION, ResizableHandle, ResizablePanel, ResizablePanelGroup, @@ -143,14 +146,25 @@ export default function Page() { />
    - {isShowingInspector && ( - <> - - - - - - )} + + {}} + collapsedSize="0px" + collapseAnimation={RESIZABLE_PANEL_ANIMATION} + > +
    + +
    +
    )} @@ -287,8 +301,14 @@ function BatchActionsCell({ runsPath }: { runsPath: string }) { - View runs + + View runs } /> diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions/route.tsx index f44ce5904dc..a17f3e7d99e 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions/route.tsx @@ -13,9 +13,11 @@ import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/Page import { PaginationControls } from "~/components/primitives/Pagination"; import { Paragraph } from "~/components/primitives/Paragraph"; import { + RESIZABLE_PANEL_ANIMATION, ResizableHandle, ResizablePanel, ResizablePanelGroup, + collapsibleHandleClassName, } from "~/components/primitives/Resizable"; import { Table, @@ -170,14 +172,26 @@ export default function Page() { )}
    - {isShowingInspector && ( - <> - - - - - - )} + + {}} + collapsedSize="0px" + collapseAnimation={RESIZABLE_PANEL_ANIMATION} + > +
    + +
    +
    )} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.custom.$dashboardId/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.custom.$dashboardId/route.tsx index 245f117ffdb..051ea7a8a28 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.custom.$dashboardId/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.custom.$dashboardId/route.tsx @@ -5,7 +5,6 @@ import { Form, useNavigation } from "@remix-run/react"; import { IconChartHistogram, IconEdit, IconTypography } from "@tabler/icons-react"; import { useCallback, useEffect, useState } from "react"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; -import { toast } from "sonner"; import { z } from "zod"; import { defaultChartConfig } from "~/components/code/ChartConfigPanel"; import { Feedback } from "~/components/Feedback"; @@ -33,7 +32,7 @@ import { PopoverVerticalEllipseTrigger, } from "~/components/primitives/Popover"; import { Sheet, SheetContent } from "~/components/primitives/SheetV3"; -import { ToastUI } from "~/components/primitives/Toast"; +import { useToast } from "~/components/primitives/Toast"; import { SimpleTooltip } from "~/components/primitives/Tooltip"; import { QueryEditor, type QueryEditorSaveData } from "~/components/query/QueryEditor"; import { $replica, prisma } from "~/db.server"; @@ -206,7 +205,8 @@ export default function Page() { const widgetActionUrl = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/dashboards/${friendlyId}/widgets`; const layoutActionUrl = widgetActionUrl; - // Handle sync errors by showing a toast + const toast = useToast(); + const handleSyncError = useCallback((error: Error, action: string) => { const actionMessages: Record = { add: "Failed to add widget", @@ -218,15 +218,8 @@ export default function Page() { const message = actionMessages[action] || "Failed to save changes"; - toast.custom((t) => ( - - )); - }, []); + toast.error(`${message}. Your changes may not be saved.`, { title: "Sync Error" }); + }, [toast]); // Add title dialog state const [showAddTitleDialog, setShowAddTitleDialog] = useState(false); diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route.tsx index a42b39c4573..9dbac88c51a 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route.tsx @@ -42,9 +42,11 @@ import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/Page import { PaginationControls } from "~/components/primitives/Pagination"; import { Paragraph } from "~/components/primitives/Paragraph"; import { + RESIZABLE_PANEL_ANIMATION, ResizableHandle, ResizablePanel, ResizablePanelGroup, + collapsibleHandleClassName, } from "~/components/primitives/Resizable"; import { Table, @@ -255,7 +257,7 @@ export default function Page() {
    - {deployment.shortCode} + {deployment.shortCode} {deployment.label && ( {titleCase(deployment.label)} )} @@ -388,14 +390,26 @@ export default function Page() { )} - {deploymentParam && ( - <> - - - - - - )} + + {}} + collapsedSize="0px" + collapseAnimation={RESIZABLE_PANEL_ANIMATION} + > +
    + +
    +
    @@ -405,8 +419,8 @@ export default function Page() { export function UserTag({ name, avatarUrl }: { name: string; avatarUrl?: string }) { return (
    - - {name} + + {name}
    ); } diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx index 2670f0188df..f7f91f33274 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx @@ -256,7 +256,7 @@ export default function Page() { const { filterText, setFilterText, filteredItems } = useFuzzyFilter({ items: environmentVariables, - keys: ["key", "value"], + keys: ["key", "value", "environment.type", "environment.branchName"], }); // Add isFirst and isLast to each environment variable diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors.$fingerprint/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors.$fingerprint/route.tsx index 0ff8594fa36..f42c73b5ea3 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors.$fingerprint/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors.$fingerprint/route.tsx @@ -1,8 +1,13 @@ -import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; -import { type MetaFunction } from "@remix-run/react"; +import { type LoaderFunctionArgs, type ActionFunctionArgs, json } from "@remix-run/server-runtime"; +import { type MetaFunction, useFetcher, useRevalidator } from "@remix-run/react"; +import { BellAlertIcon } from "@heroicons/react/20/solid"; +import { IconAlarmSnooze as IconAlarmSnoozeBase, IconCircleDotted } from "@tabler/icons-react"; +import { parse } from "@conform-to/zod"; +import { z } from "zod"; +import { ErrorStatusBadge } from "~/components/errors/ErrorStatusBadge"; import { ServiceValidationError } from "~/v3/services/baseService.server"; import { TypedAwait, typeddefer, useTypedLoaderData } from "remix-typedjson"; -import { requireUser } from "~/services/session.server"; +import { requireUser, requireUserId } from "~/services/session.server"; import { EnvironmentParamSchema, v3CreateBulkActionPath, @@ -14,38 +19,69 @@ import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; import { ErrorGroupPresenter, type ErrorGroupActivity, + type ErrorGroupActivityVersions, type ErrorGroupOccurrences, type ErrorGroupSummary, + type ErrorGroupState, } from "~/presenters/v3/ErrorGroupPresenter.server"; import { type NextRunList } from "~/presenters/v3/NextRunListPresenter.server"; import { $replica } from "~/db.server"; import { logsClickhouseClient, clickhouseClient } from "~/services/clickhouseInstance.server"; import { NavBar, PageTitle } from "~/components/primitives/PageHeader"; import { PageBody } from "~/components/layout/AppLayout"; -import { Suspense, useMemo } from "react"; +import { + ResizableHandle, + ResizablePanel, + ResizablePanelGroup, +} from "~/components/primitives/Resizable"; +import { AnimatePresence, motion } from "framer-motion"; +import { Suspense, useEffect, useMemo, useRef, useState } from "react"; import { Spinner } from "~/components/primitives/Spinner"; import { Paragraph } from "~/components/primitives/Paragraph"; import { Callout } from "~/components/primitives/Callout"; -import { Header1, Header2, Header3 } from "~/components/primitives/Headers"; -import { formatDistanceToNow } from "date-fns"; -import { formatNumberCompact } from "~/utils/numberFormatter"; +import { Header2, Header3 } from "~/components/primitives/Headers"; + +import { formatDistanceToNow, isPast } from "date-fns"; + import * as Property from "~/components/primitives/PropertyTable"; import { TaskRunsTable } from "~/components/runs/v3/TaskRunsTable"; import { DateTime, RelativeDateTime } from "~/components/primitives/DateTime"; import { ErrorId } from "@trigger.dev/core/v3/isomorphic"; -import { Chart, type ChartConfig } from "~/components/primitives/charts/ChartCompound"; +import { + Bar, + BarChart, + CartesianGrid, + ResponsiveContainer, + Tooltip, + type TooltipProps, + XAxis, + YAxis, +} from "recharts"; +import TooltipPortal from "~/components/primitives/TooltipPortal"; import { TimeFilter, timeFilterFromTo } from "~/components/runs/v3/SharedFilters"; import { useOptimisticLocation } from "~/hooks/useOptimisticLocation"; import { DirectionSchema, ListPagination } from "~/components/ListPagination"; -import { LinkButton } from "~/components/primitives/Buttons"; +import { Button, LinkButton } from "~/components/primitives/Buttons"; import { ListCheckedIcon } from "~/assets/icons/ListCheckedIcon"; import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; import { useEnvironment } from "~/hooks/useEnvironment"; import { RunsIcon } from "~/assets/icons/RunsIcon"; -import { TaskRunListSearchFilters } from "~/components/runs/v3/RunFilters"; +import type { TaskRunListSearchFilters } from "~/components/runs/v3/RunFilters"; import { useSearchParams } from "~/hooks/useSearchParam"; import { CopyableText } from "~/components/primitives/CopyableText"; +import { cn } from "~/utils/cn"; +import { LogsVersionFilter } from "~/components/logs/LogsVersionFilter"; +import { CodeBlock } from "~/components/code/CodeBlock"; + +import { Popover, PopoverArrowTrigger, PopoverContent } from "~/components/primitives/Popover"; +import { ErrorGroupActions } from "~/v3/services/errorGroupActions.server"; +import { + ErrorStatusMenuItems, + CustomIgnoreDialog, + statusActionToastMessage, +} from "~/components/errors/ErrorStatusMenu"; +import { useToast } from "~/components/primitives/Toast"; export const meta: MetaFunction = ({ data }) => { return [ @@ -55,6 +91,119 @@ export const meta: MetaFunction = ({ data }) => { ]; }; +const emptyStringToUndefined = z.preprocess( + (v) => (v === "" ? undefined : v), + z.coerce.number().positive().optional() +); + +const actionSchema = z.discriminatedUnion("action", [ + z.object({ + action: z.literal("resolve"), + taskIdentifier: z.string().min(1), + resolvedInVersion: z.string().optional(), + }), + z.object({ + action: z.literal("ignore"), + taskIdentifier: z.string().min(1), + duration: emptyStringToUndefined, + occurrenceRate: emptyStringToUndefined, + totalOccurrences: emptyStringToUndefined, + reason: z.preprocess((v) => (v === "" ? undefined : v), z.string().optional()), + }), + z.object({ + action: z.literal("unresolve"), + taskIdentifier: z.string().min(1), + }), +]); + +export const action = async ({ request, params }: ActionFunctionArgs) => { + const userId = await requireUserId(request); + const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params); + const fingerprint = params.fingerprint; + + if (!fingerprint) { + return json({ error: "Fingerprint parameter is required" }, { status: 400 }); + } + + const project = await findProjectBySlug(organizationSlug, projectParam, userId); + if (!project) { + return json({ error: "Project not found" }, { status: 404 }); + } + + const environment = await findEnvironmentBySlug(project.id, envParam, userId); + if (!environment) { + return json({ error: "Environment not found" }, { status: 404 }); + } + + const formData = await request.formData(); + const submission = parse(formData, { schema: actionSchema }); + + if (!submission.value) { + return json(submission); + } + + const actions = new ErrorGroupActions(); + const identifier = { + organizationId: project.organizationId, + projectId: project.id, + environmentId: environment.id, + taskIdentifier: submission.value.taskIdentifier, + errorFingerprint: fingerprint, + }; + + switch (submission.value.action) { + case "resolve": { + await actions.resolveError(identifier, { + userId, + resolvedInVersion: submission.value.resolvedInVersion, + }); + return json({ ok: true }); + } + case "ignore": { + let occurrenceCountAtIgnoreTime: number | undefined; + + if (submission.value.totalOccurrences) { + const qb = clickhouseClient.errors.listQueryBuilder(); + qb.where("organization_id = {organizationId: String}", { + organizationId: project.organizationId, + }); + qb.where("project_id = {projectId: String}", { projectId: project.id }); + qb.where("environment_id = {environmentId: String}", { + environmentId: environment.id, + }); + qb.where("error_fingerprint = {fingerprint: String}", { fingerprint }); + qb.where("task_identifier = {taskIdentifier: String}", { + taskIdentifier: submission.value.taskIdentifier, + }); + qb.groupBy("error_fingerprint, task_identifier"); + + const [err, results] = await qb.execute(); + if (err || !results || results.length === 0) { + return json( + { error: "Failed to fetch current occurrence count. Please try again." }, + { status: 500 } + ); + } + occurrenceCountAtIgnoreTime = results[0].occurrence_count; + } + + await actions.ignoreError(identifier, { + userId, + duration: submission.value.duration, + occurrenceRateThreshold: submission.value.occurrenceRate, + totalOccurrencesThreshold: submission.value.totalOccurrences, + occurrenceCountAtIgnoreTime, + reason: submission.value.reason, + }); + return json({ ok: true }); + } + case "unresolve": { + await actions.unresolveError(identifier); + return json({ ok: true }); + } + } +}; + export const loader = async ({ request, params }: LoaderFunctionArgs) => { const user = await requireUser(request); const userId = user.id; @@ -82,6 +231,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { const toStr = url.searchParams.get("to"); const from = fromStr ? parseInt(fromStr, 10) : undefined; const to = toStr ? parseInt(toStr, 10) : undefined; + const versions = url.searchParams.getAll("versions").filter((v) => v.length > 0); const cursor = url.searchParams.get("cursor") ?? undefined; const directionRaw = url.searchParams.get("direction") ?? undefined; const direction = directionRaw ? DirectionSchema.parse(directionRaw) : undefined; @@ -93,6 +243,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { userId, projectId: project.id, fingerprint, + versions: versions.length > 0 ? versions : undefined, period, from, to, @@ -115,9 +266,10 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { environment.id, fingerprint, time.from, - time.to + time.to, + versions.length > 0 ? versions : undefined ) - .catch(() => ({ data: [] as ErrorGroupActivity })); + .catch(() => ({ data: [] as ErrorGroupActivity, versions: [] as string[] })); return typeddefer({ data: detailPromise, @@ -149,10 +301,19 @@ export default function Page() { if (period) carry.set("period", period); if (from) carry.set("from", from); if (to) carry.set("to", to); + for (const v of searchParams.getAll("versions")) { + if (v) carry.append("versions", v); + } const qs = carry.toString(); return qs ? `${base}?${qs}` : base; }, [organizationSlug, projectParam, envParam, searchParams.toString()]); + const alertsHref = useMemo(() => { + const params = new URLSearchParams(location.search); + params.set("alerts", "true"); + return `?${params.toString()}`; + }, [location.search]); + return ( <> @@ -205,6 +366,7 @@ export default function Page() { projectParam={projectParam} envParam={envParam} fingerprint={fingerprint} + alertsHref={alertsHref} /> ); }} @@ -223,6 +385,7 @@ function ErrorGroupDetail({ projectParam, envParam, fingerprint, + alertsHref, }: { errorGroup: ErrorGroupSummary | undefined; runList: NextRunList | undefined; @@ -231,8 +394,9 @@ function ErrorGroupDetail({ projectParam: string; envParam: string; fingerprint: string; + alertsHref: string; }) { - const { value } = useSearchParams(); + const { value, values } = useSearchParams(); const organization = useOrganization(); const project = useProject(); const environment = useEnvironment(); @@ -252,26 +416,181 @@ function ErrorGroupDetail({ const fromValue = value("from") ?? undefined; const toValue = value("to") ?? undefined; + const selectedVersions = values("versions").filter((v) => v !== ""); const filters: TaskRunListSearchFilters = { period: value("period") ?? undefined, from: fromValue ? parseInt(fromValue, 10) : undefined, to: toValue ? parseInt(toValue, 10) : undefined, + versions: selectedVersions.length > 0 ? selectedVersions : undefined, rootOnly: false, errorId: ErrorId.toFriendlyId(fingerprint), }; return ( -
    - {/* Error Summary */} -
    -
    - {errorGroup.errorMessage} - {formatNumberCompact(errorGroup.count)} total occurrences + + {/* Main content: chart + runs */} + +
    + {/* Activity chart */} +
    +
    + + +
    + + }> + }> + {(result) => { + if (result.data.length > 0 && result.versions.length > 0) { + return ; + } + return ; + }} + + +
    + + {/* Runs Table */} +
    +
    + Runs + {runList && ( +
    + + View all runs + + + Bulk replay… + + +
    + )} +
    + {runList ? ( + 0} + filters={{ + tasks: [], + versions: selectedVersions, + statuses: [], + from: undefined, + to: undefined, + }} + runs={runList.runs} + isLoading={false} + variant="dimmed" + additionalTableState={{ errorId: ErrorId.toFriendlyId(fingerprint) }} + /> + ) : ( + + No runs found for this error. + + )} +
    +
    + + {/* Right-hand detail sidebar */} + + + + +
    + ); +} -
    +function ErrorDetailSidebar({ + errorGroup, + fingerprint, + alertsHref, +}: { + errorGroup: ErrorGroupSummary; + fingerprint: string; + alertsHref: string; +}) { + return ( +
    +
    + Details + + Configure alerts + +
    +
    +
    + {/* Status */} + + Error status + +
    + + +
    + + + {errorGroup.state.status === "IGNORED" && ( + + + + )} + +
    +
    + + {/* Error message */} + + Error + + + + ID @@ -284,9 +603,12 @@ function ErrorGroupDetail({ -
    - - + + Occurrences + + {errorGroup.count.toLocaleString()} + + First seen @@ -299,14 +621,11 @@ function ErrorGroupDetail({ - - - {errorGroup.affectedVersions.length > 0 && ( - Affected versions + Versions - + {errorGroup.affectedVersions.join(", ")} @@ -315,91 +634,170 @@ function ErrorGroupDetail({
    +
    + ); +} - {/* Activity chart */} -
    -
    - -
    +function IgnoredDetails({ + state, + totalOccurrences, + className, +}: { + state: ErrorGroupState; + totalOccurrences: number; + className?: string; +}) { + if (state.status !== "IGNORED") { + return null; + } - }> - }> - {(result) => - result.data.length > 0 ? ( - - ) : ( - - ) - } - - -
    + const hasConditions = + state.ignoredUntil || state.ignoredUntilOccurrenceRate || state.ignoredUntilTotalOccurrences; - {/* Runs Table */} -
    -
    - Runs - {runList && ( -
    - - View all runs - - - Bulk replay… - - -
    - )} + const ignoredForever = !hasConditions; + + const occurrencesSinceIgnore = + state.ignoredUntilTotalOccurrences && state.ignoredAtOccurrenceCount !== null + ? totalOccurrences - state.ignoredAtOccurrenceCount + : null; + + return ( +
    +
    +
    + + + {ignoredForever ? "Ignored permanently" : "Ignored with conditions"} +
    - {runList ? ( - - ) : ( - - No runs found for this error. + {(state.ignoredByUserDisplayName || state.ignoredAt) && ( + + {state.ignoredByUserDisplayName && <>Configured by {state.ignoredByUserDisplayName}} + {state.ignoredByUserDisplayName && state.ignoredAt && " "} + {state.ignoredAt && } )}
    + + {state.ignoredReason && ( + Reason: {state.ignoredReason} + )} + + {hasConditions && ( +
    + {state.ignoredUntil && ( + + Will revert to "Unresolved" at:{" "} + + + + {isPast(state.ignoredUntil) && (expired)} + + )} + {state.ignoredUntilOccurrenceRate !== null && state.ignoredUntilOccurrenceRate > 0 && ( + + Will revert to "Unresolved" when: Occurrence rate exceeds{" "} + + {state.ignoredUntilOccurrenceRate}/min + + + )} + {state.ignoredUntilTotalOccurrences !== null && + state.ignoredUntilTotalOccurrences > 0 && ( + + Will revert to "Unresolved" when: Total occurrences exceed{" "} + + {state.ignoredUntilTotalOccurrences.toLocaleString()} + + {occurrencesSinceIgnore !== null && ( + + ({occurrencesSinceIgnore.toLocaleString()} since ignored) + + )} + + )} +
    + )}
    ); } -const activityChartConfig: ChartConfig = { - count: { - label: "Occurrences", - color: "#6366F1", - }, -}; +function ErrorStatusDropdown({ + state, + taskIdentifier, +}: { + state: ErrorGroupState; + taskIdentifier: string; +}) { + const fetcher = useFetcher<{ ok?: boolean }>(); + const revalidator = useRevalidator(); + const [popoverOpen, setPopoverOpen] = useState(false); + const [customIgnoreOpen, setCustomIgnoreOpen] = useState(false); + const isSubmitting = fetcher.state !== "idle"; + const toast = useToast(); + const pendingToast = useRef(); + + useEffect(() => { + if (fetcher.state === "idle" && fetcher.data?.ok && pendingToast.current) { + toast.success(pendingToast.current); + pendingToast.current = undefined; + revalidator.revalidate(); + } + }, [fetcher.state, fetcher.data, toast, revalidator]); + + const act = (data: Record) => { + setPopoverOpen(false); + pendingToast.current = statusActionToastMessage(data); + fetcher.submit(data, { method: "post" }); + }; + + return ( + <> + + + + Mark error as… + + + { + setPopoverOpen(false); + setCustomIgnoreOpen(true); + }} + /> + + + + + + ); +} + +function ActivityChart({ + activity, + versions, +}: { + activity: ErrorGroupActivity; + versions: ErrorGroupActivityVersions; +}) { + const ERROR_CHART_COLORS = ["#6c5ce7", "#ec4899"]; + const colors = useMemo( + () => versions.map((_, i) => ERROR_CHART_COLORS[i % ERROR_CHART_COLORS.length]), + [versions] + ); -function ActivityChart({ activity }: { activity: ErrorGroupActivity }) { const data = useMemo( () => activity.map((d) => ({ @@ -433,48 +831,91 @@ function ActivityChart({ activity }: { activity: ErrorGroupActivity }) { }; }, []); - const tooltipLabelFormatter = useMemo(() => { - return (_label: string, payload: Array<{ payload?: Record }>) => { - const timestamp = payload[0]?.payload?.__timestamp as number | undefined; - if (timestamp) { - const date = new Date(timestamp); - return date.toLocaleString("en-US", { - month: "short", - day: "numeric", - year: "numeric", - hour: "2-digit", - minute: "2-digit", - hour12: false, - }); - } - return _label; - }; - }, []); - return ( - - - + + + + + dataMax * 1.15]} + /> + } + allowEscapeViewBox={{ x: true, y: true }} + wrapperStyle={{ zIndex: 1000 }} + animationDuration={0} + /> + {versions.map((version, i) => ( + + ))} + + ); } +const ActivityTooltip = ({ + active, + payload, + versions, + colors, +}: TooltipProps & { versions: string[]; colors: string[] }) => { + if (!active || !payload?.length) return null; + + const timestamp = payload[0]?.payload?.__timestamp as number | undefined; + if (!timestamp) return null; + + const date = new Date(timestamp); + const formattedDate = date.toLocaleString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + hour12: false, + }); + + return ( + +
    + {formattedDate} +
    + {payload.map((entry, i) => { + const value = (entry.value as number) ?? 0; + return ( +
    +
    + {entry.dataKey} + {value} +
    + ); + })} +
    +
    + + ); +}; + function ActivityChartBlankState() { return (
    diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors._index/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors._index/route.tsx index 2459a067902..e92b5b34644 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors._index/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors._index/route.tsx @@ -1,8 +1,11 @@ -import { XMarkIcon } from "@heroicons/react/20/solid"; -import { Form, type MetaFunction } from "@remix-run/react"; +import * as Ariakit from "@ariakit/react"; +import { BellAlertIcon, XMarkIcon } from "@heroicons/react/20/solid"; +import { Form, useFetcher, useRevalidator, type MetaFunction } from "@remix-run/react"; import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { IconBugFilled } from "@tabler/icons-react"; import { ErrorId } from "@trigger.dev/core/v3/isomorphic"; -import { Suspense, useMemo } from "react"; +import { type ErrorGroupStatus } from "@trigger.dev/database"; +import { Suspense, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { Bar, BarChart, @@ -13,30 +16,51 @@ import { type TooltipProps, } from "recharts"; import { TypedAwait, typeddefer, useTypedLoaderData } from "remix-typedjson"; +import { ErrorStatusBadge } from "~/components/errors/ErrorStatusBadge"; import { PageBody } from "~/components/layout/AppLayout"; -import { SearchInput } from "~/components/primitives/SearchInput"; +import { ListPagination } from "~/components/ListPagination"; import { LogsTaskFilter } from "~/components/logs/LogsTaskFilter"; -import { Button } from "~/components/primitives/Buttons"; +import { LogsVersionFilter } from "~/components/logs/LogsVersionFilter"; +import { AppliedFilter } from "~/components/primitives/AppliedFilter"; +import { Button, LinkButton } from "~/components/primitives/Buttons"; import { Callout } from "~/components/primitives/Callout"; import { formatDateTime, RelativeDateTime } from "~/components/primitives/DateTime"; import { Header3 } from "~/components/primitives/Headers"; import { NavBar, PageTitle } from "~/components/primitives/PageHeader"; import { Paragraph } from "~/components/primitives/Paragraph"; +import { SearchInput } from "~/components/primitives/SearchInput"; +import { + ComboBox, + SelectItem, + SelectList, + SelectPopover, + SelectProvider, + SelectTrigger, +} from "~/components/primitives/Select"; import { Spinner } from "~/components/primitives/Spinner"; import { CopyableTableCell, Table, TableBody, TableCell, - TableCellChevron, + TableCellMenu, TableHeader, TableHeaderCell, TableRow, } from "~/components/primitives/Table"; +import { PopoverSectionHeader } from "~/components/primitives/Popover"; +import { + ErrorStatusMenuItems, + CustomIgnoreDialog, + statusActionToastMessage, +} from "~/components/errors/ErrorStatusMenu"; +import { useToast } from "~/components/primitives/Toast"; import TooltipPortal from "~/components/primitives/TooltipPortal"; -import { TimeFilter } from "~/components/runs/v3/SharedFilters"; +import { appliedSummary, FilterMenuProvider, TimeFilter } from "~/components/runs/v3/SharedFilters"; import { $replica } from "~/db.server"; +import { useInterval } from "~/hooks/useInterval"; import { useOptimisticLocation } from "~/hooks/useOptimisticLocation"; +import { useSearchParams } from "~/hooks/useSearchParam"; import { findProjectBySlug } from "~/models/project.server"; import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; import { @@ -49,7 +73,6 @@ import { import { logsClickhouseClient } from "~/services/clickhouseInstance.server"; import { getCurrentPlan } from "~/services/platform.v3.server"; import { requireUser } from "~/services/session.server"; -import { ListPagination } from "~/components/ListPagination"; import { formatNumberCompact } from "~/utils/numberFormatter"; import { EnvironmentParamSchema, v3ErrorPath } from "~/utils/pathBuilder"; import { ServiceValidationError } from "~/v3/services/baseService.server"; @@ -80,6 +103,12 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { const url = new URL(request.url); const tasks = url.searchParams.getAll("tasks").filter((t) => t.length > 0); + const versions = url.searchParams.getAll("versions").filter((v) => v.length > 0); + const statuses = url.searchParams + .getAll("status") + .filter( + (s): s is ErrorGroupStatus => s === "UNRESOLVED" || s === "RESOLVED" || s === "IGNORED" + ); const search = url.searchParams.get("search") ?? undefined; const period = url.searchParams.get("period") ?? undefined; const fromStr = url.searchParams.get("from"); @@ -101,6 +130,8 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { userId, projectId: project.id, tasks: tasks.length > 0 ? tasks : undefined, + versions: versions.length > 0 ? versions : undefined, + statuses: statuses.length > 0 ? statuses : undefined, search, period, from, @@ -153,6 +184,24 @@ export default function Page() { envParam, } = useTypedLoaderData(); + const revalidator = useRevalidator(); + useInterval({ + interval: 60_000, + onLoad: false, + callback: useCallback(() => { + if (revalidator.state === "idle") { + revalidator.revalidate(); + } + }, [revalidator]), + }); + + const location = useOptimisticLocation(); + const alertsHref = useMemo(() => { + const params = new URLSearchParams(location.search); + params.set("alerts", "true"); + return `?${params.toString()}`; + }, [location.search]); + return ( <> @@ -177,7 +226,11 @@ export default function Page() { resolve={data} errorElement={
    - +
    Unable to load errors. Please refresh the page or try again in a moment. @@ -193,6 +246,7 @@ export default function Page() {
    @@ -208,6 +262,7 @@ export default function Page() { list={result} defaultPeriod={defaultPeriod} retentionLimitDays={retentionLimitDays} + alertsHref={alertsHref} /> ; +const statusShortcut = { key: "s" }; + +function StatusFilter() { + const { values, del } = useSearchParams(); + const selectedStatuses = values("status"); + + if (selectedStatuses.length === 0 || selectedStatuses.every((v) => v === "")) { + return ( + + {(search, setSearch) => ( + + Status + + } + searchValue={search} + clearSearchValue={() => setSearch("")} + /> + )} + + ); + } + + return ( + + {(search, setSearch) => ( + }> + { + const opt = errorStatusOptions.find((o) => o.value === s); + return opt ? opt.label : s; + }) + )} + onRemove={() => del(["status", "cursor", "direction"])} + variant="secondary/small" + /> + + } + searchValue={search} + clearSearchValue={() => setSearch("")} + /> + )} + + ); +} + +function ErrorStatusDropdown({ + trigger, + clearSearchValue, + onClose, +}: { + trigger: ReactNode; + clearSearchValue: () => void; + searchValue: string; + onClose?: () => void; +}) { + const { values, replace } = useSearchParams(); + + const handleChange = (values: string[]) => { + clearSearchValue(); + replace({ + status: values.length > 0 ? values : undefined, + cursor: undefined, + direction: undefined, + }); + }; + + return ( + + {trigger} + { + if (onClose) { + onClose(); + return false; + } + return true; + }} + > + + {errorStatusOptions.map((item) => ( + + + + ))} + + + + ); +} + function FiltersBar({ list, defaultPeriod, retentionLimitDays, + alertsHref, }: { list?: ErrorsListData; defaultPeriod?: string; retentionLimitDays: number; + alertsHref: string; }) { const location = useOptimisticLocation(); const searchParams = new URLSearchParams(location.search); const hasFilters = + searchParams.has("status") || searchParams.has("tasks") || + searchParams.has("versions") || searchParams.has("search") || searchParams.has("period") || searchParams.has("from") || @@ -246,10 +415,12 @@ function FiltersBar({ return (
    -
    +
    {list ? ( <> + + ) : ( <> + + {hasFilters && ( @@ -283,7 +456,17 @@ function FiltersBar({ )}
    - {list && } +
    + + Configure alerts + + {list && } +
    ); } @@ -303,22 +486,21 @@ function ErrorsList({ }) { if (errorGroups.length === 0) { return ( -
    -
    - No errors found - - No errors have been recorded in the selected time period. - -
    +
    + + + No errors found for this time period. +
    ); } return ( - +
    ID + Status Task Error Occurrences @@ -330,7 +512,7 @@ function ErrorsList({ {errorGroups.map((errorGroup) => ( {errorGroup.fingerprint.slice(-8)} + + + {errorGroup.taskIdentifier} - {errorMessage} + {errorMessage.length > 128 ? `${errorMessage.slice(0, 128)}…` : errorMessage} - {errorGroup.count.toLocaleString()} + + {errorGroup.count.toLocaleString()} + }> }> @@ -403,33 +593,112 @@ function ErrorGroupRow({ - + - + + ); } +function ErrorActionsCell({ + errorGroup, + organizationSlug, + projectParam, + envParam, +}: { + errorGroup: ErrorGroup; + organizationSlug: string; + projectParam: string; + envParam: string; +}) { + const fetcher = useFetcher<{ ok?: boolean }>(); + const revalidator = useRevalidator(); + const [customIgnoreOpen, setCustomIgnoreOpen] = useState(false); + const toast = useToast(); + const pendingToast = useRef(); + + useEffect(() => { + if (fetcher.state === "idle" && fetcher.data?.ok && pendingToast.current) { + toast.success(pendingToast.current); + pendingToast.current = undefined; + revalidator.revalidate(); + } + }, [fetcher.state, fetcher.data, toast, revalidator]); + + const actionUrl = v3ErrorPath( + { slug: organizationSlug }, + { slug: projectParam }, + { slug: envParam }, + { fingerprint: errorGroup.fingerprint } + ); + + return ( + <> + ( + <> + +
    + { + close(); + pendingToast.current = statusActionToastMessage(data); + fetcher.submit(data, { method: "post", action: actionUrl }); + }} + onCustomIgnore={() => { + close(); + setCustomIgnoreOpen(true); + }} + /> +
    + + )} + /> + + + ); +} + function ErrorActivityGraph({ activity }: { activity: ErrorOccurrenceActivity }) { const maxCount = Math.max(...activity.map((d) => d.count)); return (
    -
    +
    } allowEscapeViewBox={{ x: true, y: true }} wrapperStyle={{ zIndex: 1000 }} animationDuration={0} /> - + {maxCount > 0 && ( @@ -470,7 +739,7 @@ const ErrorActivityTooltip = ({ active, payload }: TooltipProps) function ErrorActivityBlankState() { return ( -
    +
    {[...Array(24)].map((_, i) => (
    ))} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors.connect-to-slack.ts b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors.connect-to-slack.ts new file mode 100644 index 00000000000..b8bed6b631d --- /dev/null +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors.connect-to-slack.ts @@ -0,0 +1,48 @@ +import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { prisma } from "~/db.server"; +import { redirectWithSuccessMessage } from "~/models/message.server"; +import { OrgIntegrationRepository } from "~/models/orgIntegration.server"; +import { findProjectBySlug } from "~/models/project.server"; +import { requireUserId } from "~/services/session.server"; +import { + EnvironmentParamSchema, + v3ErrorsPath, + v3ErrorsConnectToSlackPath, +} from "~/utils/pathBuilder"; + +export async function loader({ request, params }: LoaderFunctionArgs) { + const userId = await requireUserId(request); + const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params); + + const url = new URL(request.url); + const shouldReinstall = url.searchParams.get("reinstall") === "true"; + + const project = await findProjectBySlug(organizationSlug, projectParam, userId); + + if (!project) { + throw new Response("Project not found", { status: 404 }); + } + + const integration = await prisma.organizationIntegration.findFirst({ + where: { + service: "SLACK", + organizationId: project.organizationId, + deletedAt: null, + }, + }); + + if (integration && !shouldReinstall) { + return redirectWithSuccessMessage( + `${v3ErrorsPath({ slug: organizationSlug }, project, { slug: envParam })}?alerts`, + request, + "Successfully connected your Slack workspace" + ); + } + + return await OrgIntegrationRepository.redirectToAuthService( + "SLACK", + project.organizationId, + request, + v3ErrorsConnectToSlackPath({ slug: organizationSlug }, project, { slug: envParam }) + ); +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors/route.tsx index f6723ddebaa..dd9a5f6d593 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors/route.tsx @@ -1,10 +1,207 @@ -import { Outlet } from "@remix-run/react"; +import { parse } from "@conform-to/zod"; +import { Outlet, useNavigate } from "@remix-run/react"; +import { type ActionFunctionArgs, type LoaderFunctionArgs, json } from "@remix-run/server-runtime"; +import { useCallback } from "react"; +import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { PageContainer } from "~/components/layout/AppLayout"; +import { + ConfigureErrorAlerts, + ErrorAlertsFormSchema, +} from "~/components/errors/ConfigureErrorAlerts"; +import { Sheet, SheetContent } from "~/components/primitives/SheetV3"; +import { prisma } from "~/db.server"; +import { ErrorAlertChannelPresenter } from "~/presenters/v3/ErrorAlertChannelPresenter.server"; +import { findProjectBySlug } from "~/models/project.server"; +import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; +import { requireUserId } from "~/services/session.server"; +import { env } from "~/env.server"; +import { + EnvironmentParamSchema, + v3ErrorsConnectToSlackPath, + v3ErrorsPath, +} from "~/utils/pathBuilder"; +import { + type CreateAlertChannelOptions, + CreateAlertChannelService, +} from "~/v3/services/alerts/createAlertChannel.server"; +import { useOptimisticLocation } from "~/hooks/useOptimisticLocation"; +import { useSearchParams } from "~/hooks/useSearchParam"; + +export const loader = async ({ request, params }: LoaderFunctionArgs) => { + const userId = await requireUserId(request); + const { projectParam, organizationSlug, envParam } = EnvironmentParamSchema.parse(params); + + const project = await findProjectBySlug(organizationSlug, projectParam, userId); + if (!project) { + throw new Response("Project not found", { status: 404 }); + } + + const environment = await findEnvironmentBySlug(project.id, envParam, userId); + if (!environment) { + throw new Response("Environment not found", { status: 404 }); + } + + const presenter = new ErrorAlertChannelPresenter(); + const alertData = await presenter.call(project.id, environment.type); + + const connectToSlackHref = v3ErrorsConnectToSlackPath({ slug: organizationSlug }, project, { + slug: envParam, + }); + + const errorsPath = v3ErrorsPath({ slug: organizationSlug }, project, { slug: envParam }); + + return typedjson({ + alertData, + projectRef: project.externalRef, + projectId: project.id, + environmentType: environment.type, + connectToSlackHref, + errorsPath, + }); +}; + +export const action = async ({ request, params }: ActionFunctionArgs) => { + const userId = await requireUserId(request); + const { projectParam, organizationSlug, envParam } = EnvironmentParamSchema.parse(params); + + if (request.method.toUpperCase() !== "POST") { + return json({ status: 405, error: "Method Not Allowed" }, { status: 405 }); + } + + const project = await findProjectBySlug(organizationSlug, projectParam, userId); + if (!project) { + return json({ error: "Project not found" }, { status: 404 }); + } + + const environment = await findEnvironmentBySlug(project.id, envParam, userId); + if (!environment) { + return json({ error: "Environment not found" }, { status: 404 }); + } + + const formData = await request.formData(); + const submission = parse(formData, { schema: ErrorAlertsFormSchema }); + + if (!submission.value) { + return json(submission); + } + + const { emails, webhooks, slackChannel, slackIntegrationId } = submission.value; + + const emailEnabled = env.ALERT_FROM_EMAIL !== undefined && env.ALERT_RESEND_API_KEY !== undefined; + const slackEnabled = !!slackIntegrationId; + + const existingChannels = await prisma.projectAlertChannel.findMany({ + where: { + projectId: project.id, + alertTypes: { has: "ERROR_GROUP" }, + environmentTypes: { has: environment.type }, + }, + }); + + const service = new CreateAlertChannelService(); + const environmentTypes = [environment.type]; + const processedChannelIds = new Set(); + + if (emailEnabled) { + for (const email of emails) { + const options: CreateAlertChannelOptions = { + name: `Error alert to ${email}`, + alertTypes: ["ERROR_GROUP"], + environmentTypes, + deduplicationKey: `error-email:${email}:${environment.type}`, + channel: { type: "EMAIL", email }, + }; + const channel = await service.call(project.externalRef, userId, options); + processedChannelIds.add(channel.id); + } + } + + if (slackEnabled && slackChannel) { + const [channelId, channelName] = slackChannel.split("/"); + if (channelId && channelName) { + const options: CreateAlertChannelOptions = { + name: `Error alert to #${channelName}`, + alertTypes: ["ERROR_GROUP"], + environmentTypes, + deduplicationKey: `error-slack:${environment.type}`, + channel: { + type: "SLACK", + channelId, + channelName, + integrationId: slackIntegrationId, + }, + }; + const channel = await service.call(project.externalRef, userId, options); + processedChannelIds.add(channel.id); + } + } + + for (const url of webhooks) { + const options: CreateAlertChannelOptions = { + name: `Error alert to ${new URL(url).hostname}`, + alertTypes: ["ERROR_GROUP"], + environmentTypes, + deduplicationKey: `error-webhook:${url}:${environment.type}`, + channel: { type: "WEBHOOK", url }, + }; + const channel = await service.call(project.externalRef, userId, options); + processedChannelIds.add(channel.id); + } + + const editableTypes = new Set(["WEBHOOK"]); + if (emailEnabled) { + editableTypes.add("EMAIL"); + } + if (slackEnabled) { + editableTypes.add("SLACK"); + } + + const channelsToDelete = existingChannels.filter( + (ch) => + !processedChannelIds.has(ch.id) && + editableTypes.has(ch.type) && + ch.alertTypes.length === 1 && + ch.alertTypes[0] === "ERROR_GROUP" + ); + + for (const ch of channelsToDelete) { + await prisma.projectAlertChannel.delete({ where: { id: ch.id } }); + } + + return json({ ok: true }); +}; export default function Page() { + const { alertData, connectToSlackHref, errorsPath } = useTypedLoaderData(); + const { has } = useSearchParams(); + const showAlerts = has("alerts") ?? false; + const navigate = useNavigate(); + const location = useOptimisticLocation(); + + const closeAlerts = useCallback(() => { + const params = new URLSearchParams(location.search); + params.delete("alerts"); + const qs = params.toString(); + navigate(qs ? `?${qs}` : location.pathname, { replace: true }); + }, [location.search, location.pathname, navigate]); + return ( + + !open && closeAlerts()}> + e.preventDefault()} + > + + + ); } diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs/route.tsx index 80a5c6ef232..af3cc30a246 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs/route.tsx @@ -32,9 +32,12 @@ import { LogsTaskFilter } from "~/components/logs/LogsTaskFilter"; import { LogsRunIdFilter } from "~/components/logs/LogsRunIdFilter"; import { TimeFilter } from "~/components/runs/v3/SharedFilters"; import { + RESIZABLE_PANEL_ANIMATION, ResizableHandle, ResizablePanel, ResizablePanelGroup, + collapsibleHandleClassName, + useFrozenValue, } from "~/components/primitives/Resizable"; import { Button } from "~/components/primitives/Buttons"; import { FEATURE_FLAG, validateFeatureFlagValue } from "~/v3/featureFlags"; @@ -148,7 +151,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { from, to, defaultPeriod: "1h", - retentionLimitDays + retentionLimitDays, }) .catch((error) => { if (error instanceof ServiceValidationError) { @@ -165,8 +168,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { }; export default function Page() { - const { data, defaultPeriod, retentionLimitDays } = - useTypedLoaderData(); + const { data, defaultPeriod, retentionLimitDays } = useTypedLoaderData(); return ( @@ -192,10 +194,7 @@ export default function Page() { resolve={data} errorElement={
    - +
    Unable to load your logs. Please refresh the page or try again in a moment. @@ -228,10 +227,7 @@ export default function Page() { defaultPeriod={defaultPeriod} retentionLimitDays={retentionLimitDays} /> - +
    ); }} @@ -409,6 +405,11 @@ function LogsList({ return accumulatedLogs.find((log) => log.id === selectedLogId); }, [selectedLogId, accumulatedLogs]); + const frozenLogId = useFrozenValue(selectedLogId); + const frozenLog = useFrozenValue(selectedLog); + const displayLogId = selectedLogId ?? frozenLogId; + const displayLog = selectedLog ?? frozenLog ?? undefined; + const updateUrlWithLog = useCallback((logId: string | undefined) => { const url = new URL(window.location.href); if (logId) { @@ -464,11 +465,21 @@ function LogsList({ onLogSelect={handleLogSelect} /> - {/* Side panel for log details */} - {selectedLogId && ( - <> - - + + {}} + collapsedSize="0px" + collapseAnimation={RESIZABLE_PANEL_ANIMATION} + > +
    + {displayLogId && ( @@ -477,15 +488,15 @@ function LogsList({ } > - - - )} + )} +
    +
    ); } diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.models.$modelId/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.models.$modelId/route.tsx index 5b8fc9170db..7a25f996d4d 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.models.$modelId/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.models.$modelId/route.tsx @@ -42,7 +42,7 @@ import { formatModelPrice, formatTokenCount, formatModelCost, - formatCapability, + formatFeature, formatProviderName, } from "~/utils/modelFormatters"; @@ -112,8 +112,8 @@ type Tab = "overview" | "global" | "usage"; const TAB_CONFIG: { id: Tab; label: string }[] = [ { id: "overview", label: "Overview" }, - { id: "global", label: "Global Metrics" }, - { id: "usage", label: "Your Usage" }, + { id: "usage", label: "Metrics" }, + { id: "global", label: "Global metrics" }, ]; export default function ModelDetailPage() { @@ -312,14 +312,14 @@ function OverviewTab({ )} - {model.capabilities.length > 0 && ( + {model.features.length > 0 && ( - Capabilities + Features
    - {model.capabilities.map((cap) => ( - - {formatCapability(cap)} + {model.features.map((f) => ( + + {formatFeature(f)} ))}
    @@ -425,16 +425,7 @@ function GlobalMetricsTab({ return (
    {/* Big numbers */} -
    -
    - -
    +
    {/* Charts */} -
    -
    - -
    -
    - -
    +
    +
    @@ -523,7 +503,7 @@ function YourUsageTab({
    { return [{ title: "Models | Trigger.dev" }]; @@ -84,32 +117,37 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { const popularModels = await presenter.getPopularModels(sevenDaysAgo, now, 50); const allProviders = catalog.map((g) => g.provider); - const allCapabilities = Array.from( - new Set(catalog.flatMap((g) => g.models.flatMap((m) => m.capabilities))) + const allFeatures = Array.from( + new Set(catalog.flatMap((g) => g.models.flatMap((m) => m.features))) ).sort(); - return typedjson({ catalog, popularModels, allProviders, allCapabilities }); + return typedjson({ + catalog, + popularModels, + allProviders, + allFeatures, + organizationId: project.organizationId, + projectId: project.id, + environmentId: environment.id, + }); }; -// --- Helpers --- - -const FEATURE_OPTIONS = [ - { value: "structuredOutput", label: "Structured Output" }, - { value: "parallelToolCalls", label: "Parallel Tool Calls" }, - { value: "streamingToolCalls", label: "Streaming Tool Calls" }, -] as const; - -type FeatureKey = (typeof FEATURE_OPTIONS)[number]["value"]; +const providerIcons: Record JSX.Element> = { + openai: OpenAIIcon, + anthropic: AnthropicIcon, + google: GeminiIcon, + meta: LlamaIcon, + mistral: MistralIcon, + deepseek: DeepseekIcon, + xai: XAIIcon, + perplexity: PerplexityIcon, + cerebras: CerebrasIcon, + azure: AzureIcon, +}; -function modelMatchesFeature(model: ModelCatalogItem, feature: FeatureKey): boolean { - switch (feature) { - case "structuredOutput": - return model.supportsStructuredOutput; - case "parallelToolCalls": - return model.supportsParallelToolCalls; - case "streamingToolCalls": - return model.supportsStreamingToolCalls; - } +function providerIcon(slug: string) { + const Icon = providerIcons[slug] ?? CubeIcon; + return ; } // --- Filter Components --- @@ -117,245 +155,177 @@ function modelMatchesFeature(model: ModelCatalogItem, feature: FeatureKey): bool function ProviderFilter({ providers }: { providers: string[] }) { const { values, replace, del } = useSearchParams(); const selected = values("providers"); + const hasFilter = selected.length > 0; return ( - <> - replace({ providers: v })}> - - {selected.length === 0 ? ( - - - Provider - - ) : null} - - - - {providers.map((p) => ( - - {formatProviderName(p)} - - ))} - - - - {selected.length > 0 && ( + replace({ providers: v })}> + }> } + label={hasFilter ? "Provider" : undefined} + value={hasFilter ? appliedSummary(selected.map(formatProviderName))! : "Provider"} + valueClassName={hasFilter ? undefined : "text-text-bright"} + removable={hasFilter} onRemove={() => del("providers")} /> - )} - - ); -} - -function CapabilityFilter({ capabilities }: { capabilities: string[] }) { - const { values, replace, del } = useSearchParams(); - const selected = values("capabilities"); - - return ( - <> - replace({ capabilities: v })}> - - {selected.length === 0 ? ( - - - Capability - - ) : null} - - - - {capabilities.map((c) => ( - - {formatCapability(c)} - - ))} - - - - {selected.length > 0 && ( - del("capabilities")} - /> - )} - + + + + {providers.map((p) => ( + + {formatProviderName(p)} + + ))} + + + ); } -function FeaturesFilter() { +function FeaturesFilter({ features }: { features: string[] }) { const { values, replace, del } = useSearchParams(); const selected = values("features"); + const hasFilter = selected.length > 0; return ( - <> - replace({ features: v })}> - - {selected.length === 0 ? ( - - - Features - - ) : null} - - - - {FEATURE_OPTIONS.map((f) => ( - - {f.label} - - ))} - - - - {selected.length > 0 && ( + replace({ features: v })}> + }> FEATURE_OPTIONS.find((f) => f.value === s)?.label ?? s) - )! - } + icon={} + label={hasFilter ? "Features" : undefined} + value={hasFilter ? appliedSummary(selected.map(formatFeature))! : "Features"} + valueClassName={hasFilter ? undefined : "text-text-bright"} + removable={hasFilter} onRemove={() => del("features")} /> - )} - + + + + {features.map((f) => ( + + {formatFeature(f)} + + ))} + + + ); } -// --- Model Card --- +// --- Filters Bar --- -function ModelCard({ - model, - popular, - onToggleCompare, - isSelected, +function FiltersBar({ + allProviders, + allFeatures, + compareSet, + onCompare, + showAllDetails, + onToggleAllDetails, }: { - model: ModelCatalogItem; - popular?: PopularModel; - onToggleCompare: (modelName: string) => void; - isSelected: boolean; + allProviders: string[]; + allFeatures: string[]; + compareSet: Set; + onCompare: () => void; + showAllDetails: boolean; + onToggleAllDetails: (checked: boolean) => void; }) { - const organization = useOrganization(); - const project = useProject(); - const environment = useEnvironment(); - const detailPath = v3ModelDetailPath(organization, project, environment, model.friendlyId); - - return ( -
    -
    e.stopPropagation()}> - onToggleCompare(model.modelName)} - title="Select for comparison" - /> -
    + const location = useOptimisticLocation(); + const searchParams = new URLSearchParams(location.search); + const hasFilters = + searchParams.has("providers") || searchParams.has("features") || searchParams.has("search"); - - {model.displayId} - - - {model.description && ( -

    {model.description}

    - )} + const compareDisabled = compareSet.size < 2; -
    - - {formatModelPrice(model.inputPrice)}/1M in - - - {formatModelPrice(model.outputPrice)}/1M out - - {model.contextWindow && ( - {formatTokenCount(model.contextWindow)} ctx + return ( +
    +
    + + + + {hasFilters && ( +
    +
    - - {model.capabilities.length > 0 && ( -
    - {model.capabilities.map((cap) => ( - - {formatCapability(cap)} - - ))} -
    - )} - -
    - {popular && popular.callCount > 0 && ( - {formatNumberCompact(popular.callCount)} calls (7d) - )} - {popular && popular.ttfcP50 > 0 && ( - {popular.ttfcP50.toFixed(0)}ms TTFC - )} +
    + +
    - - {model.variants.length > 0 && }
    ); } -function VariantsDropdown({ variants }: { variants: ModelCatalogItem["variants"] }) { - const [isOpen, setIsOpen] = useState(false); - const organization = useOrganization(); - const project = useProject(); - const environment = useEnvironment(); +// --- Models Table --- +function BooleanCell({ value, onClick }: { value: boolean; onClick: () => void }) { return ( -
    - - {isOpen && ( -
    - {variants.map((v) => ( - - {v.modelName} - {v.releaseDate && ( - {v.releaseDate} - )} - - ))} -
    - )} -
    + + {value ? ( + + ) : null} + ); } -// --- Models Table --- - -function ModelsTable({ +function ModelsList({ models, popularMap, compareSet, onToggleCompare, + showAllDetails, + allFeatures, + selectedModelId, + onSelectModel, }: { models: ModelCatalogItem[]; popularMap: Map; compareSet: Set; onToggleCompare: (modelName: string) => void; + showAllDetails: boolean; + allFeatures: string[]; + selectedModelId: string | null; + onSelectModel: (model: ModelCatalogItem) => void; }) { - const organization = useOrganization(); - const project = useProject(); - const environment = useEnvironment(); + if (models.length === 0) { + return ( +
    +

    No models match your filters.

    +
    + ); + } return ( -
    +
    @@ -364,43 +334,71 @@ function ModelsTable({ Input $/1M Output $/1M Context + {showAllDetails && ( + <> + Max output + Release date + {allFeatures.map((f) => ( + + {formatFeature(f)} + + ))} + + )} p50 TTFC - Calls (7d) {models.map((model) => { - const path = v3ModelDetailPath(organization, project, environment, model.friendlyId); const popular = popularMap.get(model.modelName); + const select = () => onSelectModel(model); return ( - + onToggleCompare(model.modelName)} + disabled={compareSet.size >= 4 && !compareSet.has(model.modelName)} /> - - {model.displayId} + + {model.displayId} + + + + {providerIcon(model.provider)} + {formatProviderName(model.provider)} + - {formatProviderName(model.provider)} - + {formatModelPrice(model.inputPrice)} - + {formatModelPrice(model.outputPrice)} - + {formatTokenCount(model.contextWindow)} - + {showAllDetails && ( + <> + + {formatTokenCount(model.maxOutputTokens)} + + + {model.releaseDate ? ( + + ) : ( + "—" + )} + + {allFeatures.map((f) => ( + + ))} + + )} + {popular && popular.ttfcP50 > 0 ? `${popular.ttfcP50.toFixed(0)}ms` : "—"} - - {popular && popular.callCount > 0 - ? formatNumberCompact(popular.callCount) - : "—"} - ); })} @@ -409,30 +407,673 @@ function ModelsTable({ ); } -// --- Main Page --- +// --- Compare Dialog --- -export default function ModelsPage() { - const { catalog, popularModels, allProviders, allCapabilities } = - useTypedLoaderData(); +type ComparisonRow = { + label: string; + values: React.ReactNode[]; + bestIndex?: number; +}; + +function buildComparisonRows( + models: string[], + catalogModels: ModelCatalogItem[], + comparison: ModelComparisonItem[] +): ComparisonRow[] { + const catalogMap = new Map(); + for (const item of catalogModels) { + catalogMap.set(item.modelName, item); + } + + const dataMap = new Map(); + for (const item of comparison) { + dataMap.set(item.responseModel, item); + } + + const allFeatures = Array.from( + new Set(models.flatMap((m) => catalogMap.get(m)?.features ?? [])) + ).sort(); + + const getCatalog = (model: string) => catalogMap.get(model); + const getMetric = (model: string, key: keyof ModelComparisonItem) => { + const d = dataMap.get(model); + return d ? d[key] : 0; + }; + + const findBest = (values: number[], lowerIsBetter: boolean) => { + if (values.every((v) => v === 0)) return undefined; + const filtered = values.map((v, i) => ({ v, i })).filter(({ v }) => v > 0); + if (filtered.length === 0) return undefined; + const best = lowerIsBetter + ? filtered.reduce((a, b) => (a.v < b.v ? a : b)) + : filtered.reduce((a, b) => (a.v > b.v ? a : b)); + return best.i; + }; + + const inputPrices = models.map((m) => getCatalog(m)?.inputPrice ?? 0); + const outputPrices = models.map((m) => getCatalog(m)?.outputPrice ?? 0); + const contextWindows = models.map((m) => getCatalog(m)?.contextWindow ?? 0); + const maxOutputs = models.map((m) => getCatalog(m)?.maxOutputTokens ?? 0); + const callValues = models.map((m) => Number(getMetric(m, "callCount"))); + const ttfcP50Values = models.map((m) => Number(getMetric(m, "ttfcP50"))); + const ttfcP90Values = models.map((m) => Number(getMetric(m, "ttfcP90"))); + const tpsP50Values = models.map((m) => Number(getMetric(m, "tpsP50"))); + const tpsP90Values = models.map((m) => Number(getMetric(m, "tpsP90"))); + const costValues = models.map((m) => Number(getMetric(m, "totalCost"))); + + return [ + { + label: "Provider", + values: models.map((m) => { + const c = getCatalog(m); + const slug = c?.provider ?? dataMap.get(m)?.genAiSystem; + if (!slug) return "—"; + return ( + + {providerIcon(slug)} + {formatProviderName(slug)} + + ); + }), + }, + { + label: "Input $/1M", + values: models.map((m) => formatModelPrice(getCatalog(m)?.inputPrice ?? null)), + bestIndex: findBest(inputPrices, true), + }, + { + label: "Output $/1M", + values: models.map((m) => formatModelPrice(getCatalog(m)?.outputPrice ?? null)), + bestIndex: findBest(outputPrices, true), + }, + { + label: "Context window", + values: models.map((m) => formatTokenCount(getCatalog(m)?.contextWindow ?? null)), + bestIndex: findBest(contextWindows, false), + }, + { + label: "Max output", + values: models.map((m) => formatTokenCount(getCatalog(m)?.maxOutputTokens ?? null)), + bestIndex: findBest(maxOutputs, false), + }, + { + label: "Release date", + values: models.map((m) => { + const c = getCatalog(m); + return c?.releaseDate + ? new Date(c.releaseDate).toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + }) + : "—"; + }), + }, + ...allFeatures.map((feature) => ({ + label: formatFeature(feature), + values: models.map((m) => + getCatalog(m)?.features.includes(feature) ? ( + + ) : ( + "—" + ) + ), + })), + { + label: "Total calls (7d)", + values: callValues.map((v) => formatNumberCompact(v)), + bestIndex: findBest(callValues, false), + }, + { + label: "p50 TTFC", + values: ttfcP50Values.map((v) => (v > 0 ? `${v.toFixed(0)}ms` : "—")), + bestIndex: findBest(ttfcP50Values, true), + }, + { + label: "p90 TTFC", + values: ttfcP90Values.map((v) => (v > 0 ? `${v.toFixed(0)}ms` : "—")), + bestIndex: findBest(ttfcP90Values, true), + }, + { + label: "Tokens/sec (p50)", + values: tpsP50Values.map((v) => (v > 0 ? v.toFixed(0) : "—")), + bestIndex: findBest(tpsP50Values, false), + }, + { + label: "Tokens/sec (p90)", + values: tpsP90Values.map((v) => (v > 0 ? v.toFixed(0) : "—")), + bestIndex: findBest(tpsP90Values, false), + }, + { + label: "Total cost (7d)", + values: costValues.map((v) => (v > 0 ? formatModelCost(v) : "—")), + bestIndex: findBest(costValues, true), + }, + ]; +} + +function CompareDialog({ + open, + onOpenChange, + models, + catalogModels, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + models: string[]; + catalogModels: ModelCatalogItem[]; +}) { const organization = useOrganization(); const project = useProject(); const environment = useEnvironment(); - const navigate = useNavigate(); - const searchParams = useSearchParams(); - - const view = searchParams.value("view") ?? "cards"; - const search = searchParams.value("search") ?? ""; - const selectedProviders = searchParams.values("providers"); - const selectedCapabilities = searchParams.values("capabilities"); - const selectedFeatures = searchParams.values("features") as FeatureKey[]; + const fetcher = useFetcher(); + + const comparison = (fetcher.data as { comparison?: ModelComparisonItem[] } | undefined) + ?.comparison; + const rows = useMemo( + () => buildComparisonRows(models, catalogModels, comparison ?? []), + [comparison, models, catalogModels] + ); + + // eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally only fires on open; other deps are stable per dialog mount + useEffect(() => { + if (open && models.length >= 2) { + const params = models.join(","); + fetcher.load(`${v3ModelComparePath(organization, project, environment)}?models=${params}`); + } + }, [open]); + + return ( + + + + Compare models + + {rows.length > 0 ? ( +
    +
    + + + Metric + {models.map((model) => ( + + {model} + + ))} + + + + {rows.map((row) => ( + + + + {row.label} + + + {row.values.map((value, i) => ( + +
    + {value} +
    +
    + ))} +
    + ))} +
    +
    +
    + ) : ( +
    + No comparison data available for these models. +
    + )} + + + ); +} + +// --- Model Detail Panel --- + +function escapeTSQL(value: string): string { + return value.replace(/'/g, "''"); +} + +function bignumberConfig( + column: string, + opts?: { aggregation?: "sum" | "avg" | "first"; suffix?: string; abbreviate?: boolean } +): QueryWidgetConfig { + return { + type: "bignumber", + column, + aggregation: opts?.aggregation ?? "sum", + abbreviate: opts?.abbreviate ?? false, + suffix: opts?.suffix, + }; +} + +function chartConfig(opts: { + chartType: "bar" | "line"; + xAxisColumn: string; + yAxisColumns: string[]; + aggregation?: "sum" | "avg"; +}): QueryWidgetConfig { + return { + type: "chart", + chartType: opts.chartType, + xAxisColumn: opts.xAxisColumn, + yAxisColumns: opts.yAxisColumns, + groupByColumn: null, + stacked: false, + sortByColumn: null, + sortDirection: "asc", + aggregation: opts.aggregation ?? "sum", + }; +} + +type DetailTab = "overview" | "global" | "usage"; + +function ModelDetailPanel({ + model, + organizationId, + projectId, + environmentId, + onClose, +}: { + model: ModelCatalogItem; + organizationId: string; + projectId: string; + environmentId: string; + onClose: () => void; +}) { + const [tab, setTab] = useState("overview"); + + return ( +
    +
    + {model.displayId} +
    +
    + + setTab("overview")} + shortcut={{ key: "o" }} + > + Overview + + setTab("usage")} + shortcut={{ key: "u" }} + > + Metrics + + setTab("global")} + shortcut={{ key: "g" }} + > + Global metrics + + +
    +
    + {tab === "overview" && } + {tab === "global" && ( + + )} + {tab === "usage" && ( + + )} +
    +
    + ); +} + +function DetailOverviewTab({ model }: { model: ModelCatalogItem }) { + return ( +
    + + + Provider + {formatProviderName(model.provider)} + + + Model name + + {model.modelName} + + + {model.description && ( + + Description + {model.description} + + )} + + Input price + + {formatModelPrice(model.inputPrice)} / 1M tokens + + + + Output price + + {formatModelPrice(model.outputPrice)} / 1M tokens + + + {model.contextWindow && ( + + Context window + + {formatTokenCount(model.contextWindow)} tokens + + + )} + {model.maxOutputTokens && ( + + Max output tokens + + {formatTokenCount(model.maxOutputTokens)} tokens + + + )} + {model.releaseDate && ( + + Release date + + + + + )} + + + {model.features.length > 0 && ( + + + Features + +
    + {model.features.map((f) => ( +
    + + {formatFeature(f)} +
    + ))} +
    +
    +
    +
    + )} + + {model.variants.length > 0 && ( + <> + Variants + + {model.variants.map((v) => ( + + {v.displayId} + + {v.releaseDate ? : "—"} + + + ))} + + + )} +
    + ); +} + +function DetailGlobalMetricsTab({ + modelName, + organizationId, + projectId, + environmentId, +}: { + modelName: string; + organizationId: string; + projectId: string; + environmentId: string; +}) { + const widgetProps = { + organizationId, + projectId, + environmentId, + scope: "environment" as const, + period: "7d", + from: null, + to: null, + }; + + return ( +
    +
    + +
    +
    + +
    +
    + +
    + +
    + +
    + + + Aggregated across all Trigger.dev users. No tenant-specific data is exposed. + +
    + ); +} + +function DetailYourUsageTab({ + modelName, + organizationId, + projectId, + environmentId, +}: { + modelName: string; + organizationId: string; + projectId: string; + environmentId: string; +}) { + const widgetProps = { + organizationId, + projectId, + environmentId, + scope: "environment" as const, + period: "7d", + from: null, + to: null, + }; + + return ( +
    +
    + +
    +
    + +
    +
    + 0`} + config={bignumberConfig("avg_ttfc", { aggregation: "avg", suffix: "ms" })} + {...widgetProps} + /> +
    +
    + 0`} + config={bignumberConfig("avg_tps", { aggregation: "avg" })} + {...widgetProps} + /> +
    + +
    + +
    +
    + +
    +
    + +
    +
    + ); +} + +// --- Main Page --- + +export default function ModelsPage() { + const { + catalog, + popularModels, + allProviders, + allFeatures, + organizationId, + projectId, + environmentId, + } = useTypedLoaderData(); + const { values: searchValues, value: searchValue } = useSearchParams(); + + const search = searchValue("search") ?? ""; + const selectedProviders = searchValues("providers"); + const selectedFeatures = searchValues("features"); const [compareSet, setCompareSet] = useState>(new Set()); + const [showAllDetails, setShowAllDetails] = useState(false); + const [compareOpen, setCompareOpen] = useState(false); + const [selectedModel, setSelectedModel] = useState(null); + const frozenModel = useFrozenValue(selectedModel); + const displayModel = selectedModel ?? frozenModel; const popularMap = useMemo(() => { const map = new Map(); for (const m of popularModels) { - // Index by raw response_model map.set(m.responseModel, m); - // Also index by model name without provider prefix (e.g. "openai/gpt-4o" → "gpt-4o") if (m.responseModel.includes("/")) { map.set(m.responseModel.split("/").slice(1).join("/"), m); } @@ -440,33 +1081,17 @@ export default function ModelsPage() { return map; }, [popularModels]); - const filteredCatalog = useMemo(() => { + const filteredModels = useMemo(() => { return catalog - .map((group) => ({ - ...group, - models: group.models.filter((m) => { - if (search && !m.displayId.toLowerCase().includes(search.toLowerCase())) return false; - if (selectedProviders.length > 0 && !selectedProviders.includes(m.provider)) return false; - if ( - selectedCapabilities.length > 0 && - !selectedCapabilities.every((c) => m.capabilities.includes(c)) - ) - return false; - if ( - selectedFeatures.length > 0 && - !selectedFeatures.every((f) => modelMatchesFeature(m, f)) - ) - return false; - return true; - }), - })) - .filter((group) => group.models.length > 0); - }, [catalog, search, selectedProviders, selectedCapabilities, selectedFeatures]); - - const allFilteredModels = useMemo( - () => filteredCatalog.flatMap((g) => g.models), - [filteredCatalog] - ); + .flatMap((group) => group.models) + .filter((m) => { + if (search && !m.displayId.toLowerCase().includes(search.toLowerCase())) return false; + if (selectedProviders.length > 0 && !selectedProviders.includes(m.provider)) return false; + if (selectedFeatures.length > 0 && !selectedFeatures.every((f) => m.features.includes(f))) + return false; + return true; + }); + }, [catalog, search, selectedProviders, selectedFeatures]); const toggleCompare = (modelName: string) => { setCompareSet((prev) => { @@ -480,118 +1105,77 @@ export default function ModelsPage() { }); }; - const hasActiveFilters = - selectedProviders.length > 0 || - selectedCapabilities.length > 0 || - selectedFeatures.length > 0; + const compareModels = useMemo(() => Array.from(compareSet), [compareSet]); + const allModels = useMemo(() => catalog.flatMap((g) => g.models), [catalog]); return ( - -
    -
    - - searchParams.replace({ search: e.target.value || undefined })} - variant="small" - className="pl-8" - fullWidth={false} - /> -
    - -
    - - -
    -
    -
    - - {/* Filter bar */} -
    - - - - {hasActiveFilters && ( - - )} -
    - - {/* Compare bar */} - {compareSet.size >= 2 && ( -
    - {compareSet.size} models selected -
    - - + + + +
    + setCompareOpen(true)} + showAllDetails={showAllDetails} + onToggleAllDetails={(checked) => setShowAllDetails(checked)} + /> +
    -
    - )} - - {view === "cards" ? ( -
    - {filteredCatalog.map((group) => ( -
    - {formatProviderName(group.provider)} -
    - {group.models.map((model) => ( - - ))} -
    -
    - ))} - {filteredCatalog.length === 0 && ( -

    - No models match your filters. -

    - )} -
    - ) : ( - + - )} + { + if (isCollapsed) setSelectedModel(null); + }} + collapsedSize="0px" + collapseAnimation={RESIZABLE_PANEL_ANIMATION} + > +
    + {displayModel && ( + setSelectedModel(null)} + /> + )} +
    +
    + + ); } diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.regions/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.regions/route.tsx index 3484e1378b4..26daa24df34 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.regions/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.regions/route.tsx @@ -262,7 +262,7 @@ export default function Page() { } /> @@ -309,12 +309,9 @@ export default function Page() { security portal or{" "} + get in touch - + } defaultValue="help" /> @@ -345,20 +342,21 @@ function SetDefaultDialog({ Set as default region - + +
    Are you sure you want to set {newDefaultRegion.name} as your new default region? @@ -441,6 +439,7 @@ function SetDefaultDialog({ Runs triggered from now on will execute in "{newDefaultRegion.name}", unless you{" "} override when triggering. +
    - {isShowingBulkActionInspector && ( - <> - - + + {}} + collapsedSize="0px" + collapseAnimation={RESIZABLE_PANEL_ANIMATION} + > +
    + {isShowingBulkActionInspector && ( 0} /> - - - )} + )} +
    +
    ); } diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules/route.tsx index d32f9ecb5b4..769aa10cd75 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules/route.tsx @@ -25,9 +25,11 @@ import { PaginationControls } from "~/components/primitives/Pagination"; import { Paragraph } from "~/components/primitives/Paragraph"; import * as Property from "~/components/primitives/PropertyTable"; import { + RESIZABLE_PANEL_ANIMATION, ResizableHandle, ResizablePanel, ResizablePanelGroup, + collapsibleHandleClassName, } from "~/components/primitives/Resizable"; import { Table, @@ -304,14 +306,25 @@ export default function Page() { )}
    - {(isShowingNewPane || isShowingSchedule) && ( - <> - - - - - - )} + + {}} + collapsedSize="0px" + collapseAnimation={RESIZABLE_PANEL_ANIMATION} + > +
    + +
    +
    diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.tokens/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.tokens/route.tsx index b3233abb858..782f3b132ff 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.tokens/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.tokens/route.tsx @@ -13,9 +13,11 @@ import { DateTime } from "~/components/primitives/DateTime"; import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader"; import { Paragraph } from "~/components/primitives/Paragraph"; import { + RESIZABLE_PANEL_ANIMATION, ResizableHandle, ResizablePanel, ResizablePanelGroup, + collapsibleHandleClassName, } from "~/components/primitives/Resizable"; import { Table, @@ -240,14 +242,25 @@ export default function Page() {
    - {isShowingWaitpoint && ( - <> - - - - - - )} + + {}} + collapsedSize="0px" + collapseAnimation={RESIZABLE_PANEL_ANIMATION} + > +
    + +
    +
    )} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.integrations.slack.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.integrations.slack.tsx index c954a6fe697..ba11cf8f8a1 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.integrations.slack.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.integrations.slack.tsx @@ -1,13 +1,14 @@ -import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node"; -import { json, redirect } from "@remix-run/node"; +import { type ActionFunctionArgs, type LoaderFunctionArgs, json, redirect } from "@remix-run/node"; import { fromPromise } from "neverthrow"; import { Form, useActionData, useNavigation } from "@remix-run/react"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; import { DialogClose } from "@radix-ui/react-dialog"; -import { SlackIcon } from "@trigger.dev/companyicons"; import { TrashIcon } from "@heroicons/react/20/solid"; +import { IconBugFilled } from "@tabler/icons-react"; +import { SlackMonoIcon } from "~/assets/icons/SlackMonoIcon"; import { Button } from "~/components/primitives/Buttons"; +import { DateTime } from "~/components/primitives/DateTime"; import { Dialog, DialogContent, @@ -17,8 +18,14 @@ import { DialogTrigger, } from "~/components/primitives/Dialog"; import { FormButtons } from "~/components/primitives/FormButtons"; -import { Header1 } from "~/components/primitives/Headers"; -import { PageBody, PageContainer } from "~/components/layout/AppLayout"; +import { Header2, Header3 } from "~/components/primitives/Headers"; +import { Hint } from "~/components/primitives/Hint"; +import { + MainHorizontallyCenteredContainer, + PageBody, + PageContainer, +} from "~/components/layout/AppLayout"; +import { NavBar, PageTitle } from "~/components/primitives/PageHeader"; import { Paragraph } from "~/components/primitives/Paragraph"; import { Table, @@ -31,21 +38,9 @@ import { import { EnabledStatus } from "~/components/runs/v3/EnabledStatus"; import { $transaction, prisma } from "~/db.server"; import { requireOrganization } from "~/services/org.server"; -import { OrganizationParamsSchema, organizationSettingsPath } from "~/utils/pathBuilder"; +import { OrganizationParamsSchema, organizationSlackIntegrationPath } from "~/utils/pathBuilder"; import { logger } from "~/services/logger.server"; -function formatDate(date: Date): string { - return new Intl.DateTimeFormat("en-US", { - month: "short", - day: "numeric", - year: "numeric", - hour: "numeric", - minute: "2-digit", - second: "2-digit", - hour12: true, - }).format(date); -} - export const loader = async ({ request, params }: LoaderFunctionArgs) => { const { organizationSlug } = OrganizationParamsSchema.parse(params); const { organization } = await requireOrganization(request, organizationSlug); @@ -183,12 +178,11 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { integrationId: slackIntegration.id, }); - return redirect(organizationSettingsPath({ slug: organizationSlug })); + return redirect(organizationSlackIntegrationPath({ slug: organizationSlug })); }; export default function SlackIntegrationPage() { - const { slackIntegration, alertChannels, teamName } = - useTypedLoaderData(); + const { slackIntegration, alertChannels, teamName } = useTypedLoaderData(); const actionData = useActionData(); const navigation = useNavigation(); const isUninstalling = @@ -197,12 +191,18 @@ export default function SlackIntegrationPage() { if (!slackIntegration) { return ( + + + -
    - No Slack Integration Found - - This organization doesn't have a Slack integration configured. You can connect Slack - when setting up alert channels in your project settings. +
    + + No Slack integration found + + Your organization doesn't have a Slack integration configured. You can connect Slack + when setting up alerts from the{" "} + + Errors page.
    @@ -212,114 +212,131 @@ export default function SlackIntegrationPage() { return ( + + + -
    - Slack Integration - - Manage your organization's Slack integration and connected alert channels. - -
    - - {/* Integration Info Section */} -
    -
    + +
    -

    Integration Details

    -
    +
    + Integration details +
    +
    {teamName && ( -
    - Slack Workspace: {teamName} -
    + + Workspace:{" "} + {teamName} + )} -
    - Installed:{" "} - {formatDate(new Date(slackIntegration.createdAt))} -
    + + Installed:{" "} + + + +
    -
    - - - - - - - Remove Slack Integration - - - This will remove the Slack integration and disable all connected alert channels. - This action cannot be undone. - - - + +
    + + Connected alert channels + ({alertChannels.length}) + + {alertChannels.length === 0 ? ( + + No alert channels are currently connected to this Slack integration. + + ) : ( + + + + Channel + Project + Status + Created + + + + {alertChannels.map((channel) => ( + + {channel.name} + {channel.project.name} + + + + + + + + ))} + +
    + )} +
    + +
    + Danger zone +
    + Remove integration + + This will remove the Slack integration and disable all connected alert channels. + This action cannot be undone. + + {actionData?.error && ( + + {actionData.error} + + )} + + - - } - cancelButton={ - - - - } - /> - -
    - {actionData?.error && ( - - {actionData.error} - - )} + + + + Remove Slack integration + + + This will remove the Slack integration and disable all connected alert + channels. This action cannot be undone. + + + + + + } + cancelButton={ + + + + } + /> + + + } + /> +
    -
    - - {/* Connected Alert Channels Section */} -
    -

    - Connected Alert Channels ({alertChannels.length}) -

    - - {alertChannels.length === 0 ? ( -
    - - No alert channels are currently connected to this Slack integration. - -
    - ) : ( - - - - Channel Name - Project - Status - Created - - - - {alertChannels.map((channel) => ( - - {channel.name} - {channel.project.name} - - - - {formatDate(new Date(channel.createdAt))} - - ))} - -
    - )} -
    + ); diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.private-connections._index/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.private-connections._index/route.tsx index 963969ddffc..7c82333ccc0 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.private-connections._index/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.private-connections._index/route.tsx @@ -265,13 +265,13 @@ export default function Page() { Region: {connection.targetRegion}
    - {connection.endpointDnsName && ( + {connection.endpointIps && connection.endpointIps.length > 0 && (
    - DNS: + IPs: - {connection.endpointDnsName} + {connection.endpointIps.join(", ")} - +
    )} {connection.statusMessage && ( diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug_.projects.new/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug_.projects.new/route.tsx index abe6da43f1d..39cde0cf2d0 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug_.projects.new/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug_.projects.new/route.tsx @@ -361,7 +361,7 @@ export default function Page() { return ( - +
    } diff --git a/apps/webapp/app/routes/_app.orgs.new/route.tsx b/apps/webapp/app/routes/_app.orgs.new/route.tsx index fb072518c54..f4f7f995bf9 100644 --- a/apps/webapp/app/routes/_app.orgs.new/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.new/route.tsx @@ -143,7 +143,7 @@ export default function NewOrganizationPage() { return ( - + } title="Create an Organization" diff --git a/apps/webapp/app/routes/admin.feature-flags.tsx b/apps/webapp/app/routes/admin.feature-flags.tsx index 65e975880cb..8e91bdb0731 100644 --- a/apps/webapp/app/routes/admin.feature-flags.tsx +++ b/apps/webapp/app/routes/admin.feature-flags.tsx @@ -55,7 +55,6 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { // Resolve env-based defaults for locked flags const resolvedDefaults: Record = { - [FEATURE_FLAG.runsListRepository]: "clickhouse", [FEATURE_FLAG.taskEventRepository]: env.EVENT_REPOSITORY_DEFAULT_STORE, }; diff --git a/apps/webapp/app/routes/admin.llm-models.$modelId.tsx b/apps/webapp/app/routes/admin.llm-models.$modelId.tsx index 60c182d2274..7b51067dd0c 100644 --- a/apps/webapp/app/routes/admin.llm-models.$modelId.tsx +++ b/apps/webapp/app/routes/admin.llm-models.$modelId.tsx @@ -312,7 +312,7 @@ export default function AdminLlmModelDetailRoute() { />
    - +
    - + ({})); const stringifiedData = await stringifyIO(body); - const finalData = await conditionallyExportPacket( + const finalData = await processWaitpointCompletionPacket( stringifiedData, - `${waitpointId}/waitpoint/http-callback` + waitpoint.environment, + `${WaitpointId.toFriendlyId(waitpointId)}/http-callback` ); const result = await engine.completeWaitpoint({ diff --git a/apps/webapp/app/routes/api.v1.waitpoints.tokens.$waitpointFriendlyId.complete.ts b/apps/webapp/app/routes/api.v1.waitpoints.tokens.$waitpointFriendlyId.complete.ts index afd68850130..916bfd19864 100644 --- a/apps/webapp/app/routes/api.v1.waitpoints.tokens.$waitpointFriendlyId.complete.ts +++ b/apps/webapp/app/routes/api.v1.waitpoints.tokens.$waitpointFriendlyId.complete.ts @@ -2,7 +2,6 @@ import { json } from "@remix-run/server-runtime"; import { CompleteWaitpointTokenRequestBody, type CompleteWaitpointTokenResponseBody, - conditionallyExportPacket, stringifyIO, } from "@trigger.dev/core/v3"; import { WaitpointId } from "@trigger.dev/core/v3/isomorphic"; @@ -10,6 +9,7 @@ import { z } from "zod"; import { $replica } from "~/db.server"; import { env } from "~/env.server"; import { logger } from "~/services/logger.server"; +import { processWaitpointCompletionPacket } from "~/runEngine/concerns/waitpointCompletionPacket.server"; import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server"; import { engine } from "~/v3/runEngine.server"; @@ -52,9 +52,10 @@ const { action, loader } = createActionApiRoute( } const stringifiedData = await stringifyIO(body.data); - const finalData = await conditionallyExportPacket( + const finalData = await processWaitpointCompletionPacket( stringifiedData, - `${waitpointId}/waitpoint/token` + authentication.environment, + `${WaitpointId.toFriendlyId(waitpointId)}/token` ); const result = await engine.completeWaitpoint({ diff --git a/apps/webapp/app/routes/api.v2.packets.$.ts b/apps/webapp/app/routes/api.v2.packets.$.ts new file mode 100644 index 00000000000..8810dc6005a --- /dev/null +++ b/apps/webapp/app/routes/api.v2.packets.$.ts @@ -0,0 +1,45 @@ +import type { ActionFunctionArgs } from "@remix-run/server-runtime"; +import { json } from "@remix-run/server-runtime"; +import { z } from "zod"; +import { authenticateApiRequest } from "~/services/apiAuth.server"; +import { generatePresignedUrl } from "~/v3/objectStore.server"; + +const ParamsSchema = z.object({ + "*": z.string(), +}); + +/** + * PUT-only presign for packet uploads (SDK offload). Uses OBJECT_STORE_DEFAULT_PROTOCOL for + * unprefixed keys; returns canonical storagePath for IOPacket.data. GET presigns use v1. + */ +export async function action({ request, params }: ActionFunctionArgs) { + if (request.method.toUpperCase() !== "PUT") { + return { status: 405, body: "Method Not Allowed" }; + } + + const authenticationResult = await authenticateApiRequest(request); + + if (!authenticationResult) { + return json({ error: "Invalid or Missing API key" }, { status: 401 }); + } + + const parsedParams = ParamsSchema.parse(params); + const filename = parsedParams["*"]; + + const signed = await generatePresignedUrl( + authenticationResult.environment.project.externalRef, + authenticationResult.environment.slug, + filename, + "PUT" + ); + + if (!signed.success) { + return json({ error: `Failed to generate presigned URL: ${signed.error}` }, { status: 500 }); + } + + if (signed.storagePath === undefined) { + return json({ error: "Failed to resolve storage path for packet upload" }, { status: 500 }); + } + + return json({ presignedUrl: signed.url, storagePath: signed.storagePath }); +} diff --git a/apps/webapp/app/routes/api.v3.batches.$batchId.items.ts b/apps/webapp/app/routes/api.v3.batches.$batchId.items.ts index b3ed1c22422..2d732d1555a 100644 --- a/apps/webapp/app/routes/api.v3.batches.$batchId.items.ts +++ b/apps/webapp/app/routes/api.v3.batches.$batchId.items.ts @@ -104,10 +104,7 @@ export async function action({ request, params }: ActionFunctionArgs) { return json({ error: error.message }, { status: 400 }); } - return json( - { error: error.message }, - { status: 500, headers: { "x-should-retry": "false" } } - ); + return json({ error: error.message }, { status: 500 }); } return json({ error: "Something went wrong" }, { status: 500 }); diff --git a/apps/webapp/app/routes/confirm-basic-details.tsx b/apps/webapp/app/routes/confirm-basic-details.tsx index ed868186552..2c6209b74f0 100644 --- a/apps/webapp/app/routes/confirm-basic-details.tsx +++ b/apps/webapp/app/routes/confirm-basic-details.tsx @@ -241,7 +241,7 @@ export default function Page() { return ( - +
    - +
    } diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx index 3d8d7ed4e31..e0bec66ffb2 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx @@ -32,6 +32,7 @@ import { MachineTooltipInfo } from "~/components/MachineTooltipInfo"; import { Button, LinkButton } from "~/components/primitives/Buttons"; import { Callout } from "~/components/primitives/Callout"; import { CopyableText } from "~/components/primitives/CopyableText"; +import { CopyTextLink } from "~/components/primitives/CopyTextLink"; import { DateTime, DateTimeAccurate } from "~/components/primitives/DateTime"; import { Header2, Header3 } from "~/components/primitives/Headers"; import { Paragraph } from "~/components/primitives/Paragraph"; @@ -263,19 +264,21 @@ function SpanBody({ span.entity?.type === "prompt"; return ( -
    +
    -
    -
    +
    +
    - +
    @@ -311,7 +314,6 @@ function formatSpanDuration(nanoseconds: number): string { return `${mins}m ${secs}s`; } - function applySpanOverrides(span: Span, spanOverrides?: SpanOverride): Span { if (!spanOverrides) { return span; @@ -1259,8 +1261,13 @@ function SpanEntity({ span }: { span: Span }) { )} - Message - {span.message} + + Message + + + + {span.message} + {span.events.length > 0 && } @@ -1416,7 +1423,13 @@ function SpanEntity({ span }: { span: Span }) { @@ -1456,4 +1469,3 @@ function SpanEntity({ span }: { span: Span }) { } } } - diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.$waitpointFriendlyId.complete/route.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.$waitpointFriendlyId.complete/route.tsx index 2544b2ea52c..670aee165c1 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.$waitpointFriendlyId.complete/route.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.$waitpointFriendlyId.complete/route.tsx @@ -2,13 +2,7 @@ import { env } from "~/env.server"; import { parse } from "@conform-to/zod"; import { Form, useLocation, useNavigation, useSubmit } from "@remix-run/react"; import { type ActionFunctionArgs, json } from "@remix-run/server-runtime"; -import { - conditionallyExportPacket, - IOPacket, - stringifyIO, - timeoutError, - WaitpointTokenStatus, -} from "@trigger.dev/core/v3"; +import { stringifyIO, timeoutError, WaitpointTokenStatus } from "@trigger.dev/core/v3"; import { WaitpointId } from "@trigger.dev/core/v3/isomorphic"; import type { Waitpoint } from "@trigger.dev/database"; import { useCallback, useRef } from "react"; @@ -24,6 +18,8 @@ import { $replica } from "~/db.server"; import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server"; +import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; +import { processWaitpointCompletionPacket } from "~/runEngine/concerns/waitpointCompletionPacket.server"; import { logger } from "~/services/logger.server"; import { requireUserId } from "~/services/session.server"; import { EnvironmentParamSchema, ProjectParamSchema, v3RunsPath } from "~/utils/pathBuilder"; @@ -86,6 +82,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { const waitpoint = await $replica.waitpoint.findFirst({ select: { projectId: true, + environmentId: true, }, where: { id: waitpointId, @@ -150,11 +147,29 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { ); } + const environment = await findEnvironmentBySlug(project.id, envParam, userId); + if (!environment) { + return redirectWithErrorMessage( + submission.value.failureRedirect, + request, + "Environment not found" + ); + } + + if (environment.id !== waitpoint.environmentId) { + return redirectWithErrorMessage( + submission.value.failureRedirect, + request, + "No waitpoint found" + ); + } + const data = submission.value.payload ? JSON.parse(submission.value.payload) : {}; const stringifiedData = await stringifyIO(data); - const finalData = await conditionallyExportPacket( + const finalData = await processWaitpointCompletionPacket( stringifiedData, - `${waitpointId}/waitpoint/token` + environment, + `${WaitpointId.toFriendlyId(waitpointId)}/token` ); const result = await engine.completeWaitpoint({ diff --git a/apps/webapp/app/routes/resources.packets.$environmentId.$.ts b/apps/webapp/app/routes/resources.packets.$environmentId.$.ts index 2c3e280a7e5..4269e13d63a 100644 --- a/apps/webapp/app/routes/resources.packets.$environmentId.$.ts +++ b/apps/webapp/app/routes/resources.packets.$environmentId.$.ts @@ -3,7 +3,7 @@ import { basename } from "node:path"; import { z } from "zod"; import { prisma } from "~/db.server"; import { requireUserId } from "~/services/session.server"; -import { generatePresignedRequest } from "~/v3/r2.server"; +import { generatePresignedRequest } from "~/v3/objectStore.server"; const ParamSchema = z.object({ environmentId: z.string(), diff --git a/apps/webapp/app/routes/storybook.animated-panel/route.tsx b/apps/webapp/app/routes/storybook.animated-panel/route.tsx new file mode 100644 index 00000000000..2f136bddb19 --- /dev/null +++ b/apps/webapp/app/routes/storybook.animated-panel/route.tsx @@ -0,0 +1,178 @@ +import { useState } from "react"; +import { ExitIcon } from "~/assets/icons/ExitIcon"; +import { Button } from "~/components/primitives/Buttons"; +import { Header2 } from "~/components/primitives/Headers"; +import { Paragraph } from "~/components/primitives/Paragraph"; +import * as Property from "~/components/primitives/PropertyTable"; +import { + RESIZABLE_PANEL_ANIMATION, + ResizableHandle, + ResizablePanel, + ResizablePanelGroup, + collapsibleHandleClassName, + useFrozenValue, +} from "~/components/primitives/Resizable"; +import { + Table, + TableBody, + TableCell, + TableHeader, + TableHeaderCell, + TableRow, +} from "~/components/primitives/Table"; + +type DemoItem = { + id: string; + name: string; + status: "completed" | "running" | "failed" | "queued"; + duration: string; + task: string; +}; + +const demoItems: DemoItem[] = [ + { id: "run_a1b2c3d4", name: "Process invoices", status: "completed", duration: "2.3s", task: "invoice/process" }, + { id: "run_e5f6g7h8", name: "Send welcome email", status: "running", duration: "0.8s", task: "email/welcome" }, + { id: "run_i9j0k1l2", name: "Generate report", status: "failed", duration: "12.1s", task: "report/generate" }, + { id: "run_m3n4o5p6", name: "Sync inventory", status: "completed", duration: "5.7s", task: "inventory/sync" }, + { id: "run_q7r8s9t0", name: "Resize images", status: "queued", duration: "—", task: "image/resize" }, + { id: "run_u1v2w3x4", name: "Update search index", status: "completed", duration: "1.1s", task: "search/index" }, + { id: "run_y5z6a7b8", name: "Calculate analytics", status: "running", duration: "8.4s", task: "analytics/calc" }, + { id: "run_c9d0e1f2", name: "Deploy preview", status: "completed", duration: "34.2s", task: "deploy/preview" }, + { id: "run_g3h4i5j6", name: "Run migrations", status: "failed", duration: "0.3s", task: "db/migrate" }, + { id: "run_k7l8m9n0", name: "Notify Slack", status: "completed", duration: "0.5s", task: "notify/slack" }, +]; + +const statusColors: Record = { + completed: "text-success", + running: "text-blue-500", + failed: "text-error", + queued: "text-text-dimmed", +}; + +function DetailPanel({ item, onClose }: { item: DemoItem; onClose: () => void }) { + return ( +
    +
    + {item.name} +
    +
    + + + Run ID + {item.id} + + + Task + {item.task} + + + Status + + + {item.status.charAt(0).toUpperCase() + item.status.slice(1)} + + + + + Duration + {item.duration} + + +
    + + This is a demo detail panel showing the animated slide-in/out behavior using + react-window-splitter's collapseAnimation. Click a different row to change the + detail, or press Esc / click the close button to dismiss. + +
    +
    +
    + ); +} + +export default function Story() { + const [selectedItem, setSelectedItem] = useState(null); + const show = !!selectedItem; + const frozenItem = useFrozenValue(selectedItem); + const displayItem = selectedItem ?? frozenItem; + + return ( +
    + + +
    +
    + Runs +
    + + + + Run ID + Name + Task + Status + Duration + + + + {demoItems.map((item) => ( + + setSelectedItem(item)} isTabbableCell> + {item.id} + + setSelectedItem(item)}>{item.name} + setSelectedItem(item)}>{item.task} + setSelectedItem(item)}> + + {item.status.charAt(0).toUpperCase() + item.status.slice(1)} + + + setSelectedItem(item)} alignment="right"> + {item.duration} + + + ))} + +
    +
    +
    + + { + if (isCollapsed) setSelectedItem(null); + }} + collapsedSize="0px" + collapseAnimation={RESIZABLE_PANEL_ANIMATION} + > +
    + {displayItem && ( + setSelectedItem(null)} + /> + )} +
    +
    +
    +
    + ); +} diff --git a/apps/webapp/app/routes/storybook.unordered-list/route.tsx b/apps/webapp/app/routes/storybook.unordered-list/route.tsx new file mode 100644 index 00000000000..b17bb2dda11 --- /dev/null +++ b/apps/webapp/app/routes/storybook.unordered-list/route.tsx @@ -0,0 +1,67 @@ +import { Header2 } from "~/components/primitives/Headers"; +import { Paragraph, type ParagraphVariant } from "~/components/primitives/Paragraph"; +import { UnorderedList } from "~/components/primitives/UnorderedList"; + +const sampleItems = [ + "A new issue is seen for the first time", + "A resolved issue re-occurs", + "An ignored issue re-occurs depending on the settings you configured", +]; + +const variantGroups: { label: string; variants: ParagraphVariant[] }[] = [ + { + label: "Base", + variants: ["base", "base/bright"], + }, + { + label: "Small", + variants: ["small", "small/bright", "small/dimmed"], + }, + { + label: "Extra small", + variants: [ + "extra-small", + "extra-small/bright", + "extra-small/dimmed", + "extra-small/mono", + "extra-small/bright/mono", + "extra-small/dimmed/mono", + "extra-small/caps", + "extra-small/bright/caps", + ], + }, + { + label: "Extra extra small", + variants: [ + "extra-extra-small", + "extra-extra-small/bright", + "extra-extra-small/caps", + "extra-extra-small/bright/caps", + "extra-extra-small/dimmed/caps", + ], + }, +]; + +export default function Story() { + return ( +
    + {variantGroups.map((group) => ( +
    + {group.label} + {group.variants.map((variant) => ( +
    + {variant} + This is a paragraph before the list. + + {sampleItems.map((item) => ( +
  • {item}
  • + ))} +
    + This is a paragraph after the list. +
    + ))} +
    + ))} +
    + ); +} diff --git a/apps/webapp/app/routes/storybook/route.tsx b/apps/webapp/app/routes/storybook/route.tsx index 83d455c2a55..3efa990548c 100644 --- a/apps/webapp/app/routes/storybook/route.tsx +++ b/apps/webapp/app/routes/storybook/route.tsx @@ -8,6 +8,10 @@ import { requireUser } from "~/services/session.server"; import { cn } from "~/utils/cn"; const stories: Story[] = [ + { + name: "Animated panel", + slug: "animated-panel", + }, { name: "Avatar", slug: "avatar", @@ -136,6 +140,10 @@ const stories: Story[] = [ name: "Typography", slug: "typography", }, + { + name: "Unordered list", + slug: "unordered-list", + }, { name: "Usage", slug: "usage", diff --git a/apps/webapp/app/routes/vercel.onboarding.tsx b/apps/webapp/app/routes/vercel.onboarding.tsx index bdd1c1d05ca..1cf351532e9 100644 --- a/apps/webapp/app/routes/vercel.onboarding.tsx +++ b/apps/webapp/app/routes/vercel.onboarding.tsx @@ -293,7 +293,7 @@ export default function VercelOnboardingPage() { return ( - +