diff --git a/apps/webapp/app/presenters/EnvironmentsStreamPresenter.server.ts b/apps/webapp/app/presenters/EnvironmentsStreamPresenter.server.ts new file mode 100644 index 000000000..7e4bb4f23 --- /dev/null +++ b/apps/webapp/app/presenters/EnvironmentsStreamPresenter.server.ts @@ -0,0 +1,143 @@ +import { PrismaClient, prisma } from "~/db.server"; +import { Project } from "~/models/project.server"; +import { User } from "~/models/user.server"; +import { sse } from "~/utils/sse"; + +type EnvironmentSignalsMap = { + [x: string]: { + lastUpdatedAt: number; + lastTotalEndpointUpdatedTime: number; + lastTotalIndexingUpdatedTime: number; + }; +}; + +export class EnvironmentsStreamPresenter { + #prismaClient: PrismaClient; + + constructor(prismaClient: PrismaClient = prisma) { + this.#prismaClient = prismaClient; + } + + public async call({ + request, + userId, + projectSlug, + }: { + request: Request; + userId: User["id"]; + projectSlug: Project["slug"]; + }) { + let lastEnvironmentSignals: EnvironmentSignalsMap; + + return sse({ + request, + run: async (send, stop) => { + const nextEnvironmentSignals = await this.#runForUpdates({ + userId, + projectSlug, + }); + + if (!nextEnvironmentSignals) { + return stop(); + } + + const lastEnvironmentIds = lastEnvironmentSignals + ? Object.keys(lastEnvironmentSignals) + : []; + const nextEnvironmentIds = Object.keys(nextEnvironmentSignals); + + if ( + //push update if the number of environments is different + nextEnvironmentIds.length !== lastEnvironmentIds.length || + //push update if the list of ids is different + lastEnvironmentIds.some((id) => !nextEnvironmentSignals[id]) || + nextEnvironmentIds.some((id) => !lastEnvironmentSignals[id]) || + //push update if any signals changed + nextEnvironmentIds.some( + (id) => + nextEnvironmentSignals[id].lastUpdatedAt !== + lastEnvironmentSignals[id].lastUpdatedAt || + nextEnvironmentSignals[id].lastTotalEndpointUpdatedTime !== + lastEnvironmentSignals[id].lastTotalEndpointUpdatedTime || + nextEnvironmentSignals[id].lastTotalIndexingUpdatedTime !== + lastEnvironmentSignals[id].lastTotalIndexingUpdatedTime + ) + ) { + send({ data: new Date().toISOString() }); + } + + lastEnvironmentSignals = nextEnvironmentSignals; + }, + }); + } + + async #runForUpdates({ + userId, + projectSlug, + }: { + userId: User["id"]; + projectSlug: Project["slug"]; + }) { + const environments = await this.#prismaClient.runtimeEnvironment.findMany({ + select: { + id: true, + updatedAt: true, + endpoints: { + select: { + updatedAt: true, + indexings: { + select: { + updatedAt: true, + }, + }, + }, + }, + }, + where: { + project: { + slug: projectSlug, + }, + organization: { + members: { + some: { + userId, + }, + }, + }, + }, + }); + + if (!environments) return null; + + const environmentSignalsMap = environments.reduce( + (acc, environment) => { + const lastUpdatedAt = environment.updatedAt.getTime(); + const lastTotalEndpointUpdatedTime = environment.endpoints.reduce( + (prev, endpoint) => prev + endpoint.updatedAt.getTime(), + 0 + ); + const lastTotalIndexingUpdatedTime = environment.endpoints.reduce( + (prev, endpoint) => + prev + + endpoint.indexings.reduce( + (prev, indexing) => prev + indexing.updatedAt.getTime(), + 0 + ), + 0 + ); + + return { + ...acc, + [environment.id]: { + lastUpdatedAt, + lastTotalEndpointUpdatedTime, + lastTotalIndexingUpdatedTime, + }, + }; + }, + {} + ); + + return environmentSignalsMap; + } +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.environments.stream/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.environments.stream/route.tsx new file mode 100644 index 000000000..d04597fff --- /dev/null +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.environments.stream/route.tsx @@ -0,0 +1,16 @@ +import { LoaderArgs } from "@remix-run/server-runtime"; +import { EnvironmentsStreamPresenter } from "~/presenters/EnvironmentsStreamPresenter.server"; +import { requireUserId } from "~/services/session.server"; +import { ProjectParamSchema } from "~/utils/pathBuilder"; + +export const loader = async ({ request, params }: LoaderArgs) => { + const userId = await requireUserId(request); + const { projectParam } = ProjectParamSchema.parse(params); + + const presenter = new EnvironmentsStreamPresenter(); + return await presenter.call({ + request, + userId, + projectSlug: projectParam, + }); +}; diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.environments/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.environments/route.tsx index 511038506..4eda0abb6 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.environments/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.environments/route.tsx @@ -1,6 +1,8 @@ import { LoaderArgs } from "@remix-run/server-runtime"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; +import { useRevalidator } from "@remix-run/react"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; +import { useEventSource } from "remix-utils"; import { EnvironmentLabel, environmentTitle, @@ -32,7 +34,10 @@ import { } from "~/presenters/EnvironmentsPresenter.server"; import { requireUserId } from "~/services/session.server"; import { Handle } from "~/utils/handle"; -import { ProjectParamSchema } from "~/utils/pathBuilder"; +import { + ProjectParamSchema, + projectEnvironmentsStreamingPath, +} from "~/utils/pathBuilder"; import { requestUrl } from "~/utils/requestUrl.server"; import { RuntimeEnvironmentType } from "../../../../../packages/database/src"; import { ConfigureEndpointSheet } from "./ConfigureEndpointSheet"; @@ -43,6 +48,8 @@ import { HowToUseApiKeysAndEndpoints, } from "~/components/helpContent/HelpContentText"; import { BreadcrumbLink } from "~/components/navigation/NavBar"; +import { useOrganization } from "~/hooks/useOrganizations"; +import { useProject } from "~/hooks/useProject"; export const loader = async ({ request, params }: LoaderArgs) => { const userId = await requireUserId(request); @@ -102,6 +109,23 @@ export default function Page() { }; }, [selected, clients]); + const organization = useOrganization(); + const project = useProject(); + + const revalidator = useRevalidator(); + const events = useEventSource( + projectEnvironmentsStreamingPath(organization, project), + { event: "message" } + ); + + useEffect(() => { + if (events !== null) { + revalidator.revalidate(); + } + // WARNING Don't put the revalidator in the useEffect deps array or bad things will happen + }, [events]); // eslint-disable-line react-hooks/exhaustive-deps + + return ( diff --git a/apps/webapp/app/utils/pathBuilder.ts b/apps/webapp/app/utils/pathBuilder.ts index 437cac82d..16c500d3a 100644 --- a/apps/webapp/app/utils/pathBuilder.ts +++ b/apps/webapp/app/utils/pathBuilder.ts @@ -143,6 +143,13 @@ export function projectEnvironmentsPath( return `${projectPath(organization, project)}/environments`; } +export function projectEnvironmentsStreamingPath( + organization: OrgForPath, + project: ProjectForPath +) { + return `${projectEnvironmentsPath(organization, project)}/stream`; +} + export function endpointStreamingPath(environment: { id: string }) { return `/resources/environments/${environment.id}/endpoint/stream`; }