diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index 8f9b5274c5..77ae2e8317 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -1028,8 +1028,9 @@ const EnvironmentSchema = z TASK_EVENT_PARTITIONING_ENABLED: z.string().default("0"), TASK_EVENT_PARTITIONED_WINDOW_IN_SECONDS: z.coerce.number().int().default(60), // 1 minute - QUEUE_SSE_AUTORELOAD_INTERVAL_MS: z.coerce.number().int().default(5_000), - QUEUE_SSE_AUTORELOAD_TIMEOUT_MS: z.coerce.number().int().default(60_000), + DEPLOYMENTS_AUTORELOAD_POLL_INTERVAL_MS: z.coerce.number().int().default(5_000), + BULK_ACTION_AUTORELOAD_POLL_INTERVAL_MS: z.coerce.number().int().default(1_000), + QUEUES_AUTORELOAD_POLL_INTERVAL_MS: z.coerce.number().int().default(5_000), SLACK_BOT_TOKEN: z.string().optional(), SLACK_SIGNUP_REASON_CHANNEL_ID: z.string().optional(), diff --git a/apps/webapp/app/hooks/useAutoRevalidate.ts b/apps/webapp/app/hooks/useAutoRevalidate.ts new file mode 100644 index 0000000000..4205b03bcc --- /dev/null +++ b/apps/webapp/app/hooks/useAutoRevalidate.ts @@ -0,0 +1,48 @@ +import { useRevalidator } from "@remix-run/react"; +import { useEffect } from "react"; + +type UseAutoRevalidateOptions = { + interval?: number; // in milliseconds + onFocus?: boolean; + disabled?: boolean; +}; + +export function useAutoRevalidate(options: UseAutoRevalidateOptions = {}) { + const { interval = 5000, onFocus = true, disabled = false } = options; + const revalidator = useRevalidator(); + + useEffect(() => { + if (!interval || interval <= 0 || disabled) return; + + const intervalId = setInterval(() => { + if (revalidator.state === "loading") { + return; + } + revalidator.revalidate(); + }, interval); + + return () => clearInterval(intervalId); + }, [interval, disabled]); + + useEffect(() => { + if (!onFocus || disabled) return; + + const handleFocus = () => { + if (document.visibilityState === "visible" && revalidator.state !== "loading") { + revalidator.revalidate(); + } + }; + + // Revalidate when the page becomes visible + document.addEventListener("visibilitychange", handleFocus); + // Revalidate when the window gains focus + window.addEventListener("focus", handleFocus); + + return () => { + document.removeEventListener("visibilitychange", handleFocus); + window.removeEventListener("focus", handleFocus); + }; + }, [onFocus, disabled]); + + return revalidator; +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions.$bulkActionParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions.$bulkActionParam/route.tsx index 197257624f..0bd53caac3 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions.$bulkActionParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions.$bulkActionParam/route.tsx @@ -1,10 +1,9 @@ import { ArrowPathIcon } from "@heroicons/react/20/solid"; -import { Form, useRevalidator } from "@remix-run/react"; +import { Form } from "@remix-run/react"; import { type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { tryCatch } from "@trigger.dev/core"; import type { BulkActionType } from "@trigger.dev/database"; import { motion } from "framer-motion"; -import { useEffect } from "react"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; import { ExitIcon } from "~/assets/icons/ExitIcon"; @@ -18,8 +17,9 @@ import { Paragraph } from "~/components/primitives/Paragraph"; import * as Property from "~/components/primitives/PropertyTable"; import { BulkActionStatusCombo, BulkActionTypeCombo } from "~/components/runs/v3/BulkAction"; import { UserAvatar } from "~/components/UserProfilePhoto"; +import { env } from "~/env.server"; +import { useAutoRevalidate } from "~/hooks/useAutoRevalidate"; import { useEnvironment } from "~/hooks/useEnvironment"; -import { useEventSource } from "~/hooks/useEventSource"; import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server"; @@ -72,7 +72,9 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { throw new Error(error.message); } - return typedjson({ bulkAction: data }); + const autoReloadPollIntervalMs = env.BULK_ACTION_AUTORELOAD_POLL_INTERVAL_MS; + + return typedjson({ bulkAction: data, autoReloadPollIntervalMs }); } catch (error) { console.error(error); throw new Response(undefined, { @@ -130,30 +132,16 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { }; export default function Page() { - const { bulkAction } = useTypedLoaderData(); + const { bulkAction, autoReloadPollIntervalMs } = useTypedLoaderData(); const organization = useOrganization(); const project = useProject(); const environment = useEnvironment(); - const disabled = bulkAction.status !== "PENDING"; - - const streamedEvents = useEventSource( - `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.id}/runs/bulkaction/${bulkAction.friendlyId}/stream`, - { - event: "progress", - disabled, - } - ); - - const revalidation = useRevalidator(); - - useEffect(() => { - if (disabled || streamedEvents === null) { - return; - } - - revalidation.revalidate(); - }, [streamedEvents, disabled]); + useAutoRevalidate({ + interval: autoReloadPollIntervalMs, + onFocus: true, + disabled: bulkAction.status !== "PENDING", + }); return (
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments.$deploymentParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments.$deploymentParam/route.tsx index 069fa61a48..0805338ed7 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments.$deploymentParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments.$deploymentParam/route.tsx @@ -4,7 +4,6 @@ import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { ExitIcon } from "~/assets/icons/ExitIcon"; import { GitMetadata } from "~/components/GitMetadata"; import { RuntimeIcon } from "~/components/RuntimeIcon"; -import { UserAvatar } from "~/components/UserProfilePhoto"; import { AdminDebugTooltip } from "~/components/admin/debugTooltip"; import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel"; import { Badge } from "~/components/primitives/Badge"; @@ -132,7 +131,11 @@ export default function Page() { Deploy {deployment.shortCode} - {deployment.label && {deployment.label}} + {deployment.label && ( + + {deployment.label} + + )} 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 59b6c58ad7..204ffbc58b 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 @@ -61,6 +61,8 @@ import { } from "~/utils/pathBuilder"; import { createSearchParams } from "~/utils/searchParams"; import { compareDeploymentVersions } from "~/v3/utils/deploymentVersions"; +import { useAutoRevalidate } from "~/hooks/useAutoRevalidate"; +import { env } from "~/env.server"; export const meta: MetaFunction = () => { return [ @@ -116,7 +118,9 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { ? result.deployments.find((d) => d.version === version) : undefined; - return typedjson({ ...result, selectedDeployment }); + const autoReloadPollIntervalMs = env.DEPLOYMENTS_AUTORELOAD_POLL_INTERVAL_MS; + + return typedjson({ ...result, selectedDeployment, autoReloadPollIntervalMs }); } catch (error) { console.error(error); throw new Response(undefined, { @@ -137,6 +141,7 @@ export default function Page() { selectedDeployment, connectedGithubRepository, environmentGitHubBranch, + autoReloadPollIntervalMs, } = useTypedLoaderData(); const hasDeployments = totalPages > 0; @@ -144,6 +149,8 @@ export default function Page() { const location = useLocation(); const navigate = useNavigate(); + useAutoRevalidate({ interval: autoReloadPollIntervalMs, onFocus: true }); + // If we have a selected deployment from the version param, show it useEffect(() => { if (selectedDeployment && !deploymentParam) { diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx index 95110490a5..80d6855ce4 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx @@ -8,14 +8,7 @@ import { RectangleStackIcon, } from "@heroicons/react/20/solid"; import { DialogClose } from "@radix-ui/react-dialog"; -import { - Form, - useNavigate, - useNavigation, - useRevalidator, - useSearchParams, - type MetaFunction, -} from "@remix-run/react"; +import { Form, useNavigation, useSearchParams, type MetaFunction } from "@remix-run/react"; import { type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/server-runtime"; import type { RuntimeEnvironmentType } from "@trigger.dev/database"; import { useEffect, useState } from "react"; @@ -30,7 +23,7 @@ import { Feedback } from "~/components/Feedback"; import { PageBody, PageContainer } from "~/components/layout/AppLayout"; import { BigNumber } from "~/components/metrics/BigNumber"; import { Badge } from "~/components/primitives/Badge"; -import { Button, ButtonVariant, LinkButton } from "~/components/primitives/Buttons"; +import { Button, type ButtonVariant, LinkButton } from "~/components/primitives/Buttons"; import { Callout } from "~/components/primitives/Callout"; import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog"; import { FormButtons } from "~/components/primitives/FormButtons"; @@ -56,7 +49,6 @@ import { TooltipTrigger, } from "~/components/primitives/Tooltip"; import { useEnvironment } from "~/hooks/useEnvironment"; -import { useEventSource } from "~/hooks/useEventSource"; import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server"; @@ -74,6 +66,8 @@ import { Header3 } from "~/components/primitives/Headers"; import { Input } from "~/components/primitives/Input"; import { useThrottle } from "~/hooks/useThrottle"; import { RunsIcon } from "~/assets/icons/RunsIcon"; +import { useAutoRevalidate } from "~/hooks/useAutoRevalidate"; +import { env } from "~/env.server"; const SearchParamsSchema = z.object({ query: z.string().optional(), @@ -121,9 +115,12 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { const environmentQueuePresenter = new EnvironmentQueuePresenter(); + const autoReloadPollIntervalMs = env.QUEUES_AUTORELOAD_POLL_INTERVAL_MS; + return typedjson({ ...queues, environment: await environmentQueuePresenter.call(environment), + autoReloadPollIntervalMs, }); } catch (error) { console.error(error); @@ -217,28 +214,23 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { }; export default function Page() { - const { environment, queues, success, pagination, code, totalQueues, hasFilters } = - useTypedLoaderData(); + const { + environment, + queues, + success, + pagination, + code, + totalQueues, + hasFilters, + autoReloadPollIntervalMs, + } = useTypedLoaderData(); const organization = useOrganization(); const project = useProject(); const env = useEnvironment(); const plan = useCurrentPlan(); - // Reload the page periodically - const streamedEvents = useEventSource( - `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${env.slug}/queues/stream`, - { - event: "update", - } - ); - - const revalidation = useRevalidator(); - useEffect(() => { - if (streamedEvents) { - revalidation.revalidate(); - } - }, [streamedEvents]); + useAutoRevalidate({ interval: autoReloadPollIntervalMs, onFocus: true }); const limitStatus = environment.running === environment.concurrencyLimit * environment.burstFactor diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues.stream.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues.stream.tsx deleted file mode 100644 index b4104dfe38..0000000000 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues.stream.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { $replica } from "~/db.server"; -import { env } from "~/env.server"; -import { logger } from "~/services/logger.server"; -import { requireUserId } from "~/services/session.server"; -import { EnvironmentParamSchema } from "~/utils/pathBuilder"; -import { createSSELoader } from "~/utils/sse"; - -export const loader = createSSELoader({ - timeout: env.QUEUE_SSE_AUTORELOAD_TIMEOUT_MS, - interval: env.QUEUE_SSE_AUTORELOAD_INTERVAL_MS, - debug: false, - handler: async ({ request, params }) => { - const userId = await requireUserId(request); - const { projectParam, envParam } = EnvironmentParamSchema.parse(params); - - const environment = await $replica.runtimeEnvironment.findFirst({ - where: { - slug: envParam, - OR: [ - { - type: { - in: ["PREVIEW", "STAGING", "PRODUCTION"], - }, - }, - { - type: "DEVELOPMENT", - orgMember: { - userId, - }, - }, - ], - project: { - slug: projectParam, - }, - }, - }); - - if (!environment) { - throw new Response("Not Found", { status: 404 }); - } - - return { - beforeStream: async () => { - logger.debug("Start queue page SSE session", { - environmentId: environment.id, - }); - }, - initStream: async ({ send }) => { - send({ event: "time", data: new Date().toISOString() }); - }, - iterator: async ({ send }) => { - send({ - event: "update", - data: new Date().toISOString(), - }); - }, - cleanup: async () => { - logger.debug("End queue page SSE session", { - environmentId: environment.id, - }); - }, - }; - }, -}); diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.$bulkActionParam.stream.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.$bulkActionParam.stream.tsx deleted file mode 100644 index b46cbc3851..0000000000 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.$bulkActionParam.stream.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import { z } from "zod"; -import { $replica } from "~/db.server"; -import { env } from "~/env.server"; -import { logger } from "~/services/logger.server"; -import { requireUserId } from "~/services/session.server"; -import { EnvironmentParamSchema } from "~/utils/pathBuilder"; -import { createSSELoader, type SendFunction } from "~/utils/sse"; - -const Params = EnvironmentParamSchema.extend({ - bulkActionParam: z.string(), -}); - -export const loader = createSSELoader({ - timeout: env.DEV_PRESENCE_SSE_TIMEOUT, - interval: env.DEV_PRESENCE_POLL_MS, - debug: false, - handler: async ({ id, controller, debug, request, params }) => { - const userId = await requireUserId(request); - const { organizationSlug, projectParam, envParam, bulkActionParam } = Params.parse(params); - - const environment = await $replica.runtimeEnvironment.findFirst({ - where: { - id: envParam, - project: { - slug: projectParam, - organization: { - members: { - some: { - userId, - }, - }, - }, - }, - }, - }); - - if (!environment) { - throw new Response("Not Found", { status: 404 }); - } - - const getBulkActionProgress = async (send: SendFunction) => { - try { - const bulkAction = await $replica.bulkActionGroup.findFirst({ - select: { - status: true, - successCount: true, - failureCount: true, - }, - where: { - friendlyId: bulkActionParam, - environmentId: environment.id, - }, - }); - - send({ - event: "progress", - data: JSON.stringify({ - status: bulkAction?.status, - successCount: bulkAction?.successCount, - failureCount: bulkAction?.failureCount, - }), - }); - - return bulkAction; - } catch (error) { - // Handle the case where the controller is closed - logger.debug("Failed to send bulk action progress data, stream might be closed", { error }); - return null; - } - }; - - return { - beforeStream: async () => { - logger.debug("Start dev presence listening SSE session", { - environmentId: environment.id, - }); - }, - initStream: async ({ send }) => { - const bulkAction = await getBulkActionProgress(send); - - send({ event: "time", data: new Date().toISOString() }); - - if (bulkAction?.status !== "PENDING") { - return false; - } - - return true; - }, - iterator: async ({ send, date }) => { - const bulkAction = await getBulkActionProgress(send); - - if (bulkAction?.status !== "PENDING") { - return false; - } - - return true; - }, - cleanup: async ({ send }) => { - await getBulkActionProgress(send); - }, - }; - }, -});